-
用户2 编辑器
-
+
-
+
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 @@