= { 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()
+})
+
+test('edit conflict simultaneously test', async () => {
+ await Promise.all([
+ p1.locator('.ql-editor').click(),
+ p2.locator('.ql-editor').click(),
+ ])
+ await Promise.all([
+ p1.keyboard.type('A'),
+ p2.keyboard.type('B'),
+ ])
+ await expect.poll(async () => {
+ const text1 = await p1.locator('.ql-editor').textContent()
+ const text2 = await p2.locator('.ql-editor').textContent()
+ if (text1 === text2 && (text1 === 'AB' || text1 === 'BA')) {
+ return true
+ }
+ return false
+ }).toBeTruthy()
+})
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..53823c75
--- /dev/null
+++ b/packages/docs/fluent-editor/demos/collaborative-editing.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
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..26c6e0c1
--- /dev/null
+++ b/packages/docs/fluent-editor/docs/demo/collaborative-editing.md
@@ -0,0 +1,499 @@
+# 协同编辑
+
+
+
+
+
+TinyEditor 支持多人实时协作编辑,支持多种连接协议(如 WebSocket、WebRTC),可自定义后端持久化,适用于多场景的在线协同编辑需求。
+
+
+## 在线协同演示
+
+整个协同编辑系统由三部分组成:前端 `TinyEditor`、中间层协作引擎 `Yjs` 和后端服务(用于数据同步和持久化)。前端编辑器将操作传递给 `Yjs`,`Yjs` 通过不同的连接协议(如 `WebSocket` 或 `WebRTC`)实现多端同步, 并支持将数据持久化到后端数据库(如 `MongoDB`)。
+
+
+下面是一个完整的协同编辑演示:
+
+:::demo src=demos/collaborative-editing.vue
+:::
+
+## 快速开始
+
+协同编辑功能需要配置前端和后端服务。
+
+### 前端配置
+
+安装依赖
+
+```bash
+pnpm i quill-cursors y-protocols y-quill yjs y-indexeddb y-websocket
+```
+
+引入协同编辑模块
+
+```javascript
+import FluentEditor, { CollaborationModule } from '@opentiny/fluent-editor'
+FluentEditor.register('modules/collaborative-editing', CollaborationModule, true)
+```
+
+编辑器基础配置:
+
+```javascript
+const editor = new FluentEditor('#editor', {
+ theme: 'snow',
+ modules: {
+ 'collaborative-editing': {
+ provider: {
+ type: 'websocket',
+ options: {
+ serverUrl: 'ws://localhost:1234',
+ roomName: 'Tiny-Editor-Demo',
+ },
+ },
+ },
+ },
+})
+```
+
+> 在 Vue 项目中集成协作编辑:[YuQue.vue](https://github.com/opentiny/tiny-editor/blob/ospp-2025/collaborative-editing/packages/projects/src/views/yuque/YuQue.vue)
+
+### 后端服务
+
+可选择 Docker 容器化启动或本地部署
+
+#### Docker 容器化部署(推荐)
+
+1. 拉取 Docker 镜像,使用 Docker Compose 一键启动:
+
+```bash
+docker pull yinlin124/collaborative-editor-backend:latest
+```
+
+2. 创建 `docker-compose.yml` 文件,内容如下:
+
+```yaml
+services:
+ mongodb:
+ image: mongo:latest
+ container_name: yjs-mongodb
+ restart: always
+ ports:
+ - '27017:27017'
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: admin # 设置 MongoDB 初始用户名
+ MONGO_INITDB_ROOT_PASSWORD: admin!123 # 设置 MongoDB 初始密码
+ volumes:
+ - mongodb_data:/data/db
+
+websocket-server:
+ image: yinlin124/collaborative-editor-backend:latest
+ container_name: yjs-websocket-server
+ restart: always
+ ports:
+ - '${PORT:-1234}:${PORT:-1234}'
+ environment:
+ HOST: ${HOST:-0.0.0.0} # 设置后端监听的网络接口
+ PORT: ${PORT:-1234} # 默认 1234 端口,可以使用环境变量修改
+ MONGODB_URL: ${MONGODB_URL:-mongodb://admin:admin!123@mongodb:27017/?authSource=admin} # 如果你使用自己的 mongodb 服务需要修改此项
+ MONGODB_DB: ${MONGODB_DB:-tinyeditor} # 数据库名称
+ MONGODB_COLLECTION: ${MONGODB_COLLECTION:-documents} # 集合名称
+
+ depends_on:
+ - mongodb
+
+volumes:
+ mongodb_data:
+```
+
+如果你需要更换映射端口等,可创建 `.env` 文件按照下面的参数值更改环境变量:
+
+| 变量名 | 必需 | 默认值 | 说明 |
+| -------------------- | ---- | ------ | --------------------- |
+| `HOST` | ✅ | - | 服务器监听地址 |
+| `PORT` | ✅ | - | WebSocket 服务端口 |
+| `MONGODB_URL` | ✅ | - | MongoDB 连接字符串 |
+| `MONGODB_DB` | ✅ | - | MongoDB 数据库名称 |
+| `MONGODB_COLLECTION` | ✅ | - | MongoDB 集合名称 |
+| `GC` | ❌ | `true` | 是否启用 Yjs 垃圾回收 |
+
+3. 在项目根目录下运行 `docker-compose` 启动容器:
+
+```bash
+docker compose up
+```
+
+启动后即可使用 `ws://localhost:1234` 作为前端 `serverUrl` 配置
+
+#### 本地部署
+
+1. 启动 MongoDB(如果有其他 MongoDB 服务可跳过此步骤)
+
+```bash
+docker run -d \
+ --name mongo \
+ -p 27017:27017 \
+ -e MONGO_INITDB_ROOT_USERNAME=admin \
+ -e MONGO_INITDB_ROOT_PASSWORD=admin \
+ -v mongodb_data:/data/db \
+ mongo:latest
+```
+
+2. 进入协同编辑后端子包目录
+
+```bash
+cd packages/collaborative-editing-backend
+```
+
+3. 创建 `.env` 文件
+
+```env
+HOST=0.0.0.0
+PORT=1234
+MONGODB_URL=mongodb://admin:admin@127.0.0.1:27017/?authSource=admin
+MONGODB_DB=tinyeditor
+MONGODB_COLLECTION=documents
+GC=true
+```
+
+4. 启动本地服务器
+
+```bash
+pnpm install -g pm2
+pnpm install
+pnpm start
+```
+
+启动后即可使用 `ws://localhost:1234` 作为前端 `serverUrl` 配置
+
+---
+
+## 编辑器配置说明
+
+### Provider 配置
+
+Provider 用于管理和同步多个用户之间的数据,它负责将本地的编辑操作与远程的其他用户进行实时同步。TinyEditor 支持多种 Provider 类型,常用的有 `WebSocket` 和 `WebRTC`,你也可以根据本文实现自定义的 Provider。
+
+#### WebSocket Provider
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+| ---------------- | ------------------------ | ---- | ------ | -------------------- |
+| `serverUrl` | `string` | 是 | - | WebSocket 服务器地址 |
+| `roomName` | `string` | 是 | - | 房间名称 |
+| `connect` | `boolean` | 否 | `true` | 是否自动连接 |
+| `params` | `Record` | 否 | - | 连接参数 |
+| `protocols` | `string[]` | 否 | - | WebSocket 协议 |
+| `resyncInterval` | `number` | 否 | - | 重新同步间隔(毫秒) |
+| `maxBackoffTime` | `number` | 否 | - | 最大退避时间 |
+
+#### 示例
+
+```javascript
+const editor = new FluentEditor('#editor', {
+ theme: 'snow',
+ modules: {
+ 'collaborative-editing': {
+ provider: {
+ type: 'websocket',
+ options: {
+ serverUrl: 'wss://120.26.92.145:1234',
+ roomName: 'tiny-editor-demo',
+ connect: true,
+ resyncInterval: 3000,
+ maxBackoffTime: 2500,
+ protocols: ['json'],
+ },
+ },
+ },
+ },
+})
+```
+
+---
+
+#### WebRTC Provider
+
+> **注意:** 需要额外安装 WebRTC 依赖 `pnpm i y-webrtc`,并且搭配 [WebRTC 后端](#webrtc-服务器)使用
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+| --------------- | ------------------------- | ---- | ------ | ------------------- |
+| `type` | `'webrtc'` | 是 | - | 提供者类型 |
+| `roomName` | `string` | 是 | - | 房间名称 |
+| `signaling` | `string[]` | 否 | - | 信令服务器列表 |
+| `filterBcConns` | `boolean` | 否 | - | 是否过滤广播连接 |
+| `maxConns` | `number` | 否 | - | 最大连接数 |
+| `password` | `string` | 否 | - | 房间密码 |
+| `peerOpts` | `Record` | 否 | - | WebRTC 对等连接选项 |
+
+---
+
+#### WebRTC 前端配置示例
+
+```javascript
+const editor = new FluentEditor('#editor', {
+ theme: 'snow',
+ modules: {
+ 'collaborative-editing': {
+ provider: {
+ type: 'webrtc',
+ options: {
+ roomName: 'Tiny-Editor-WebRTC',
+ signaling: ['wss://signaling.yjs.dev'],
+ },
+ },
+ },
+ },
+})
+```
+
+#### 自定义 Provider
+
+TinyEditor 支持注册自定义的 Provider 类型,您可以实现自己的连接提供者。
+
+> **提示:** Yjs 生态系统提供了多种现成的 Provider 可供选择和参考,详见:[Yjs Connection Provider](https://docs.yjs.dev/ecosystem/connection-provider)。您可以基于这些 Provider 进行二次开发,或者作为实现自定义 Provider 的参考。
+
+##### UnifiedProvider 接口
+
+所有自定义 Provider 都必须实现 `UnifiedProvider` 接口:
+
+```typescript
+interface UnifiedProvider {
+ type: string // Provider 类型标识
+ awareness: Awareness // 感知实例
+ document: Y.Doc // Yjs 文档
+ connect: () => void // 连接方法
+ destroy: () => void // 销毁方法
+ disconnect: () => void // 断开连接方法
+ isConnected: boolean // 连接状态
+ isSynced: boolean // 同步状态
+
+ // 事件处理器
+ onConnect?: () => void // 连接成功回调
+ onDisconnect?: () => void // 断开连接回调
+ onError?: (error: Error) => void // 错误回调
+ onSyncChange?: (isSynced: boolean) => void // 同步状态变化回调
+}
+```
+
+##### 创建自定义 Provider
+
+1. **实现 Provider 类**
+
+```typescript
+import type { ProviderConstructorProps, UnifiedProvider } from '@opentiny/fluent-editor'
+import type { Awareness } from 'y-protocols/awareness'
+import * as Y from 'yjs'
+
+export class MyCustomProvider implements UnifiedProvider {
+ type = 'my-custom'
+ isConnected = false
+ isSynced = false
+
+ constructor({ options, awareness, doc, onConnect, onDisconnect, onError, onSyncChange }: ProviderConstructorProps<{ endpoint: string }>) {
+ this.document = doc
+ this.awareness = awareness
+ this.onConnect = onConnect
+ this.onDisconnect = onDisconnect
+ this.onError = onError
+ this.onSyncChange = onSyncChange
+ // 以上参数都会传入,可直接接收或重新定义
+
+ // 自定义逻辑
+ this.connect()
+ }
+
+ connect = () => {
+ // 连接逻辑
+ // 例如
+ // const provider = new WebsocketProvider(
+ // options.serverUrl,
+ // options.roomName,
+ // this.document,
+ // {
+ // awareness: this.awareness,
+ // ...options,
+ // },
+ // )
+ // provider.connect();
+ }
+
+ disconnect = () => {}
+ destroy = () => {}
+}
+```
+
+2. **注册 Provider**
+
+```typescript
+import { registerProviderType } from '@opentiny/fluent-editor'
+import { MyCustomProvider } from './MyCustomProvider'
+
+registerProviderType('my-custom', MyCustomProvider)
+```
+
+3. **使用自定义 Provider**
+
+```typescript
+const editor = new FluentEditor('#editor', {
+ modules: {
+ 'collaborative-editing': {
+ provider: {
+ type: 'my-custom',
+ options: {
+ endpoint: 'https://my-service.com/api',
+ },
+ },
+ },
+ },
+})
+```
+
+---
+
+### Awareness 配置
+
+Awareness 实现用户在线状态、光标位置等信息的实时同步。每个用户的在线状态、名称、颜色、光标位置等会自动广播给其他协作者,实现多人编辑时的身份和操作可视化。
+
+| 参数 | 类型 | 必填 | 说明 |
+| --------- | ---------------- | ---- | -------------------- |
+| `state` | `AwarenessState` | 否 | 用户状态信息 |
+| `timeout` | `number` | 否 | 用户状态超时时间(ms) |
+
+#### AwarenessState 结构
+
+| 参数 | 类型 | 必填 | 默认值 | 说明 |
+| ------- | -------- | ---- | ------------ | ---------------------------------- |
+| `name` | `string` | 否 | `User ${id}` | 用户名称 |
+| `color` | `string` | 否 | `#ff6b6b` | 用户颜色,用于光标和选区的颜色显示 |
+
+#### 示例
+
+```javascript
+const editor = new FluentEditor('#editor', {
+ theme: 'snow',
+ modules: {
+ 'collaborative-editing': {
+ awareness: {
+ state: {
+ name: `user${Math.random().toString(36).substring(2, 8)}`,
+ color: `#${Math.floor(Math.random() * 16777215).toString(16)}`,
+ },
+ timeout: 30000,
+ },
+ },
+ },
+})
+```
+
+---
+
+### 事件回调
+
+| 回调函数 | 参数 | 说明 |
+| -------------- | ------------------- | ----------------------------------------- |
+| `onConnect` | 无 | 成功连接到协作服务器时触发 |
+| `onDisconnect` | 无 | 与协作服务器连接断开时触发 |
+| `onSyncChange` | `isSynced: boolean` | 文档同步状态变化时触发,`true` 表示已同步 |
+
+---
+
+### Cursors 配置
+
+`cursors` 默认开启,并且支持以下配置(详细配置可见 [quill-cursors](https://github.com/reedsy/quill-cursors)):
+
+| 参数 | 类型 | 默认值 | 说明 |
+| ----------------------- | --------- | ------ | ---------------------- |
+| `template` | `string` | - | 光标模板 |
+| `hideDelayMs` | `number` | `5000` | 光标隐藏延迟时间 |
+| `hideSpeedMs` | `number` | `0` | 光标隐藏动画速度 |
+| `selectionChangeSource` | `string` | - | 选择变化源 |
+| `transformOnTextChange` | `boolean` | `true` | 文本变化时是否转换光标 |
+
+#### 示例
+
+> 注意光标模板内的类名不可变
+
+```javascript
+const CURSOR_CLASSES = {
+ SELECTION_CLASS: 'ql-cursor-selections',
+ CARET_CONTAINER_CLASS: 'ql-cursor-caret-container',
+ CARET_CLASS: 'ql-cursor-caret',
+ FLAG_CLASS: 'ql-cursor-flag',
+ NAME_CLASS: 'ql-cursor-name',
+}
+
+const editor = new FluentEditor('#editor', {
+ theme: 'snow',
+ modules: {
+ 'collaborative-editing': {
+ cursors: {
+ template: `
+
+
+
+
+
+
+
+ `,
+ hideDelayMs: 300,
+ hideSpeedMs: 300,
+ transformOnTextChange: true,
+ },
+ },
+ },
+})
+```
+
+---
+
+## 更多后端服务支持
+
+### y-websocket-server
+
+可以使用 [y-websocket-server](https://github.com/yjs/y-websocket-server/) 快速搭建 WebSocket 服务器。
+
+安装依赖:
+
+```bash
+git clone https://github.com/yjs/y-websocket-server.git
+cd y-websocket-server
+pnpm i
+```
+
+启动服务:
+
+| 操作系统 | 启动命令 |
+| ------------------ | --------------------------------------------------------------------------------------- |
+| Ubuntu/MacOS | `HOST=localhost PORT=1234 YPERSISTENCE=./dbDir npx y-websocket` |
+| Windows PowerShell | `$env:HOST="localhost"; $env:PORT="1234"; $env:YPERSISTENCE="./dbDir"; npx y-websocket` |
+
+`HOST`指定可访问地址,`PORT`指定暴露端口,`YPERSISTENCE`指定持久化目录。
+
+### WebRTC 服务器
+
+可以使用 [y-webrtc-server](https://github.com/yjs/y-webrtc/) 快速搭建 WebRTC 服务器。
+
+安装依赖:
+
+克隆 WebRTC 服务端仓库并安装依赖:
+
+```bash
+git clone https://github.com/yjs/y-webrtc.git
+cd y-webrtc
+pnpm i
+```
+
+启动服务:
+
+| 操作系统 | 启动命令 |
+| ------------------ | ------------------------------------------------------- |
+| Ubuntu/MacOS | `HOST=localhost PORT=4444 npx y-webrtc` |
+| Windows PowerShell | `$env:HOST="localhost"; $env:PORT="4444"; npx y-webrtc` |
+
+---
+
+## 自定义数据库持久化
+
+TinyEditor 基于 WebSocket 提供了自定义的协同编辑后端服务,支持 MongoDB 持久化和 Docker 容器化部署。
+
+详细的自定义持久化服务配置和部署请参考:[collaborative-editing-backend](https://github.com/opentiny/tiny-editor/tree/ospp-2025/collaborative-editing/packages/collaborative-editing-backend)
diff --git a/packages/docs/fluent-editor/public/Collab-arch.png b/packages/docs/fluent-editor/public/Collab-arch.png
new file mode 100644
index 00000000..e20732e9
Binary files /dev/null and b/packages/docs/fluent-editor/public/Collab-arch.png differ
diff --git a/packages/docs/fluent-editor/public/Tiny-editor-demo.png b/packages/docs/fluent-editor/public/Tiny-editor-demo.png
new file mode 100644
index 00000000..e165836d
Binary files /dev/null and b/packages/docs/fluent-editor/public/Tiny-editor-demo.png differ
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 4aedbff1..79e3a6ac 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -8,7 +8,7 @@
"build": "vitepress build fluent-editor",
"build:v3": "node --env-file=fluent-editor/.env.v3 ./node_modules/vitepress/bin/vitepress.js build fluent-editor",
"preview": "vitepress preview fluent-editor",
- "install:browser": "pnpm exec playwright install --with-deps chromium",
+ "install:browser": "pnpm exec playwright install --with-deps chromium firefox",
"test": "pnpm exec playwright test",
"test:ui": "pnpm exec playwright test --ui",
"report": "pnpm exec playwright show-report"
diff --git a/packages/fluent-editor/package.json b/packages/fluent-editor/package.json
index 32398045..cfc11af4 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.90.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..ef2d956f
--- /dev/null
+++ b/packages/fluent-editor/src/modules/collaborative-editing/awareness/awareness.ts
@@ -0,0 +1,141 @@
+import type QuillCursors from 'quill-cursors'
+import type { Awareness } from 'y-protocols/awareness'
+import type FluentEditor from '../../../core/fluent-editor'
+import * as Y from 'yjs'
+
+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
+}
+
+export function bindAwarenessToCursors(
+ awareness: Awareness,
+ cursorsModule: QuillCursors,
+ quill: FluentEditor,
+ yText: Y.Text,
+): (() => void) | void {
+ if (!cursorsModule || !awareness) return
+
+ const doc = yText.doc!
+
+ const updateCursor = (clientId: number, state: any) => {
+ try {
+ if (state?.cursor && clientId !== awareness.clientID) {
+ const user = state.user || {}
+ const color = user.color || '#ff6b6b'
+ const name = user.name || `User ${clientId}`
+
+ cursorsModule.createCursor(clientId.toString(), name, color)
+
+ const anchor = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(state.cursor.anchor), doc)
+ const head = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(state.cursor.head), doc)
+
+ if (anchor && head && anchor.type === yText && clientId) {
+ setTimeout(() => {
+ cursorsModule.moveCursor(clientId.toString(), {
+ index: anchor.index,
+ length: head.index - anchor.index,
+ })
+ }, 0)
+ }
+ }
+ else {
+ cursorsModule.removeCursor(clientId.toString())
+ }
+ }
+ catch (err) {
+ console.error('Cursor update failed:', err)
+ }
+ }
+
+ const selectionChangeHandler = (range: { index: number, length: number } | null) => {
+ setTimeout(() => {
+ if (range) {
+ const anchor = Y.createRelativePositionFromTypeIndex(yText, range.index)
+ const head = Y.createRelativePositionFromTypeIndex(yText, range.index + range.length)
+
+ const currentState = awareness.getLocalState()
+ if (!currentState?.cursor
+ || !Y.compareRelativePositions(anchor, currentState.cursor.anchor)
+ || !Y.compareRelativePositions(head, currentState.cursor.head)) {
+ awareness.setLocalStateField('cursor', { anchor, head })
+ }
+ }
+ else {
+ if (awareness.getLocalState()?.cursor !== null) {
+ awareness.setLocalStateField('cursor', null)
+ }
+ }
+ }, 0)
+ }
+
+ const changeHandler = ({ added, updated, removed }: {
+ added: number[]
+ updated: number[]
+ removed: number[]
+ }) => {
+ if (quill.composition.isComposing) return
+ const states = awareness.getStates()
+
+ added.forEach((id) => {
+ updateCursor(id, states.get(id))
+ })
+
+ updated.forEach((id) => {
+ updateCursor(id, states.get(id))
+ })
+
+ removed.forEach((id) => {
+ cursorsModule.removeCursor(id.toString())
+ })
+ }
+
+ awareness.on('change', changeHandler)
+ quill.on('editor-change', (eventName, ...args) => {
+ if (quill.composition.isComposing) return
+ if (eventName === 'text-change') {
+ if (args[2] === 'user') {
+ const range = quill.getSelection()
+ selectionChangeHandler(range)
+ }
+ }
+ else if (eventName === 'selection-change') {
+ if (args[2] === 'user') {
+ selectionChangeHandler(args[0] as { index: number, length: number } | null)
+ }
+ }
+ })
+
+ awareness.getStates().forEach((state, clientId) => {
+ updateCursor(clientId, state)
+ })
+
+ return () => {
+ awareness.off('change', changeHandler)
+ quill.off('editor-change', selectionChangeHandler)
+ }
+}
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..4a095877
--- /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 function setupIndexedDB(doc: Doc): () => void {
+ const fullDbName = `tiny-editor-${doc.guid}`
+
+ new IndexeddbPersistence(fullDbName, doc)
+
+ return (): void => {
+ indexedDB.deleteDatabase(fullDbName)
+ }
+}
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..04e9846e
--- /dev/null
+++ b/packages/fluent-editor/src/modules/collaborative-editing/collaborative-editing.ts
@@ -0,0 +1,115 @@
+import type FluentEditor from '../../fluent-editor'
+import type { UnifiedProvider } from './provider/providerRegistry'
+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 { bindAwarenessToCursors, setupAwareness } from './awareness'
+import { setupIndexedDB } from './awareness/y-indexeddb'
+import { createProvider } from './provider/providerRegistry'
+
+export class CollaborativeEditor {
+ private ydoc: Y.Doc = new Y.Doc()
+ private provider: UnifiedProvider
+ private awareness: Awareness
+ private cursors: QuillCursors | null
+ private cleanupBindings: (() => void) | null = null
+ private clearIndexedDB: (() => void) | null = null
+
+ constructor(
+ public quill: FluentEditor,
+ public options: YjsOptions,
+ ) {
+ this.ydoc = this.options.ydoc || new Y.Doc()
+
+ 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))
+ 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.options.onConnect,
+ onDisconnect: this.options.onDisconnect,
+ onError: this.options.onError,
+ onSyncChange: this.options.onSyncChange,
+ })
+ 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')
+ this.cleanupBindings = bindAwarenessToCursors(this.awareness, this.cursors, quill, ytext) || null
+ new QuillBinding(
+ ytext,
+ this.quill,
+ this.awareness,
+ )
+ }
+ else {
+ console.error('Failed to initialize collaborative editor: no valid provider configured')
+ }
+
+ if (this.options.offline !== false) {
+ this.clearIndexedDB = setupIndexedDB(this.ydoc)
+ }
+ }
+
+ public getAwareness() {
+ return this.awareness
+ }
+
+ public getProvider() {
+ return this.provider
+ }
+
+ public getYDoc() {
+ return this.ydoc
+ }
+
+ get isConnected() {
+ return this.provider?.isConnected ?? false
+ }
+
+ get isSynced() {
+ return this.provider?.isSynced ?? false
+ }
+
+ public getCursors() {
+ return this.cursors
+ }
+
+ public async destroy() {
+ this.cleanupBindings?.()
+ this.provider?.destroy?.()
+ this.cursors?.clearCursors()
+ this.awareness?.destroy?.()
+ this.clearIndexedDB?.()
+ this.ydoc?.destroy?.()
+ }
+}
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..75544599
--- /dev/null
+++ b/packages/fluent-editor/src/modules/collaborative-editing/module.ts
@@ -0,0 +1,32 @@
+import type FluentEditor from '../../fluent-editor'
+import { CollaborativeEditor } from './collaborative-editing'
+
+export class CollaborationModule {
+ private collaborativeEditor: CollaborativeEditor
+
+ constructor(public quill: FluentEditor, public options: any) {
+ this.collaborativeEditor = new CollaborativeEditor(quill, options)
+
+ window.addEventListener(
+ 'beforeunload',
+ () => { void this.collaborativeEditor.destroy().catch(err => console.warn('[yjs] destroy failed:', err)) },
+ { once: true },
+ )
+ }
+
+ public getCursors() {
+ return this.collaborativeEditor.getCursors()
+ }
+
+ public getAwareness() {
+ return this.collaborativeEditor.getAwareness()
+ }
+
+ public getProvider() {
+ return this.collaborativeEditor.getProvider()
+ }
+
+ public async destroy() {
+ await this.collaborativeEditor.destroy()
+ }
+}
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..6e37c898
--- /dev/null
+++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/index.ts
@@ -0,0 +1,3 @@
+export * from './providerRegistry'
+export * from './webrtc'
+export * from './websocket'
diff --git a/packages/fluent-editor/src/modules/collaborative-editing/provider/providerRegistry.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/providerRegistry.ts
new file mode 100644
index 00000000..36d90dc9
--- /dev/null
+++ b/packages/fluent-editor/src/modules/collaborative-editing/provider/providerRegistry.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 extends ProviderEventHandlers {
+ 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/webrtc.ts b/packages/fluent-editor/src/modules/collaborative-editing/provider/webrtc.ts
new file mode 100644
index 00000000..dd5904d7
--- /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 './providerRegistry'
+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
+
+ onConnect?: () => void
+ onDisconnect?: () => void
+ onError?: (error: Error) => void
+ 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..245648a5
--- /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 './providerRegistry'
+import { Awareness } from 'y-protocols/awareness'
+import { WebsocketProvider } from 'y-websocket'
+import * as Y from 'yjs'
+
+export interface WebsocketProviderOptions {
+ 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
+
+ onConnect?: () => void
+ onDisconnect?: () => void
+ onError?: (error: Error) => void
+ 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..ac4f7132
--- /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 } 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
+ 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'
diff --git a/packages/projects/package.json b/packages/projects/package.json
index 573d374b..72f8b521 100644
--- a/packages/projects/package.json
+++ b/packages/projects/package.json
@@ -11,12 +11,18 @@
"dependencies": {
"@opentiny/fluent-editor": "workspace:^",
"@tailwindcss/vite": "^4.0.0",
+ "quill-cursors": "^4.0.4",
"quill-header-list": "0.0.2",
"quill-table-up": "^3.0.1",
"sass": "^1.90.0",
"tailwindcss": "^4.0.0",
"vue": "^3.5.13",
- "vue-router": "4"
+ "vue-router": "4",
+ "y-indexeddb": "^9.0.12",
+ "y-protocols": "^1.0.6",
+ "y-quill": "^1.0.0",
+ "y-websocket": "^3.0.0",
+ "yjs": "^13.6.27"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
diff --git a/packages/projects/src/views/yuque/YuQue.vue b/packages/projects/src/views/yuque/YuQue.vue
index 85f2549f..4aea985b 100644
--- a/packages/projects/src/views/yuque/YuQue.vue
+++ b/packages/projects/src/views/yuque/YuQue.vue
@@ -1,11 +1,12 @@