From 705234b8ae7419897cd4156cc3e6b548c7c95b54 Mon Sep 17 00:00:00 2001 From: YinLin <91457024+Yinlin124@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:39:07 +0800 Subject: [PATCH 01/13] feat: collaborative-editing modules init and import quill-cursor modules (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: collaborative-editing modules init and import quill-cursor modules * chore: 修改 collaborative-editing 依赖到 devDependencies * feat:把 collaborative-editing 模块改为为手动注册 * docs: 基础 collaborative-editing 社区文档 * docs: 新增协同编辑演示 * fix: 新增 awareness 构建异常处理,去除 webrtc 和 websocket 中无用的 getter 函数以及 document 命名错误 * docs:修改文档顺序,修复 roomName * docs:demo 新增表格协同 --- .../docs/fluent-editor/.vitepress/sidebar.ts | 1 + .../demos/collaborative-editing.vue | 119 +++++++++++++++ .../docs/demo/collaborative-editing.md | 101 +++++++++++++ packages/fluent-editor/package.json | 9 +- .../awareness/awareness.ts | 30 ++++ .../collaborative-editing/awareness/index.ts | 2 + .../awareness/y-indexeddb.ts | 12 ++ .../collaborative-editing.ts | 110 ++++++++++++++ .../modules/collaborative-editing/index.ts | 2 + .../modules/collaborative-editing/module.ts | 19 +++ .../provider/customProvider.ts | 57 +++++++ .../collaborative-editing/provider/index.ts | 3 + .../collaborative-editing/provider/webrtc.ts | 125 +++++++++++++++ .../provider/websocket.ts | 142 ++++++++++++++++++ .../modules/collaborative-editing/types.ts | 44 ++++++ packages/fluent-editor/src/modules/index.ts | 1 + 16 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 packages/docs/fluent-editor/demos/collaborative-editing.vue create mode 100644 packages/docs/fluent-editor/docs/demo/collaborative-editing.md create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/awareness/index.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/awareness/y-indexeddb.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/index.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/module.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/provider/customProvider.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/provider/index.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/provider/webrtc.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/provider/websocket.ts create mode 100644 packages/fluent-editor/src/modules/collaborative-editing/types.ts diff --git a/packages/docs/fluent-editor/.vitepress/sidebar.ts b/packages/docs/fluent-editor/.vitepress/sidebar.ts index 41ec10ba..61606db5 100644 --- a/packages/docs/fluent-editor/.vitepress/sidebar.ts +++ b/packages/docs/fluent-editor/.vitepress/sidebar.ts @@ -26,6 +26,7 @@ export function sidebar() { { text: '只读模式', link: '/docs/demo/readonly' }, { text: '模拟语雀文档', link: 'https://opentiny.github.io/tiny-editor/projects' }, { text: '图片工具栏', link: '/docs/demo/image-tool' }, + { text: '协同编辑', link: '/docs/demo/collaborative-editing' }, ], }, { diff --git a/packages/docs/fluent-editor/demos/collaborative-editing.vue b/packages/docs/fluent-editor/demos/collaborative-editing.vue new file mode 100644 index 00000000..9fe280e0 --- /dev/null +++ b/packages/docs/fluent-editor/demos/collaborative-editing.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/docs/fluent-editor/docs/demo/collaborative-editing.md b/packages/docs/fluent-editor/docs/demo/collaborative-editing.md new file mode 100644 index 00000000..7ef36887 --- /dev/null +++ b/packages/docs/fluent-editor/docs/demo/collaborative-editing.md @@ -0,0 +1,101 @@ +# 协作编辑 + +TinyEditor 支持多人实时协作编辑功能,基于 Yjs 实现,支持 WebSocket 和 WebRTC 等连接方式。 + +## 在线协同演示 + +下面是一个完整的协同编辑演示,包含两个编辑器实例,模拟不同用户的协同编辑场景: + +:::demo src=demos/collaborative-editing.vue +::: + +## 前端依赖安装 + +**基础协作编辑(必需):** + +```bash +npm i quill-cursors y-protocols y-quill yjs +``` + +**连接支持:** provider 选择一种即可(要与对应后端协议匹配) + +```bash +npm i y-websocket +npm i y-webrtc +``` + +**离线功能支持:** + +```bash +npm i y-indexeddb +``` + +## 后端配置 + +选择其中一种作为后端服务支持 + +### WebSocket 服务器 + +可以使用 [y-websocket-server](https://github.com/yjs/y-websocket-server/) 快速搭建 WebSocket 服务器。 + +```shell +HOST=localhost PORT=1234 YPERSISTENCE=./dbDir npx y-websocket +``` + +### WebRTC 服务器 + +可以使用 [y-webrtc-server](https://github.com/yjs/y-webrtc-server/) 快速搭建 WebRTC 服务器。 + +```shell +HOST=localhost PORT=4444 npx y-webrtc +``` + +## 基本用法 + +通过配置 `collaboration` 模块可以开启协作编辑功能: + +```javascript +import FluentEditor from '@opentiny/fluent-editor' +CollaborationModule.register() +FluentEditor.register('modules/collaboration', CollaborationModule, true) + +const editor = new FluentEditor('#editor', { + theme: 'snow', + modules: { + cursors: true, + collaboration: { + cursors: true, + provider: { + // WebSocket 配置 + type: 'websocket', + options: { + serverUrl: 'ws://localhost:1234', + roomName: 'my-document', + }, + // 或者 WebRTC 配置 + // type: 'webrtc', + // options: { + // roomName: 'my-document', + // signaling: ['ws://localhost:4444'], + // }, + }, + awareness: { + state: { + name: `user${Math.random().toString(36).substring(2, 15)}`, + color: 'red', + }, + }, + offline: true, // { name: 'my-document' }, + onConnect: () => { + console.log('connected') + }, + onDisconnect: () => { + console.log('disconnected') + }, + onSyncChange: (isSynced) => { + console.log('synced', isSynced) + }, + }, + }, +}) +``` diff --git a/packages/fluent-editor/package.json b/packages/fluent-editor/package.json index 104da133..87518091 100644 --- a/packages/fluent-editor/package.json +++ b/packages/fluent-editor/package.json @@ -49,12 +49,19 @@ "jest": "^26.6.3", "mathlive": "^0.101.0", "parchment": "^3.0.0", + "quill-cursors": "^4.0.4", "sass": "^1.47.0", "shelljs": "^0.8.4", "ts-jest": "^26.5.6", "ts-node": "^9.1.1", "typescript": "^4.9.5", "vite": "^5.0.0", - "vite-plugin-dts": "^4.3.0" + "vite-plugin-dts": "^4.3.0", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "y-quill": "^1.0.0", + "y-webrtc": "10.3.0", + "y-websocket": "^3.0.0", + "yjs": "^13.6.27" } } diff --git a/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts b/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts new file mode 100644 index 00000000..1ef41e68 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts @@ -0,0 +1,30 @@ +import type { Awareness } from 'y-protocols/awareness' + +export interface AwarenessState { + name?: string + color?: string +} + +export interface AwarenessEvents { + change?: (changes: { added: number[], updated: number[], removed: number[] }, transactionOrigin: any) => void + update?: ({ added, updated, removed }: { added: number[], updated: number[], removed: number[] }, origin: any) => void + destroy?: () => void +} + +export interface AwarenessOptions { + state?: AwarenessState + events?: AwarenessEvents + timeout?: number | undefined +} + +export function setupAwareness(options?: AwarenessOptions, defaultAwareness?: Awareness): Awareness | null { + if (!defaultAwareness) return null + + const awareness = defaultAwareness + + if (options?.state) { + awareness.setLocalStateField('user', options.state) + } + + return awareness +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/awareness/index.ts b/packages/fluent-editor/src/modules/collaborative-editing/awareness/index.ts new file mode 100644 index 00000000..29d9bcf4 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/awareness/index.ts @@ -0,0 +1,2 @@ +export * from './awareness' +export * from './y-indexeddb' diff --git a/packages/fluent-editor/src/modules/collaborative-editing/awareness/y-indexeddb.ts b/packages/fluent-editor/src/modules/collaborative-editing/awareness/y-indexeddb.ts new file mode 100644 index 00000000..ef8f22f7 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/awareness/y-indexeddb.ts @@ -0,0 +1,12 @@ +import type { Doc } from 'yjs' +import { IndexeddbPersistence } from 'y-indexeddb' + +export interface IndexedDBOptions { + dbName: string +} + +export function setupIndexedDB(doc: Doc, options?: IndexedDBOptions) { + const id = 'tiny-editor' + const dbName = options?.dbName || 'document' + return new IndexeddbPersistence(`${id}-${dbName}`, doc) +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts b/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts new file mode 100644 index 00000000..0b96f2e8 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts @@ -0,0 +1,110 @@ +import type FluentEditor from '../../fluent-editor' +import type { YjsOptions } from './types' +import { Awareness } from 'y-protocols/awareness' +import { QuillBinding } from 'y-quill' +import * as Y from 'yjs' +import { setupAwareness } from './awareness' +import { setupIndexedDB } from './awareness/y-indexeddb' +import { createProvider } from './provider/customProvider' + +export class CollaborativeEditor { + private ydoc: Y.Doc = new Y.Doc() + private provider: any + private awareness: Awareness + private cursors: any + private _isConnected = false + private _isSynced = false + + constructor( + public quill: FluentEditor, + private options: YjsOptions, + ) { + this.ydoc = this.options.ydoc || new Y.Doc() + this.cursors = this.quill.getModule('cursors') + + if (this.options.awareness) { + const awareness = setupAwareness(this.options.awareness, new Awareness(this.ydoc)) + if (!awareness) { + throw new Error('Failed to initialize awareness') + } + this.awareness = awareness + } + else { + this.awareness = new Awareness(this.ydoc) + } + + if (this.options.provider) { + const providerConfig = this.options.provider + try { + const provider = createProvider({ + doc: this.ydoc, + options: providerConfig.options, + type: providerConfig.type, + awareness: this.awareness, + onConnect: () => { + this._isConnected = true + this.options.onConnect?.() + }, + onDisconnect: () => { + this._isConnected = false + this.options.onDisconnect?.() + }, + onError: (error) => { + this.options.onError?.(error) + }, + onSyncChange: (isSynced) => { + this._isSynced = isSynced + this.options.onSyncChange?.(isSynced) + }, + }) + this.provider = provider + } + catch (error) { + console.warn( + `[yjs] Error creating provider of type ${providerConfig.type}:`, + error, + ) + } + } + + if (this.provider) { + const ytext = this.ydoc.getText('tiny-editor') + new QuillBinding( + ytext, + this.quill, + this.awareness, + ) + } + else { + console.error('Failed to initialize collaborative editor: no valid provider configured') + } + + if (this.options.offline) { + setupIndexedDB(this.ydoc, typeof this.options.offline === 'object' ? this.options.offline : undefined) + } + } + + public getAwareness() { + return this.awareness + } + + public getProvider() { + return this.provider + } + + public getYDoc() { + return this.ydoc + } + + get isConnected() { + return this._isConnected + } + + get isSynced() { + return this._isSynced + } + + public getCursors() { + return this.cursors + } +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/index.ts b/packages/fluent-editor/src/modules/collaborative-editing/index.ts new file mode 100644 index 00000000..29f76e05 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/index.ts @@ -0,0 +1,2 @@ +export * from './module' +export * from './types' diff --git a/packages/fluent-editor/src/modules/collaborative-editing/module.ts b/packages/fluent-editor/src/modules/collaborative-editing/module.ts new file mode 100644 index 00000000..73dfc3e9 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/module.ts @@ -0,0 +1,19 @@ +import QuillCursors from 'quill-cursors' +import FluentEditor from '../../fluent-editor' +import { CollaborativeEditor } from './collaborative-editing' + +export class CollaborationModule { + private collaborativeEditor: CollaborativeEditor + + static register() { + FluentEditor.register('modules/cursors', QuillCursors, true) + } + + constructor(public quill: FluentEditor, public options: any) { + this.collaborativeEditor = new CollaborativeEditor(quill, options) + } + + public getCursors() { + return this.collaborativeEditor.getCursors() + } +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/provider/customProvider.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/customProvider.ts new file mode 100644 index 00000000..6b8f01b8 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/customProvider.ts @@ -0,0 +1,57 @@ +import type { Awareness } from 'y-protocols/awareness' +import type * as Y from 'yjs' +import type { ProviderEventHandlers } from '../types' +import { WebRTCProviderWrapper } from './webrtc' +import { WebsocketProviderWrapper } from './websocket' + +export type ProviderRegistry = Record + +export type ProviderConstructor = new ( + props: ProviderConstructorProps +) => UnifiedProvider + +export type ProviderConstructorProps = { + options: T + awareness?: Awareness + doc?: Y.Doc +} & ProviderEventHandlers + +export interface UnifiedProvider { + awareness: Awareness + document: Y.Doc + type: 'webrtc' | 'websocket' | string + connect: () => void + destroy: () => void + disconnect: () => void + isConnected: boolean + isSynced: boolean +} + +const providerRegistry: ProviderRegistry = { + websocket: WebsocketProviderWrapper, + webrtc: WebRTCProviderWrapper, +} + +export function registerProviderType(type: string, providerClass: ProviderConstructor) { + providerRegistry[type as string] + = providerClass as ProviderConstructor +} + +export function getProviderClass(type: string): ProviderConstructor | undefined { + return providerRegistry[type] +} + +export function createProvider({ + type, + ...props +}: ProviderConstructorProps & { + type: string +}) { + const ProviderClass = getProviderClass(type) + + if (!ProviderClass) { + throw new Error(`Provider type "${type}" not found in registry`) + } + + return new ProviderClass(props) +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/provider/index.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/index.ts new file mode 100644 index 00000000..eaf2a5ae --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/index.ts @@ -0,0 +1,3 @@ +export * from './customProvider' +export * from './webrtc' +export * from './websocket' diff --git a/packages/fluent-editor/src/modules/collaborative-editing/provider/webrtc.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/webrtc.ts new file mode 100644 index 00000000..7ebdd6ed --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/webrtc.ts @@ -0,0 +1,125 @@ +import type { Awareness } from 'y-protocols/awareness' +import type { ProviderEventHandlers } from '../types' +import type { UnifiedProvider } from './customProvider' +import { WebrtcProvider } from 'y-webrtc' +import * as Y from 'yjs' + +export interface WebRTCProviderOptions { + roomName: string + filterBcConns?: boolean + maxConns?: number + password?: string + peerOpts?: Record + signaling?: string[] +} + +export class WebRTCProviderWrapper implements UnifiedProvider { + private provider: WebrtcProvider + private _isConnected = false + private _isSynced = false + + private onConnect?: () => void + private onDisconnect?: () => void + private onError?: (error: Error) => void + private onSyncChange?: (isSynced: boolean) => void + + document: Y.Doc + awareness: Awareness + type: 'webrtc' + + connect = () => { + try { + this.provider.connect() + } + catch (error) { + console.warn('[yjs] Error connecting WebRTC provider:', error) + } + } + + destroy = () => { + try { + this.provider.destroy() + } + catch (error) { + console.warn('[yjs] Error destroying WebRTC provider:', error) + } + } + + disconnect = () => { + try { + this.provider.disconnect() + this._isConnected = false + this._isSynced = false + } + catch (error) { + console.warn('[yjs] Error disconnecting WebRTC provider:', error) + } + } + + constructor({ + awareness, + doc, + options, + onConnect, + onDisconnect, + onError, + onSyncChange, + }: { + options: WebRTCProviderOptions + awareness?: Awareness + doc?: Y.Doc + } & ProviderEventHandlers) { + this.onConnect = onConnect + this.onDisconnect = onDisconnect + this.onError = onError + this.onSyncChange = onSyncChange + + this.document = doc || new Y.Doc() + try { + this.provider = new WebrtcProvider(options.roomName, this.document, { + awareness, + ...options, + }) + + this.provider.on('status', (status: { connected: boolean }) => { + const wasConnected = this._isConnected + this._isConnected = status.connected + if (status.connected) { + if (!wasConnected) { + this.onConnect?.() + } + if (!this._isSynced) { + this._isSynced = true + this.onSyncChange?.(true) + } + } + else { + if (wasConnected) { + this.onDisconnect?.() + + if (this._isSynced) { + this._isSynced = false + this.onSyncChange?.(false) + } + } + } + }) + } + catch (error) { + console.warn('[yjs] Error creating WebRTC provider:', error) + onError?.(error instanceof Error ? error : new Error(String(error))) + } + } + + get isConnected() { + return this._isConnected + } + + get isSynced() { + return this._isSynced + } + + getProvider() { + return this.provider + } +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/provider/websocket.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/websocket.ts new file mode 100644 index 00000000..7be52e66 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/websocket.ts @@ -0,0 +1,142 @@ +import type { ProviderEventHandlers } from '../types' +import type { UnifiedProvider } from './customProvider' +import { Awareness } from 'y-protocols/awareness' +import { WebsocketProvider } from 'y-websocket' +import * as Y from 'yjs' + +export interface WebsocketProviderOptions extends ProviderEventHandlers { + serverUrl: string + roomName: string + connect?: boolean + awareness?: any + params?: Record + protocols?: string[] + WebSocketPolyfill?: typeof WebSocket + resyncInterval?: number + maxBackoffTime?: number + disableBc?: boolean +} + +export class WebsocketProviderWrapper implements UnifiedProvider { + private provider: WebsocketProvider + + private _isConnected = false + private _isSynced = false + + private onConnect?: () => void + private onDisconnect?: () => void + private onError?: (error: Error) => void + private onSyncChange?: (isSynced: boolean) => void + + document: Y.Doc + awareness: Awareness + type: 'websocket' + + connect = () => { + try { + this.provider.connect() + } + catch (error) { + console.warn('[yjs] Error connecting WebSocket provider:', error) + } + } + + destroy = () => { + try { + this.provider.destroy() + } + catch (error) { + console.warn('[yjs] Error destroying WebSocket provider:', error) + } + } + + disconnect = () => { + try { + this.provider.disconnect() + const wasSynced = this._isSynced + + this._isConnected = false + this._isSynced = false + + if (wasSynced) { + this.onSyncChange?.(false) + } + } + catch (error) { + console.warn('[yjs] Error disconnecting WebSocket provider:', error) + } + } + + constructor({ + awareness, + doc, + options, + onConnect, + onDisconnect, + onError, + onSyncChange, + }: { + options: WebsocketProviderOptions + awareness?: Awareness + doc?: Y.Doc + } & ProviderEventHandlers) { + this.onConnect = onConnect + this.onDisconnect = onDisconnect + this.onError = onError + this.onSyncChange = onSyncChange + + this.document = doc || new Y.Doc() + this.awareness = awareness ?? new Awareness(this.document) + try { + this.provider = new WebsocketProvider( + options.serverUrl, + options.roomName, + this.document, + { + awareness: this.awareness, + ...options, + }, + ) + + this.provider.on('status', (event: { status: 'connected' | 'disconnected' | 'connecting' }) => { + const wasConnected = this._isConnected + this._isConnected = event.status === 'connected' + + if (event.status === 'connected') { + if (!wasConnected) { + this.onConnect?.() + } + if (!this._isSynced) { + this._isSynced = true + this.onSyncChange?.(true) + } + } + else if (event.status === 'disconnected') { + if (wasConnected) { + this.onDisconnect?.() + if (this._isSynced) { + this._isSynced = false + this.onSyncChange?.(false) + } + } + } + }) + } + catch (error) { + console.warn('[yjs] Error creating WebSocket provider:', error) + onError?.(error instanceof Error ? error : new Error(String(error))) + } + } + + get isConnected() { + return this._isConnected + } + + get isSynced() { + return this._isSynced + } + + getProvider() { + return this.provider + } +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/types.ts b/packages/fluent-editor/src/modules/collaborative-editing/types.ts new file mode 100644 index 00000000..cd1bc9a8 --- /dev/null +++ b/packages/fluent-editor/src/modules/collaborative-editing/types.ts @@ -0,0 +1,44 @@ +import type * as Y from 'yjs' +import type { AwarenessOptions, IndexedDBOptions } from './awareness' +import type { WebRTCProviderOptions, WebsocketProviderOptions } from './provider' + +export interface ProviderEventHandlers { + onConnect?: () => void + onDisconnect?: () => void + onError?: (error: Error) => void + onSyncChange?: (isSynced: boolean) => void +} +export interface BaseYjsProviderConfig extends ProviderEventHandlers { + options: Record + type: string +} + +export type WebRTCProviderConfig = BaseYjsProviderConfig & { + options: WebRTCProviderOptions + type: 'webrtc' +} +export type WebsocketProviderConfig = BaseYjsProviderConfig & { + options: WebsocketProviderOptions + type: 'websocket' +} + +export type CustomProviderConfig = BaseYjsProviderConfig & { + options: Record + type: string +} + +export type CursorsConfig = boolean | object + +export interface YjsOptions { + ydoc?: Y.Doc + provider: (WebRTCProviderConfig | WebsocketProviderConfig | CustomProviderConfig) + awareness?: AwarenessOptions + offline?: boolean | IndexedDBOptions + cursors?: CursorsConfig + + // callback + onConnect?: () => void + onDisconnect?: () => void + onError?: (error) => void + onSyncChange?: (isSynced: boolean) => void +} diff --git a/packages/fluent-editor/src/modules/index.ts b/packages/fluent-editor/src/modules/index.ts index 99e44b14..ab4bd1be 100644 --- a/packages/fluent-editor/src/modules/index.ts +++ b/packages/fluent-editor/src/modules/index.ts @@ -1,3 +1,4 @@ +export * from './collaborative-editing' export * from './counter' export * from './custom-clipboard' export * from './custom-image' From d150a230da3dac7c7c4470347045eade9e835644 Mon Sep 17 00:00:00 2001 From: YinLin <91457024+Yinlin124@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:29:01 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=E9=BB=98=E8=AE=A4=E5=BC=80?= =?UTF-8?q?=E5=90=AF=E5=85=89=E6=A0=87=E5=92=8C=E7=A6=BB=E7=BA=BF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E8=87=AA=E5=AE=9A=E4=B9=89=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=96=87=E6=A1=A3=EF=BC=8Cdemo=E9=87=87=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E9=83=A8=E7=BD=B2=E5=90=8E=E7=AB=AF=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 默认开启光标和离线支持,自定义持久化文档,demo采用自部署后端代替 * fix:删除用户手动注册光标模块逻辑与相关文档 * refactor:新增QuillCursors types,新增监听回收 * fix: 修复demo公式 katex 依赖 * fix:修复用户断开光标无法移除问题 * fix:修复光标类型检测,默认开启光标逻辑 * fix: 删除无使用引入 * fix: 修复截图模块依赖 --- .../demos/collaborative-editing.vue | 165 ++++++++------- .../docs/demo/collaborative-editing.md | 189 ++++++++++++++---- .../awareness/awareness.ts | 66 ++++++ .../collaborative-editing.ts | 33 ++- .../modules/collaborative-editing/module.ts | 11 +- 5 files changed, 321 insertions(+), 143 deletions(-) diff --git a/packages/docs/fluent-editor/demos/collaborative-editing.vue b/packages/docs/fluent-editor/demos/collaborative-editing.vue index 9fe280e0..d694feef 100644 --- a/packages/docs/fluent-editor/demos/collaborative-editing.vue +++ b/packages/docs/fluent-editor/demos/collaborative-editing.vue @@ -1,22 +1,40 @@ - + diff --git a/packages/docs/fluent-editor/docs/demo/collaborative-editing.md b/packages/docs/fluent-editor/docs/demo/collaborative-editing.md index 7ef36887..85b9d1b5 100644 --- a/packages/docs/fluent-editor/docs/demo/collaborative-editing.md +++ b/packages/docs/fluent-editor/docs/demo/collaborative-editing.md @@ -2,82 +2,55 @@ TinyEditor 支持多人实时协作编辑功能,基于 Yjs 实现,支持 WebSocket 和 WebRTC 等连接方式。 -## 在线协同演示 - -下面是一个完整的协同编辑演示,包含两个编辑器实例,模拟不同用户的协同编辑场景: - -:::demo src=demos/collaborative-editing.vue -::: - ## 前端依赖安装 **基础协作编辑(必需):** ```bash -npm i quill-cursors y-protocols y-quill yjs +npm i quill-cursors y-protocols y-quill yjs y-indexeddb ``` -**连接支持:** provider 选择一种即可(要与对应后端协议匹配) +**连接支持**:选择一种即可(要与对应后端协议匹配) ```bash npm i y-websocket -npm i y-webrtc ``` -**离线功能支持:** - ```bash -npm i y-indexeddb +npm i y-webrtc ``` -## 后端配置 - -选择其中一种作为后端服务支持 +## 在线协同演示 -### WebSocket 服务器 +下面是一个完整的协同编辑演示: -可以使用 [y-websocket-server](https://github.com/yjs/y-websocket-server/) 快速搭建 WebSocket 服务器。 +:::demo src=demos/collaborative-editing.vue +::: -```shell -HOST=localhost PORT=1234 YPERSISTENCE=./dbDir npx y-websocket -``` +## 基本用法 -### WebRTC 服务器 +通过配置 `collaborative-editing` 模块可以开启协作编辑功能: -可以使用 [y-webrtc-server](https://github.com/yjs/y-webrtc-server/) 快速搭建 WebRTC 服务器。 +模块注册: -```shell -HOST=localhost PORT=4444 npx y-webrtc +```javascript +import FluentEditor from '@opentiny/fluent-editor' +FluentEditor.register('modules/collaborative-editing', CollaborationModule, true) ``` -## 基本用法 - -通过配置 `collaboration` 模块可以开启协作编辑功能: +编辑器配置: ```javascript -import FluentEditor from '@opentiny/fluent-editor' -CollaborationModule.register() -FluentEditor.register('modules/collaboration', CollaborationModule, true) - const editor = new FluentEditor('#editor', { theme: 'snow', modules: { - cursors: true, - collaboration: { - cursors: true, + 'collaborative-editing': { provider: { - // WebSocket 配置 type: 'websocket', options: { serverUrl: 'ws://localhost:1234', roomName: 'my-document', }, - // 或者 WebRTC 配置 - // type: 'webrtc', - // options: { - // roomName: 'my-document', - // signaling: ['ws://localhost:4444'], - // }, }, awareness: { state: { @@ -85,7 +58,6 @@ const editor = new FluentEditor('#editor', { color: 'red', }, }, - offline: true, // { name: 'my-document' }, onConnect: () => { console.log('connected') }, @@ -99,3 +71,134 @@ const editor = new FluentEditor('#editor', { }, }) ``` + +## 后端集成 + +选择其中一种作为后端服务支持 + +### WebSocket 服务器 + +可以使用 [y-websocket-server](https://github.com/yjs/y-websocket-server/) 快速搭建 WebSocket 服务器。 + +```shell +git clone https://github.com/yjs/y-websocket-server.git +cd y-websocket-server +pnpm i +HOST=localhost PORT=1234 YPERSISTENCE=./dbDir npx y-websocket +``` + +`HOST`指定可访问地址,`PORT`指定暴露端口,`YPERSISTENCE`指定持久化目录。 + +### WebRTC 服务器 + +可以使用 [y-webrtc-server](https://github.com/yjs/y-webrtc/) 快速搭建 WebRTC 服务器。 + +```shell +git clone https://github.com/yjs/y-webrtc.git +cd y-webrtc +pnpm i +HOST=localhost PORT=4444 npx y-webrtc +``` + +## 自定义持久化 + +如果你有需要自定义持久化(存储到第三方数据库服务器),可以参考 [y-websocket-custom-persistence](https://github.com/Yinlin124/y-websocket-custom-persistence), 对 y-websocket-server 进行修改 + +```shell +git clone https://github.com/Yinlin124/y-websocket-custom-persistence.git +cd y-websocket-custom-persistence +pnpm i +cp .env.example .env +pnpm start +``` + +## 配置说明 + +### 配置参数表格 + +| 参数 | 类型 | 必填 | 说明 | +| -------------- | ------------------------------------------------------------------------- | ---- | ------------------- | +| `provider` | `WebRTCProviderConfig \| WebsocketProviderConfig \| CustomProviderConfig` | 是 | 连接提供者配置 | +| `awareness` | `AwarenessOptions` | 否 | 用户感知配置 | +| `cursors` | `boolean \| object` | 否 | 光标显示配置 | +| `ydoc` | `Y.Doc` | 否 | 自定义 Yjs 文档实例 | +| `onConnect` | `() => void` | 否 | 连接成功回调 | +| `onDisconnect` | `() => void` | 否 | 连接断开回调 | +| `onError` | `(error: Error) => void` | 否 | 错误处理回调 | +| `onSyncChange` | `(isSynced: boolean) => void` | 否 | 同步状态变化回调 | + +### provider(连接提供者) + +**WebSocket 提供者配置:** + +```javascript +provider: { + type: 'websocket', + options: { + serverUrl: 'ws://localhost:1234', // WebSocket 服务器地址 + roomName: 'my-document', // 房间名称 + connect: true, // 是否自动连接,默认 true + params: {}, // 连接参数 + protocols: [], // WebSocket 协议 + resyncInterval: -1, // 重新同步间隔(毫秒) + maxBackoffTime: 2500, // 最大退避时间 + disableBc: false // 是否禁用广播通道 + } +} +``` + +**WebRTC 提供者配置:** + +```javascript +provider: { + type: 'webrtc', + options: { + signaling: ['wss://signaling-server.com','wss://localhost:4444'], // 信令服务器列表 + roomName: 'my-document', // 房间名称 + password: null, // 房间密码 + awareness: true, // 是否启用感知 + maxConns: 20, // 最大连接数 + filterBcConns: true, // 是否过滤广播连接 + peerOpts: {} // WebRTC 对等连接选项 + } +} +``` + +**自定义提供者配置:(待写)** + +### awareness(用户感知) + +用于配置用户状态信息,让其他用户能够看到当前用户的信息: + +```javascript +awareness: { + state: { + name: 'John Doe', // 用户名称,显示在光标旁 + color: '#ff6b6b' // 用户颜色,用于光标和选区 + }, + timeout: 30000, // 用户状态超时时间(毫秒) +} +``` + +#### 事件回调 + +| 回调函数 | 参数 | 说明 | +| -------------- | ------------------- | ----------------------------------------- | +| `onConnect` | 无 | 成功连接到协作服务器时触发 | +| `onDisconnect` | 无 | 与协作服务器连接断开时触发 | +| `onError` | `error: Error` | 发生错误时触发,包含错误信息 | +| `onSyncChange` | `isSynced: boolean` | 文档同步状态变化时触发,`true` 表示已同步 | + +#### 光标配置 + +具体含义可参照 [quill-cursors](https://github.com/reedsy/quill-cursors) + +```javascript +cursors: { + template: '
...
', + hideDelayMs: 5000, + hideSpeedMs: 0, + selectionChangeSource: null, + transformOnTextChange: true, +}, +``` diff --git a/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts b/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts index 1ef41e68..a736c7bb 100644 --- a/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts +++ b/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts @@ -1,4 +1,5 @@ import type { Awareness } from 'y-protocols/awareness' +import type FluentEditor from '../../../core/fluent-editor' export interface AwarenessState { name?: string @@ -28,3 +29,68 @@ export function setupAwareness(options?: AwarenessOptions, defaultAwareness?: Aw return awareness } + +interface QuillCursors { + createCursor: (id: string, name: string, color: string) => any + moveCursor: (id: string, range: { index: number, length: number }) => void + removeCursor: (id: string) => void +} + +interface QuillEditor { + on: (event: 'selection-change', handler: (range: { index: number, length: number } | null) => void) => void + off: (event: 'selection-change', handler: Function) => void +} + +export function bindAwarenessToCursors( + awareness: Awareness, + cursorsModule: QuillCursors, + quill: FluentEditor, +): (() => void) | void { + if (!cursorsModule || !awareness) return + + const awarenessChangeHandler = (changes?: { added: number[], updated: number[], removed: number[] }) => { + if (changes?.removed?.length) { + changes.removed.forEach((clientId) => { + cursorsModule.removeCursor(clientId.toString()) + }) + } + + const states = awareness.getStates() + states.forEach((state, clientId) => { + if (clientId === awareness.clientID) return + + if (state.cursor) { + cursorsModule.createCursor( + clientId.toString(), + state.user?.name || `User ${clientId}`, + state.user?.color || '#ff6b6b', + ) + cursorsModule.moveCursor(clientId.toString(), state.cursor) + } + else { + cursorsModule.removeCursor(clientId.toString()) + } + }) + } + + const selectionChangeHandler = (range) => { + if (range) { + awareness.setLocalStateField('cursor', { + index: range.index, + length: range.length, + }) + } + else { + awareness.setLocalStateField('cursor', null) + } + } + + awareness.on('change', awarenessChangeHandler) + quill.on('selection-change', selectionChangeHandler) + awarenessChangeHandler() + + return () => { + awareness.off('change', awarenessChangeHandler) + quill.off('selection-change', selectionChangeHandler) + } +} diff --git a/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts b/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts index 0b96f2e8..c96cc07e 100644 --- a/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts +++ b/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts @@ -1,26 +1,35 @@ -import type FluentEditor from '../../fluent-editor' +import type { UnifiedProvider } from './provider/customProvider' import type { YjsOptions } from './types' +import QuillCursors from 'quill-cursors' import { Awareness } from 'y-protocols/awareness' import { QuillBinding } from 'y-quill' import * as Y from 'yjs' -import { setupAwareness } from './awareness' +import FluentEditor from '../../fluent-editor' +import { bindAwarenessToCursors, setupAwareness } from './awareness' import { setupIndexedDB } from './awareness/y-indexeddb' import { createProvider } from './provider/customProvider' export class CollaborativeEditor { private ydoc: Y.Doc = new Y.Doc() - private provider: any + private provider: UnifiedProvider private awareness: Awareness - private cursors: any + private cursors: QuillCursors | null private _isConnected = false private _isSynced = false + private cleanupBindings: (() => void) | null = null constructor( public quill: FluentEditor, - private options: YjsOptions, + public options: YjsOptions, ) { + FluentEditor.register('modules/cursors', QuillCursors, true) + this.ydoc = this.options.ydoc || new Y.Doc() - this.cursors = this.quill.getModule('cursors') + + if (this.options.cursors !== false) { + const cursorsOptions = typeof this.options.cursors === 'object' ? this.options.cursors : {} + this.cursors = new QuillCursors(quill, cursorsOptions) + } if (this.options.awareness) { const awareness = setupAwareness(this.options.awareness, new Awareness(this.ydoc)) @@ -28,6 +37,7 @@ export class CollaborativeEditor { throw new Error('Failed to initialize awareness') } this.awareness = awareness + this.cleanupBindings = bindAwarenessToCursors(this.awareness, this.cursors, quill) || null } else { this.awareness = new Awareness(this.ydoc) @@ -79,9 +89,8 @@ export class CollaborativeEditor { console.error('Failed to initialize collaborative editor: no valid provider configured') } - if (this.options.offline) { + if (this.options.offline !== false) setupIndexedDB(this.ydoc, typeof this.options.offline === 'object' ? this.options.offline : undefined) - } } public getAwareness() { @@ -107,4 +116,12 @@ export class CollaborativeEditor { public getCursors() { return this.cursors } + + public destroy() { + this.cleanupBindings?.() + this.provider?.destroy?.() + this.cursors?.clearCursors() + this.awareness?.destroy?.() + this.ydoc?.destroy?.() + } } diff --git a/packages/fluent-editor/src/modules/collaborative-editing/module.ts b/packages/fluent-editor/src/modules/collaborative-editing/module.ts index 73dfc3e9..4c6b4df8 100644 --- a/packages/fluent-editor/src/modules/collaborative-editing/module.ts +++ b/packages/fluent-editor/src/modules/collaborative-editing/module.ts @@ -1,14 +1,9 @@ -import QuillCursors from 'quill-cursors' -import FluentEditor from '../../fluent-editor' +import type FluentEditor from '../../fluent-editor' import { CollaborativeEditor } from './collaborative-editing' export class CollaborationModule { private collaborativeEditor: CollaborativeEditor - static register() { - FluentEditor.register('modules/cursors', QuillCursors, true) - } - constructor(public quill: FluentEditor, public options: any) { this.collaborativeEditor = new CollaborativeEditor(quill, options) } @@ -16,4 +11,8 @@ export class CollaborationModule { public getCursors() { return this.collaborativeEditor.getCursors() } + + public getAwareness() { + return this.collaborativeEditor.getAwareness() + } } From b9e0c36acd0d1b744851bb0b31d35c347bb6cc24 Mon Sep 17 00:00:00 2001 From: YinLin <91457024+Yinlin124@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:21:58 +0800 Subject: [PATCH 03/13] =?UTF-8?q?test:=20=E5=8D=8F=E5=90=8C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=96=E6=B5=8B=E8=AF=95=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 协同编辑自动化测试 * fix: 修复类型 * fix: 串行测试协同模块 * fix:启动两个浏览器作为协调测试 --- .../demos/collaborative-editing.spec.ts | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 packages/docs/fluent-editor/demos/collaborative-editing.spec.ts diff --git a/packages/docs/fluent-editor/demos/collaborative-editing.spec.ts b/packages/docs/fluent-editor/demos/collaborative-editing.spec.ts new file mode 100644 index 00000000..1d9e5061 --- /dev/null +++ b/packages/docs/fluent-editor/demos/collaborative-editing.spec.ts @@ -0,0 +1,298 @@ +import { type Browser, chromium, expect, type Page, test } from '@playwright/test' + +const DEMO_URL = 'http://localhost:5173/tiny-editor/docs/demo/collaborative-editing' + +test.describe.configure({ mode: 'serial' }) + +async function openTwoPages(): Promise<[Page, Page, Browser, Browser]> { + const browser1 = await chromium.launch() + const browser2 = await chromium.launch() + + const page1 = await browser1.newPage() + const page2 = await browser2.newPage() + + await Promise.all([ + page1.goto(DEMO_URL), + page2.goto(DEMO_URL), + ]) + const editor1 = page1.locator('.ql-editor') + const editor2 = page2.locator('.ql-editor') + await expect(editor1).toBeVisible() + await expect(editor2).toBeVisible() + return [page1, page2, browser1, browser2] +} + +async function typeSync(page1: Page, page2: Page, text: string): Promise { + await page1.locator('.ql-editor').click() + await page1.keyboard.type(text) + await expect.poll(async () => (await page2.locator('.ql-editor').textContent() || '').includes(text)).toBeTruthy() +} + +async function selectAll(page: Page): Promise { + await page.locator('.ql-editor').press('ControlOrMeta+a') +} + +let p1: Page, p2: Page, b1: Browser, b2: Browser + +test.beforeEach(async () => { + [p1, p2, b1, b2] = await openTwoPages() +}) + +test.afterEach(async () => { + for (const page of [p1, p2]) { + if (!page) continue + const editor = page.locator('.ql-editor') + if (await editor.count()) { + await editor.first().click({ timeout: 2000 }).catch(() => {}) + await selectAll(page) + await page.keyboard.press('Delete').catch(() => {}) + } + } + await Promise.all([ + b1?.close().catch(() => {}), + b2?.close().catch(() => {}), + ]) +}) + +test('startup collaborative-editing test', async () => { + await expect(p1.locator('.ql-editor')).toBeVisible() + await expect(p2.locator('.ql-editor')).toBeVisible() +}) + +test('undo collaborative-editing test', async () => { + await typeSync(p1, p2, 'ABC') + await p1.getByLabel('undo').click() + await expect.poll(async () => (await p2.locator('.ql-editor').textContent() || '').includes('ABC')).toBeFalsy() +}) + +test('redo collaborative-editing test', async () => { + await typeSync(p1, p2, 'ABC') + await p1.getByLabel('undo').click() + await p1.getByLabel('redo').click() + await expect.poll(async () => (await p2.locator('.ql-editor').textContent() || '').includes('ABC')).toBeTruthy() +}) + +test('clean collaborative-editing test', async () => { + await typeSync(p1, p2, 'Bold') + await selectAll(p1) + await p1.getByLabel('bold').click() + await expect.poll(async () => (await p2.locator('.ql-editor').innerHTML()).includes('Bold')).toBeTruthy() + await p1.getByLabel('clean').click() + await expect.poll(async () => (await p2.locator('.ql-editor').innerHTML()).includes('Bold')).toBeFalsy() +}) + +// 或带 .ql-header-N 的段落 +async function headingMatched(page: Page, level: number, text: string): Promise { + const editor = page.locator('.ql-editor') + if (await editor.locator(`h${level}:has-text("${text}")`).count() > 0) return true + if (await editor.locator(`.ql-header-${level}:has-text("${text}")`).count() > 0) return true + return false +} + +test('header collaborative-editing test', async () => { + await typeSync(p1, p2, 'Title') + const levels = [1, 2, 3, 4, 5, 6] + for (const lv of levels) { + await p1.locator('.ql-editor').click() + await selectAll(p1) + if (lv <= 2) { + await p1.getByRole('button', { name: 'Normal' }).click() + await p1.getByRole('button', { name: `Heading ${lv}` }).click() + } + else { + await p1.getByRole('button', { name: `Heading ${lv - 1}` }).click() + await p1.getByRole('button', { name: `Heading ${lv}` }).click() + } + await expect.poll(() => headingMatched(p2, lv, 'Title')).toBeTruthy() + } +}) + +test('size collaborative-editing test', async () => { + await typeSync(p1, p2, 'HEAD') + await p1.locator('.ql-editor').click() + await selectAll(p1) + + const sequence = ['12px', '14px', '14px', '16px', '16px', '18px', '18px', '20px', '20px', '24px', '24px', '32px'] + + let current = sequence[0] + + for (let i = 1; i < sequence.length; i++) { + const next = sequence[i] + if (next === current) { + continue + } + await p1.getByRole('button', { name: current }).click() + await p1.getByRole('button', { name: next }).click() + + const sizeMatch = next.match(/\d+px/) + if (!sizeMatch) continue + const size = sizeMatch[0] + if (size === '12px') { + await expect.poll(async () => { + const hasParagraph = await p2.locator('.ql-editor p:has-text("HEAD")').count() + const hasSpan = await p2.locator('.ql-editor span[style*="font-size"]').count() + return hasParagraph > 0 && hasSpan === 0 + }).toBeTruthy() + } + else { + await expect.poll(async () => (await p2.locator(`.ql-editor span[style*="font-size: ${size}"]`).count()) > 0).toBeTruthy() + } + current = next + } +}) + +// serif +test('font collaborative-editing test', async () => { + await typeSync(p1, p2, 'font') + await p1.locator('.ql-editor').click() + await selectAll(p1) + + await p1.getByRole('button', { name: 'Sans Serif' }).click() + await p1.getByRole('button', { name: 'serif', exact: true }).click() + + await expect + .poll(async () => (await p2.locator('.ql-editor span[style*="font-family: serif"]').count()) > 0) + .toBeTruthy() +}) + +test('line-height collaborative-editing test', async () => { + await typeSync(p1, p2, 'fdsafdsa') + await p1.getByRole('button', { name: '1', exact: true }).click() + await p1.getByRole('button', { name: '1.15' }).click() + await expect.poll(async () => (await p2.locator('.ql-editor p[style*="line-height: 1.15"]').count()) > 0).toBeTruthy() +}) + +const formatTypes = ['bold', 'italic', 'underline', 'strike'] +formatTypes.forEach((fmt) => { + test(`${fmt} collaborative-editing test`, async () => { + await typeSync(p1, p2, fmt) + await selectAll(p1) + await p1.getByLabel(fmt).click() + const tagMap: Record = { bold: 'strong', italic: 'em', underline: 'u', strike: '.ql-custom-strike' } + const tag = tagMap[fmt] + await expect.poll(async () => (await p2.locator(`.ql-editor ${tag}`).count()) > 0).toBeTruthy() + }) +}) +test('code collaborative-editing test', async () => { + await typeSync(p1, p2, 'Colorful') + await selectAll(p1) + await p1.getByLabel('code', { exact: true }).click() + await expect.poll(async () => (await p2.locator('.ql-editor p code').count()) > 0).toBeTruthy() +}) +test('background collaborative-editing test', async () => { + await typeSync(p1, p2, 'BG') + await selectAll(p1) + await p1.locator('.ql-background > .ql-picker-expand').click() + await p1.getByRole('button', { name: 'rgb(229, 239, 255)' }).click() + await expect.poll(async () => (await p2.locator('.ql-editor p span[style*="background-color: rgb(229, 239, 255)"]').count()) > 0).toBeTruthy() +}) + +test('align collaborative-editing test', async () => { + await typeSync(p1, p2, 'Align') + await selectAll(p1) + await p1.locator('.ql-align > .ql-picker-label').click() + await p1.locator('#ql-picker-options-6').getByRole('button').nth(1).click() + + await expect.poll(async () => (await p2.locator('.ql-editor p[class="ql-align-center"]').count()) > 0).toBeTruthy() +}) + +test('list ordered collaborative-editing test', async () => { + await typeSync(p1, p2, '1') + await p1.getByLabel('ordered').click({ force: true }) + await expect.poll(async () => (await p2.locator('.ql-editor ol li').count()) > 0).toBeTruthy() +}) + +test('list bullet collaborative-editing test', async () => { + await typeSync(p1, p2, 'Item') + await p1.getByLabel('bullet').click({ force: true }) + await expect.poll(async () => (await p2.locator('.ql-editor li[data-list="bullet"]').count()) > 0).toBeTruthy() +}) + +test('list check collaborative-editing test', async () => { + await typeSync(p1, p2, 'Check') + await p1.getByLabel('check').click({ force: true }) + await expect.poll(async () => (await p2.locator('.ql-editor ol li[data-list="unchecked"]').count()) > 0).toBeTruthy() +}) + +test('indent increase collaborative-editing test', async () => { + await typeSync(p1, p2, 'Indent') + await p1.getByLabel('+1').click({ force: true }) + await expect.poll(async () => (await p2.locator('.ql-editor p[class*="indent-"]').count()) > 0).toBeTruthy() +}) + +test('indent decrease collaborative-editing test', async () => { + await typeSync(p1, p2, 'Indent') + await p1.getByLabel('+1').click({ force: true }) + await p1.getByLabel('-1').click({ force: true }) + await expect.poll(async () => (await p2.locator('.ql-editor p[class*="indent-"]').count()) === 0).toBeTruthy() +}) + +test('script sub collaborative-editing test', async () => { + await typeSync(p1, p2, 'Sub') + await selectAll(p1) + await p1.getByLabel('sub').click() + await expect.poll(async () => (await p2.locator('.ql-editor sub').count()) > 0).toBeTruthy() +}) + +test('script super collaborative-editing test', async () => { + await typeSync(p1, p2, 'Super') + await selectAll(p1) + await p1.getByLabel('super').click() + await expect.poll(async () => (await p2.locator('.ql-editor sup').count()) > 0).toBeTruthy() +}) + +test('link collaborative-editing test', async () => { + const text = 'Link' + await typeSync(p1, p2, text) + await selectAll(p1) + await p1.getByLabel('link', { exact: true }).click() + const promptInput = p1.locator('input[type="text"]:visible') + if (await promptInput.first().isVisible()) { + await promptInput.first().fill(DEMO_URL) + await promptInput.first().press('Enter') + } + await expect.poll(async () => (await p2.locator(`.ql-editor p a[href="${DEMO_URL}"]`).count()) > 0).toBeTruthy() +}) + +test('blockquote collaborative-editing test', async () => { + await typeSync(p1, p2, 'Quote') + await selectAll(p1) + await p1.getByLabel('blockquote').click() + await expect.poll(async () => (await p2.locator('.ql-editor blockquote').count()) > 0).toBeTruthy() +}) + +test('code-block collaborative-editing test', async () => { + await typeSync(p1, p2, 'console.log(1)') + await selectAll(p1) + await p1.getByLabel('code-block').click() + await expect.poll(async () => (await p2.locator('.ql-editor div.ql-code-block-container div.ql-code-block').count()) > 0).toBeTruthy() +}) + +test('divider collaborative-editing test', async () => { + await p1.getByLabel('divider').click() + await expect.poll(async () => (await p2.locator('.ql-editor hr').count()) > 0).toBeTruthy() +}) + +test('emoji collaborative-editing test', async () => { + await p1.getByLabel('emoji').click() + await p1.locator('div').filter({ hasText: /^👍😀😘😍😆😜😅😂$/ }).getByLabel('😘').click() + await expect.poll(async () => (await p2.locator('.ql-editor').textContent() || '').includes('😘')).toBeTruthy() +}) + +test('formula collaborative-editing test', async () => { + await p1.getByLabel('formula').click() + await p1.locator('.ql-tooltip[data-mode="formula"] input[data-formula]').fill('e=mc^2') + await p1.keyboard.press('Enter') + await expect.poll(async () => (await p2.locator('.ql-editor .katex').count()) > 0).toBeTruthy() +}) + +test('table-up collaborative-editing test', async () => { + await p1.locator('.ql-table-up > .ql-picker-label').click() + await p1.locator('div:nth-child(29)').first().click() + await expect.poll(async () => (await p2.locator('.ql-editor div.ql-table-wrapper').count()) > 0).toBeTruthy() +}) + +test('fullscreen collaborative-editing test', async () => { + await p1.getByLabel('fullscreen').click({ force: true }) + await expect(p1.getByLabel('fullscreen')).toBeVisible() +}) From c9a8417f8d7c468ab06d908a6808c000b54b7875 Mon Sep 17 00:00:00 2001 From: YinLin <91457024+Yinlin124@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:05:57 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=8D=8F?= =?UTF-8?q?=E5=90=8C=E7=BC=96=E8=BE=91=E5=90=8E=E7=AB=AF=E5=AD=90=E5=8C=85?= =?UTF-8?q?=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:新增协同编辑后端子包 * fix:修复env.example URL与docker配置一致,修改 Readme * fix:修复文档保存防抖内存泄露问题 * fix: 修复保存文档异步操作导致的错误文档释放 * refactor: import y-mongodb-provider as MongodbPersistence * fix:新增类型判断,增大flushSize * fix:修复 yjs 版本依赖,引入 vite 构建项目,提示 docker 容器中的 node 版本以支持 vite * fix:删除无用环境变量 * docs: 修改部署方式和配置,新增自定义持久化指南 * fix:修复 js 引入和 vite config * feat: 引入 pm2 支持,启动后检查服务器是否可连 * fix: 优化打包选项,pm2 移除项目依赖 * fix: 打包 input config, 本地启动服务器 readme 修复 --- .../.dockerignore | 22 ++ .../.env.example | 7 + .../collaborative-editing-backend/Dockerfile | 9 + .../collaborative-editing-backend/README.md | 106 +++++++++ .../docker-compose.yml | 26 +++ .../ecosystem.config.cjs | 19 ++ .../package.json | 28 +++ .../collaborative-editing-backend/src/env.ts | 8 + .../src/persistence/index.ts | 16 ++ .../src/persistence/mongo.ts | 70 ++++++ .../src/server.ts | 31 +++ .../src/utils.ts | 205 ++++++++++++++++++ .../tsconfig.json | 17 ++ .../vite.config.ts | 30 +++ 14 files changed, 594 insertions(+) create mode 100644 packages/collaborative-editing-backend/.dockerignore create mode 100644 packages/collaborative-editing-backend/.env.example create mode 100644 packages/collaborative-editing-backend/Dockerfile create mode 100644 packages/collaborative-editing-backend/README.md create mode 100644 packages/collaborative-editing-backend/docker-compose.yml create mode 100644 packages/collaborative-editing-backend/ecosystem.config.cjs create mode 100644 packages/collaborative-editing-backend/package.json create mode 100644 packages/collaborative-editing-backend/src/env.ts create mode 100644 packages/collaborative-editing-backend/src/persistence/index.ts create mode 100644 packages/collaborative-editing-backend/src/persistence/mongo.ts create mode 100644 packages/collaborative-editing-backend/src/server.ts create mode 100644 packages/collaborative-editing-backend/src/utils.ts create mode 100644 packages/collaborative-editing-backend/tsconfig.json create mode 100644 packages/collaborative-editing-backend/vite.config.ts diff --git a/packages/collaborative-editing-backend/.dockerignore b/packages/collaborative-editing-backend/.dockerignore new file mode 100644 index 00000000..c613ec02 --- /dev/null +++ b/packages/collaborative-editing-backend/.dockerignore @@ -0,0 +1,22 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.nyc_output +coverage +.DS_Store +dist +tmp +dbDir +node_modules +npm-debug.log +.git +.gitignore +README.md +.nyc_output +coverage +.DS_Store +dist +tmp +dbDir \ No newline at end of file diff --git a/packages/collaborative-editing-backend/.env.example b/packages/collaborative-editing-backend/.env.example new file mode 100644 index 00000000..8493d90f --- /dev/null +++ b/packages/collaborative-editing-backend/.env.example @@ -0,0 +1,7 @@ +HOST=0.0.0.0 +PORT=1234 + +MONGODB_URL=mongodb://admin:admin!123@mongodb:27017/?authSource=admin +MONGODB_DB=yjs_documents +MONGODB_COLLECTION=documents + diff --git a/packages/collaborative-editing-backend/Dockerfile b/packages/collaborative-editing-backend/Dockerfile new file mode 100644 index 00000000..bf36085e --- /dev/null +++ b/packages/collaborative-editing-backend/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine + +WORKDIR /app +COPY package.json ./ +RUN npm install --no-package-lock && npm install pm2@latest -g +COPY . . + +RUN npm run build +CMD [ "pm2-runtime", "start", "ecosystem.config.cjs" ] diff --git a/packages/collaborative-editing-backend/README.md b/packages/collaborative-editing-backend/README.md new file mode 100644 index 00000000..12c803de --- /dev/null +++ b/packages/collaborative-editing-backend/README.md @@ -0,0 +1,106 @@ +# TinyEditor 协同编辑后端服务 + +基于 Yjs 和 WebSocket 的实时协同编辑后端服务,支持多用户实时协作编辑,使用 MongoDB 进行文档持久化。 + +## 快速开始 + +### 环境变量配置 + +```bash +cp .env.example .env +``` + +参照下方表格进行配置 `.env` 文件 +| 变量名 | 必需 | 默认值 | 说明 | +| -------------------- | ---- | ------ | -------------------------------------------------------------- | +| `HOST` | ✅ | - | 服务器监听地址 | +| `PORT` | ✅ | - | WebSocket 服务端口 | +| `MONGODB_URL` | ✅ | - | MongoDB 连接字符串 | +| `MONGODB_DB` | ✅ | - | MongoDB 数据库名称 | +| `MONGODB_COLLECTION` | ✅ | - | MongoDB 集合名称 | +| `GC` | ❌ | `true` | 是否启用 Yjs 垃圾回收 | + +### Docker 容器化部署(推荐) + +使用 Docker Compose 一键启动(包含 MongoDB): + +```bash +docker compose up --build +``` + +### 本地部署 + +启动 mongodb + +```bash +docker run -d \ + --name mongodb \ + -p 27017:27017 \ + -e MONGO_INITDB_ROOT_USERNAME=admin \ + -e MONGO_INITDB_ROOT_PASSWORD="admin!123" \ + -v mongodb_data:/data/db \ + mongo:latest +``` + +修改 `.env MongoDB URL` + +```bash +MONGODB_URL=mongodb://admin:admin!123@localhost:27017/?authSource=admin +``` + +启动本地服务器 + +```bash +npm install -g pm2 +npm install +npm start +``` + +## 前端配置 + +(非完整前端配置主要参考 provider 部分) + +```javascript +import TinyEditor from '@opentiny/fluent-editor' + +const editor = new TinyEditor('#editor', { + theme: 'snow', + modules: { + collaboration: { + provider: { + type: 'websocket', + options: { + serverUrl: 'ws://localhost:1234', + roomName: 'my-document', + }, + }, + }, + }, +}) +``` + +## 开发指南 + +### MongoDB 持久化拓展 + +当前项目在 [`src/persistence/mongo.ts`](./src/persistence/mongo.ts) 类实现 MongoDB 持久化,基于 [`y-mongodb-provider`](https://github.com/MaxNoetzold/y-mongodb-provider) 库。 + +需要拓展当前持久化能力时,可参考 API 文档:[y-mongodb-provider API](https://github.com/MaxNoetzold/y-mongodb-provider?tab=readme-ov-file#api) + +### 自定义持久化接口 + +要支持其他数据库(如 PostgreSQL、Redis 等),需要实现 `Persistence` 接口 + +| 方法名 | 参数 | 返回值 | 说明 | +| ------------ | --------------------------------- | --------------- | -------------------------------------------- | +| `bindState` | `docName: string`
`doc: Y.Doc` | `Promise` | 文档初始化时调用,加载历史状态并设置实时同步 | +| `writeState` | `docName: string`
`doc: Y.Doc` | `Promise` | 手动保存文档状态(可选使用) | +| `close` | - | `Promise` | 服务器关闭时调用,清理资源 | + +### 更多社区持久化支持 + +[`adapter for Yjs`](https://github.com/search?q=adapter%20for%20Yjs&type=repositories): + +- [y-mongodb-provider](https://github.com/yjs/y-mongodb-provider) +- [y-redis](https://github.com/yjs/y-redis) +- [y-postgres](https://github.com/MaxNoetzold/y-postgresql) diff --git a/packages/collaborative-editing-backend/docker-compose.yml b/packages/collaborative-editing-backend/docker-compose.yml new file mode 100644 index 00000000..5ecea7c3 --- /dev/null +++ b/packages/collaborative-editing-backend/docker-compose.yml @@ -0,0 +1,26 @@ +services: + mongodb: + image: mongo:latest + container_name: yjs-mongodb + restart: always + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: admin!123 + volumes: + - mongodb_data:/data/db + + websocket-server: + build: . + container_name: yjs-websocket-server + restart: always + ports: + - "${PORT:-1234}:${PORT:-1234}" + env_file: + - .env + depends_on: + - mongodb + +volumes: + mongodb_data: diff --git a/packages/collaborative-editing-backend/ecosystem.config.cjs b/packages/collaborative-editing-backend/ecosystem.config.cjs new file mode 100644 index 00000000..4bd95381 --- /dev/null +++ b/packages/collaborative-editing-backend/ecosystem.config.cjs @@ -0,0 +1,19 @@ +const fs = require('node:fs') +const path = require('node:path') + +const logDir = path.resolve(__dirname, 'log') +if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true }) + +module.exports = { + apps: [{ + name: 'collaborative-editor-backend', + script: './dist/server.js', + node_args: '--env-file=.env --no-warnings', + instances: 1, + log_file: path.join(logDir, 'app.log'), + error_file: path.join(logDir, 'error.log'), + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + watch: false, + max_restarts: 10, + }], +} diff --git a/packages/collaborative-editing-backend/package.json b/packages/collaborative-editing-backend/package.json new file mode 100644 index 00000000..1632dd13 --- /dev/null +++ b/packages/collaborative-editing-backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "collaborative-editor-backend", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "vite-node src/server.ts", + "start": "npm run build && pm2 start ecosystem.config.cjs", + "stop": "pm2 stop ecosystem.config.cjs", + "build": "vite build" + }, + "dependencies": { + "@y/protocols": "^1.0.6-1", + "lib0": "^0.2.102", + "mongodb": "^6.0.0", + "ws": "^8.0.0", + "y-mongodb-provider": "0.2.1", + "yjs": "^13.6.15" + }, + "devDependencies": { + "@types/glob": "^9.0.0", + "@types/node": "^24.3.0", + "@types/ws": "^8.18.1", + "glob": "^11.0.3", + "typescript": "^5.7.9", + "vite": "^7.1.3", + "vite-node": "^3.2.4" + } +} diff --git a/packages/collaborative-editing-backend/src/env.ts b/packages/collaborative-editing-backend/src/env.ts new file mode 100644 index 00000000..83b505c0 --- /dev/null +++ b/packages/collaborative-editing-backend/src/env.ts @@ -0,0 +1,8 @@ +export const HOST = process.env.HOST! +export const PORT = Number.parseInt(process.env.PORT!) + +export const MONGODB_URL = process.env.MONGODB_URL! as string +export const MONGODB_DB = process.env.MONGODB_DB! as string +export const MONGODB_COLLECTION = process.env.MONGODB_COLLECTION! as string + +export const GC_ENABLED = (process.env.GC !== 'false' && process.env.GC !== '0') as boolean diff --git a/packages/collaborative-editing-backend/src/persistence/index.ts b/packages/collaborative-editing-backend/src/persistence/index.ts new file mode 100644 index 00000000..1f77015e --- /dev/null +++ b/packages/collaborative-editing-backend/src/persistence/index.ts @@ -0,0 +1,16 @@ +import type * as Y from 'yjs' + +export interface Persistence { + connect: () => Promise + bindState: (docName: string, doc: Y.Doc) => Promise + writeState: (docName: string, doc: Y.Doc) => Promise + close?: () => Promise +} + +let persistence: Persistence | null = null + +export function setPersistence(p: Persistence | null) { + persistence = p +} + +export const getPersistence = () => persistence diff --git a/packages/collaborative-editing-backend/src/persistence/mongo.ts b/packages/collaborative-editing-backend/src/persistence/mongo.ts new file mode 100644 index 00000000..9b042b1c --- /dev/null +++ b/packages/collaborative-editing-backend/src/persistence/mongo.ts @@ -0,0 +1,70 @@ +import type { Db } from 'mongodb' +import type { Persistence } from './index.ts' +import { MongoClient } from 'mongodb' +import { MongodbPersistence } from 'y-mongodb-provider' +import * as Y from 'yjs' +import { MONGODB_COLLECTION, MONGODB_DB, MONGODB_URL } from '../env.ts' + +interface MongoConnectionObj { + client: MongoClient + db: Db +} + +export class MongoPersistence implements Persistence { + private mongodbPersistence: MongodbPersistence + private client: MongoClient + private connected = false + + constructor() { + if (!MONGODB_URL) throw new Error('缺少必需的环境变量: MONGODB_URL') + if (!MONGODB_DB) throw new Error('缺少必需的环境变量: MONGODB_DB') + if (!MONGODB_COLLECTION) throw new Error('缺少必需的环境变量: MONGODB_COLLECTION') + + this.client = new MongoClient(MONGODB_URL, { + connectTimeoutMS: 5000, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 5000, + }) + const db = this.client.db(MONGODB_DB) + const connectionObj: MongoConnectionObj = { client: this.client, db } + + this.mongodbPersistence = new MongodbPersistence(connectionObj, { + collectionName: MONGODB_COLLECTION, + flushSize: 50, + multipleCollections: true, + }) + } + + async connect() { + if (!this.connected) { + await this.client.connect() + this.connected = true + } + } + + async bindState(docName: string, ydoc: Y.Doc) { + await this.connect() + + const persistedYDoc = await this.mongodbPersistence.getYDoc(docName) + + const newUpdates = Y.encodeStateAsUpdate(ydoc) + if (newUpdates.byteLength > 0) { + this.mongodbPersistence.storeUpdate(docName, newUpdates) + } + + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYDoc)) + + ydoc.on('update', (update: Uint8Array) => { + this.mongodbPersistence.storeUpdate(docName, update) + }) + } + + async writeState(docName: string, ydoc: Y.Doc) { + return Promise.resolve() + } + + async close() { + this.mongodbPersistence.destroy() + await this.client.close() + } +} diff --git a/packages/collaborative-editing-backend/src/server.ts b/packages/collaborative-editing-backend/src/server.ts new file mode 100644 index 00000000..95cc29ed --- /dev/null +++ b/packages/collaborative-editing-backend/src/server.ts @@ -0,0 +1,31 @@ +import http from 'node:http' +import { WebSocketServer } from 'ws' +import { HOST, PORT } from './env.ts' +import { setPersistence } from './persistence/index.ts' +import { MongoPersistence } from './persistence/mongo.ts' +import { setupWSConnection } from './utils.ts' + +const server = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('okay') +}) + +const wss = new WebSocketServer({ server }) +wss.on('connection', setupWSConnection) + +const persistence = new MongoPersistence() +setPersistence(persistence) + +persistence.connect().then(() => { + server.listen(PORT, HOST, () => { + console.warn(`Server running on http://${HOST}:${PORT}`) + }) +}).catch((error) => { + console.error('Failed to connect to MongoDB:', error) + process.exit(1) +}) + +process.on('SIGINT', async () => { + await persistence.close() + process.exit(0) +}) diff --git a/packages/collaborative-editing-backend/src/utils.ts b/packages/collaborative-editing-backend/src/utils.ts new file mode 100644 index 00000000..998e2ff7 --- /dev/null +++ b/packages/collaborative-editing-backend/src/utils.ts @@ -0,0 +1,205 @@ +import type { IncomingMessage } from 'node:http' +import type WebSocket from 'ws' +import * as awarenessProtocol from '@y/protocols/awareness.js' +import * as syncProtocol from '@y/protocols/sync.js' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import * as map from 'lib0/map' +import * as Y from 'yjs' + +import { GC_ENABLED } from './env.ts' +import { getPersistence } from './persistence/index.ts' + +const wsReadyStateConnecting = 0 +const wsReadyStateOpen = 1 + +export const docs: Map = new Map() + +const messageSync = 0 +const messageAwareness = 1 + +function updateHandler(update: Uint8Array, _origin: any, doc: Y.Doc): void { + const wsDoc = doc as WSSharedDoc + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeUpdate(encoder, update) + const message = encoding.toUint8Array(encoder) + wsDoc.conns.forEach((_, conn) => send(wsDoc, conn, message)) +} + +let contentInitializor: (ydoc: Y.Doc) => Promise = (_ydoc: Y.Doc) => Promise.resolve() +export function setContentInitializor(f: (ydoc: Y.Doc) => Promise): void { + contentInitializor = f +} + +export class WSSharedDoc extends Y.Doc { + name: string + conns: Map> + awareness: awarenessProtocol.Awareness + whenInitialized: Promise + + constructor(name: string) { + super({ gc: GC_ENABLED }) + this.name = name + this.conns = new Map() + this.awareness = new awarenessProtocol.Awareness(this) + this.awareness.setLocalState(null) + + const awarenessChangeHandler = ( + { added, updated, removed }: { added: number[], updated: number[], removed: number[] }, + conn: WebSocket | null, + ): void => { + const changedClients = added.concat(updated, removed) + if (conn !== null) { + const connControlledIDs = this.conns.get(conn) + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID) + }) + removed.forEach((clientID) => { + connControlledIDs.delete(clientID) + }) + } + } + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageAwareness) + encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)) + const buff = encoding.toUint8Array(encoder) + this.conns.forEach((_, c) => send(this, c, buff)) + } + + this.awareness.on('update', awarenessChangeHandler) + this.on('update', updateHandler) + this.whenInitialized = contentInitializor(this) + } +} + +export function getYDoc(docname: string, gc = true): WSSharedDoc { + return map.setIfUndefined(docs as Map, docname, () => { + const doc = new WSSharedDoc(docname) + doc.gc = gc + const persistence = getPersistence() + if (persistence !== null) { + persistence.bindState(docname, doc) + } + return doc + }) +} +function messageListener(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): void { + try { + const encoder = encoding.createEncoder() + const decoder = decoding.createDecoder(message) + const messageType = decoding.readVarUint(decoder) + switch (messageType) { + case messageSync: + encoding.writeVarUint(encoder, messageSync) + syncProtocol.readSyncMessage(decoder, encoder, doc, conn) + if (encoding.length(encoder) > 1) { + send(doc, conn, encoding.toUint8Array(encoder)) + } + break + case messageAwareness: + awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn) + break + } + } + catch (err) { + console.error(err) + } +} + +function closeConn(doc: WSSharedDoc, conn: WebSocket): void { + if (doc.conns.has(conn)) { + const controlledIds = doc.conns.get(conn)! + doc.conns.delete(conn) + awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null) + const persistence = getPersistence() + if (doc.conns.size === 0 && persistence !== null) { + persistence.writeState(doc.name, doc).then(() => { + if (doc.conns.size === 0) { + doc.destroy() + docs.delete(doc.name) + } + }) + docs.delete(doc.name) + } + } + try { + conn.close() + } + catch (e) { console.warn(e) } +} + +function send(doc: WSSharedDoc, conn: WebSocket, m: Uint8Array): void { + if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) { + closeConn(doc, conn) + } + try { + conn.send(m, (err) => { + if (err != null) closeConn(doc, conn) + }) + } + catch (e) { + closeConn(doc, conn) + } +} + +const pingTimeout = 30000 + +interface SetupOptions { + docName?: string + gc?: boolean +} + +export function setupWSConnection( + conn: WebSocket, + req: IncomingMessage, + { docName = (req.url || '').slice(1).split('?')[0], gc = true }: SetupOptions = {}, +): void { + conn.binaryType = 'arraybuffer' + const doc = getYDoc(docName, gc) + doc.conns.set(conn, new Set()) + conn.on('message', (message: ArrayBuffer) => messageListener(conn, doc, new Uint8Array(message))) + + let pongReceived = true + const pingInterval = setInterval(() => { + if (!pongReceived) { + if (doc.conns.has(conn)) { + closeConn(doc, conn) + } + clearInterval(pingInterval) + } + else if (doc.conns.has(conn)) { + pongReceived = false + try { + conn.ping() + } + catch (e) { + closeConn(doc, conn) + clearInterval(pingInterval) + } + } + }, pingTimeout) + + conn.on('close', () => { + closeConn(doc, conn) + clearInterval(pingInterval) + }) + + conn.on('pong', () => { + pongReceived = true + }) + + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeSyncStep1(encoder, doc) + send(doc, conn, encoding.toUint8Array(encoder)) + + const awarenessStates = doc.awareness.getStates() + if (awarenessStates.size > 0) { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageAwareness) + encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))) + send(doc, conn, encoding.toUint8Array(encoder)) + } +} diff --git a/packages/collaborative-editing-backend/tsconfig.json b/packages/collaborative-editing-backend/tsconfig.json new file mode 100644 index 00000000..a1975669 --- /dev/null +++ b/packages/collaborative-editing-backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noEmit": true, + "outDir": "dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/collaborative-editing-backend/vite.config.ts b/packages/collaborative-editing-backend/vite.config.ts new file mode 100644 index 00000000..053e7316 --- /dev/null +++ b/packages/collaborative-editing-backend/vite.config.ts @@ -0,0 +1,30 @@ +import path from 'node:path' +import { globSync } from 'glob' +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + ssr: true, + outDir: 'dist', + emptyOutDir: true, + target: 'es2022', + sourcemap: true, + rollupOptions: { + // 遍历 src 下所有 ts 文件作为入口 + input: Object.fromEntries( + globSync('src/**/*.ts', { ignore: ['**/*.d.ts'] }).map(file => [ + path.relative('src', file).replace(/\.ts$/, ''), + path.resolve(file), + ]), + ), + output: { + format: 'esm', + preserveModules: true, // 保留模块和目录 + preserveModulesRoot: 'src', // 以 src 为根 + entryFileNames: '[name].js', // 扩展名改为 .js + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]', + }, + }, + }, +}) From ce6bd9079d38fbba0b3b026e9abfe2f963ac770c Mon Sep 17 00:00:00 2001 From: YinLin <91457024+Yinlin124@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:46:50 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=20destory?= =?UTF-8?q?=20=E9=80=BB=E8=BE=91=EF=BC=8C=E9=87=8D=E6=9E=84=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20provider=20=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E6=A1=A3=E8=AF=B4=E6=98=8E=20(#338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * demo:修改光标模板样式 * fix: 协同实例 destory 事件绑定,indexdb 修改成 guid 解决本地缓存冲突问题 * fix:demo上传图片逻辑 * refactor: customProvider 改名为 providerRegistry * refactor: 简化协同模块参数,自定义 provider 统一类构造传参 * docs:修改文档结构,新增自定义 provider 示例 * fix: destory 异步,新增监听移除 * docs: 文档修复 * fix:修复文档 * fix:修复文档图片路径 * fix: clearIndexedDB 删除多余异步操作 --- .../demos/collaborative-editing.vue | 53 ++- .../docs/demo/collaborative-editing.md | 392 +++++++++++++----- .../fluent-editor/public/Tiny-editor-demo.png | Bin 0 -> 160638 bytes .../awareness/awareness.ts | 5 - .../awareness/y-indexeddb.ts | 14 +- .../collaborative-editing.ts | 42 +- .../modules/collaborative-editing/module.ts | 14 + .../collaborative-editing/provider/index.ts | 2 +- ...{customProvider.ts => providerRegistry.ts} | 2 +- .../collaborative-editing/provider/webrtc.ts | 10 +- .../provider/websocket.ts | 12 +- .../modules/collaborative-editing/types.ts | 4 +- 12 files changed, 374 insertions(+), 176 deletions(-) create mode 100644 packages/docs/fluent-editor/public/Tiny-editor-demo.png rename packages/fluent-editor/src/modules/collaborative-editing/provider/{customProvider.ts => providerRegistry.ts} (95%) diff --git a/packages/docs/fluent-editor/demos/collaborative-editing.vue b/packages/docs/fluent-editor/demos/collaborative-editing.vue index d694feef..55c20e33 100644 --- a/packages/docs/fluent-editor/demos/collaborative-editing.vue +++ b/packages/docs/fluent-editor/demos/collaborative-editing.vue @@ -1,5 +1,6 @@