diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..6c59086d --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true diff --git a/README.md b/README.md index 55562603..3d2ff68a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # TinyEditor + [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-) + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/opentiny/tiny-editor) diff --git a/README.zh-CN.md b/README.zh-CN.md index fcbf991e..10581d37 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,9 @@ # TinyEditor 富文本编辑器 + [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-) + [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/opentiny/tiny-editor) diff --git a/package.json b/package.json index d41980cc..89bbd9aa 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "pre-commit": "lint-staged", "commit-msg": "node verifyCommit.js" }, + "pnpm": { + "patchedDependencies": { + "quill@2.0.3": "patches/quill@2.0.3.patch" + } + }, "lint-staged": { "*.ts": [ "eslint --fix", diff --git a/packages/collaborative-editing-backend/.dockerignore b/packages/collaborative-editing-backend/.dockerignore new file mode 100644 index 00000000..d44747a7 --- /dev/null +++ b/packages/collaborative-editing-backend/.dockerignore @@ -0,0 +1,11 @@ +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..92ac0723 --- /dev/null +++ b/packages/collaborative-editing-backend/.env.example @@ -0,0 +1,6 @@ +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 \ No newline at end of file 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..290f69d0 --- /dev/null +++ b/packages/collaborative-editing-backend/README.md @@ -0,0 +1,150 @@ +# TinyEditor 协同编辑后端服务 + +基于 Yjs 和 WebSocket 的实时协同编辑后端服务,支持多用户实时协作编辑,使用 MongoDB 进行文档持久化。 + +## 快速开始 + +### 环境变量配置 + +### 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 + MONGO_INITDB_ROOT_PASSWORD: admin!123 + 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}' + env_file: + - .env + depends_on: + - mongodb + +volumes: + mongodb_data: +``` + +3. 在项目根目录下新建 `.env` 文件: + +```env +HOST=0.0.0.0 +PORT=1234 +MONGODB_URL=mongodb://admin:admin!123@mongodb:27017/?authSource=admin +MONGODB_DB=tinyeditor +MONGODB_COLLECTION=documents +GC=true +``` + +可参照下方表格进行配置 `.env` 文件 +| 变量名 | 必需 | 默认值 | 说明 | +| -------------------- | ---- | ------ | -------------------------------------------------------------- | +| `HOST` | ✅ | - | 服务器监听地址 | +| `PORT` | ✅ | - | WebSocket 服务端口 | +| `MONGODB_URL` | ✅ | - | MongoDB 连接字符串 | +| `MONGODB_DB` | ✅ | - | MongoDB 数据库名称 | +| `MONGODB_COLLECTION` | ✅ | - | MongoDB 集合名称 | +| `GC` | ❌ | `true` | 是否启用 Yjs 垃圾回收 | + +4. 在项目根目录下运行 `docker-compose` 启动容器: + +```bash +docker compose up +``` + +### 本地部署 + +启动 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..bb841d2b --- /dev/null +++ b/packages/collaborative-editing-backend/docker-compose.yml @@ -0,0 +1,30 @@ +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: + 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: diff --git a/packages/collaborative-editing-backend/ecosystem.config.cjs b/packages/collaborative-editing-backend/ecosystem.config.cjs new file mode 100644 index 00000000..4eec815d --- /dev/null +++ b/packages/collaborative-editing-backend/ecosystem.config.cjs @@ -0,0 +1,23 @@ +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 }) + +const envFile = path.resolve(__dirname, '.env') +const hasEnvFile = fs.existsSync(envFile) +const nodeArgs = hasEnvFile ? '--env-file=.env --no-warnings' : '--no-warnings' + +module.exports = { + apps: [{ + name: 'collaborative-editor-backend', + script: './dist/server.js', + node_args: nodeArgs, + 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]', + }, + }, + }, +}) diff --git a/packages/docs/fluent-editor/.vitepress/sidebar.ts b/packages/docs/fluent-editor/.vitepress/sidebar.ts index 798bc2c8..e4535f08 100644 --- a/packages/docs/fluent-editor/.vitepress/sidebar.ts +++ b/packages/docs/fluent-editor/.vitepress/sidebar.ts @@ -27,6 +27,7 @@ export function sidebar() { { text: '模拟语雀文档', link: 'https://opentiny.github.io/tiny-editor/projects' }, { text: '图片工具栏', link: '/docs/demo/image-tool' }, { text: 'AI', link: '/docs/demo/ai' }, + { text: '协同编辑', link: '/docs/demo/collaborative-editing' }, ], }, { 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..c465b616 --- /dev/null +++ b/packages/docs/fluent-editor/demos/collaborative-editing.spec.ts @@ -0,0 +1,316 @@ +import { type Browser, chromium, expect, firefox, 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 firefox.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 + } +}) + +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() +}) + +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 @@ +# 协同编辑 + +

+Tiny-editor-demo +

+ +

TinyEditor 支持多人实时协作编辑,支持多种连接协议(如 WebSocket、WebRTC),可自定义后端持久化,适用于多场景的在线协同编辑需求。 +

+ +## 在线协同演示 + +整个协同编辑系统由三部分组成:前端 `TinyEditor`、中间层协作引擎 `Yjs` 和后端服务(用于数据同步和持久化)。前端编辑器将操作传递给 `Yjs`,`Yjs` 通过不同的连接协议(如 `WebSocket` 或 `WebRTC`)实现多端同步, 并支持将数据持久化到后端数据库(如 `MongoDB`)。 +Tiny-editor-demo + +下面是一个完整的协同编辑演示: + +:::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 @@