From 7f6b260c3517af8c887311ad1234c6d02ae71e5a Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Fri, 19 Sep 2025 17:28:16 +0800 Subject: [PATCH] feat: refactor editor's collaboration to reduce package size and minimize errors --- .../components/common/RichTextInput.svelte | 347 +----------------- .../common/RichTextInput/Collaboration.ts | 337 +++++++++++++++++ src/lib/stores/index.ts | 2 +- 3 files changed, 350 insertions(+), 336 deletions(-) create mode 100644 src/lib/components/common/RichTextInput/Collaboration.ts diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index d6f59ab34f..468813aeaf 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -114,19 +114,6 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { Editor, Extension, mergeAttributes } from '@tiptap/core'; - // Yjs imports - import * as Y from 'yjs'; - import { - ySyncPlugin, - yCursorPlugin, - yUndoPlugin, - undo, - redo, - prosemirrorJSONToYDoc, - yDocToProsemirrorJSON - } from 'y-prosemirror'; - import { keymap } from 'prosemirror-keymap'; - import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; import StarterKit from '@tiptap/starter-kit'; @@ -153,6 +140,7 @@ import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { all, createLowlight } from 'lowlight'; + import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration'; export let oncompositionstart = (e) => {}; export let oncompositionend = (e) => {}; @@ -161,7 +149,7 @@ // create a lowlight instance with all languages loaded const lowlight = createLowlight(all); - export let editor = null; + export let editor: Editor | null = null; export let socket = null; export let user = null; @@ -264,325 +252,11 @@ let jsonValue = ''; let mdValue = ''; - let lastSelectionBookmark = null; + let provider: SocketIOCollaborationProvider | null = null; - // Yjs setup - let ydoc = null; - let yXmlFragment = null; - let awareness = null; - - const getEditorInstance = async () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(editor); - }, 0); - }); - }; - - // Custom Yjs Socket.IO provider - class SocketIOProvider { - constructor(doc, documentId, socket, user) { - this.doc = doc; - this.documentId = documentId; - this.socket = socket; - this.user = user; - this.isConnected = false; - this.synced = false; - - this.setupEventListeners(); - } - - generateUserColor() { - const colors = [ - '#FF6B6B', - '#4ECDC4', - '#45B7D1', - '#96CEB4', - '#FFEAA7', - '#DDA0DD', - '#98D8C8', - '#F7DC6F', - '#BB8FCE', - '#85C1E9' - ]; - return colors[Math.floor(Math.random() * colors.length)]; - } - - joinDocument() { - const userColor = this.generateUserColor(); - this.socket.emit('ydoc:document:join', { - document_id: this.documentId, - user_id: this.user?.id, - user_name: this.user?.name, - user_color: userColor - }); - - // Set user awareness info - if (awareness && this.user) { - awareness.setLocalStateField('user', { - name: `${this.user.name}`, - color: userColor, - id: this.socket.id - }); - } - } - - setupEventListeners() { - // Listen for document updates from server - this.socket.on('ydoc:document:update', (data) => { - if (data.document_id === this.documentId && data.socket_id !== this.socket.id) { - try { - const update = new Uint8Array(data.update); - Y.applyUpdate(this.doc, update); - } catch (error) { - console.error('Error applying Yjs update:', error); - } - } - }); - - // Listen for document state from server - this.socket.on('ydoc:document:state', async (data) => { - if (data.document_id === this.documentId) { - try { - if (data.state) { - const state = new Uint8Array(data.state); - - if (state.length === 2 && state[0] === 0 && state[1] === 0) { - // Empty state, check if we have content to initialize - // check if editor empty as well - // const editor = await getEditorInstance(); - - const isEmptyEditor = !editor || editor.getText().trim() === ''; - if (isEmptyEditor) { - if (content && (data?.sessions ?? ['']).length === 1) { - const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content); - if (editorYdoc) { - Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); - } - } - } else { - // If the editor already has content, we don't need to send an empty state - if (this.doc.getXmlFragment('prosemirror').length > 0) { - this.socket.emit('ydoc:document:update', { - document_id: this.documentId, - user_id: this.user?.id, - socket_id: this.socket.id, - update: Y.encodeStateAsUpdate(this.doc) - }); - } else { - console.warn('Yjs document is empty, not sending state.'); - } - } - } else { - Y.applyUpdate(this.doc, state, 'server'); - } - } - this.synced = true; - } catch (error) { - console.error('Error applying Yjs state:', error); - - this.synced = false; - this.socket.emit('ydoc:document:state', { - document_id: this.documentId - }); - } - } - }); - - // Listen for awareness updates - this.socket.on('ydoc:awareness:update', (data) => { - if (data.document_id === this.documentId && awareness) { - try { - const awarenessUpdate = new Uint8Array(data.update); - awareness.applyUpdate(awarenessUpdate, 'server'); - } catch (error) { - console.error('Error applying awareness update:', error); - } - } - }); - - // Handle connection events - this.socket.on('connect', this.onConnect); - this.socket.on('disconnect', this.onDisconnect); - - // Listen for document updates from Yjs - this.doc.on('update', async (update, origin) => { - if (origin !== 'server' && this.isConnected) { - await tick(); // Ensure the DOM is updated before sending - this.socket.emit('ydoc:document:update', { - document_id: this.documentId, - user_id: this.user?.id, - socket_id: this.socket.id, - update: Array.from(update), - data: { - content: { - md: mdValue, - html: htmlValue, - json: jsonValue - } - } - }); - } - }); - - // Listen for awareness updates from Yjs - if (awareness) { - awareness.on('change', ({ added, updated, removed }, origin) => { - if (origin !== 'server' && this.isConnected) { - const changedClients = added.concat(updated).concat(removed); - const awarenessUpdate = awareness.encodeUpdate(changedClients); - this.socket.emit('ydoc:awareness:update', { - document_id: this.documentId, - user_id: this.socket.id, - update: Array.from(awarenessUpdate) - }); - } - }); - } - - if (this.socket.connected) { - this.isConnected = true; - this.joinDocument(); - } - } - - onConnect = () => { - this.isConnected = true; - this.joinDocument(); - }; - - onDisconnect = () => { - this.isConnected = false; - this.synced = false; - }; - - destroy() { - this.socket.off('ydoc:document:update'); - this.socket.off('ydoc:document:state'); - this.socket.off('ydoc:awareness:update'); - this.socket.off('connect', this.onConnect); - this.socket.off('disconnect', this.onDisconnect); - - if (this.isConnected) { - this.socket.emit('ydoc:document:leave', { - document_id: this.documentId, - user_id: this.user?.id - }); - } - } - } - - let provider = null; - - // Simple awareness implementation - class SimpleAwareness { - constructor(yDoc) { - // Yjs awareness expects clientID (not clientId) property - this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff); - // Map from clientID (number) to state (object) - this._states = new Map(); // _states, not states; will make getStates() for compat - this._updateHandlers = []; - this._localState = {}; - // As in Yjs Awareness, add our local state to the states map from the start: - this._states.set(this.clientID, this._localState); - } - on(event, handler) { - if (event === 'change') this._updateHandlers.push(handler); - } - off(event, handler) { - if (event === 'change') { - const i = this._updateHandlers.indexOf(handler); - if (i !== -1) this._updateHandlers.splice(i, 1); - } - } - getLocalState() { - return this._states.get(this.clientID) || null; - } - getStates() { - // Yjs returns a Map (clientID->state) - return this._states; - } - setLocalStateField(field, value) { - let localState = this._states.get(this.clientID); - if (!localState) { - localState = {}; - this._states.set(this.clientID, localState); - } - localState[field] = value; - // After updating, fire 'update' event to all handlers - for (const cb of this._updateHandlers) { - // Follows Yjs Awareness ({ added, updated, removed }, origin) - cb({ added: [], updated: [this.clientID], removed: [] }, 'local'); - } - } - applyUpdate(update, origin) { - // Very simple: Accepts a serialized JSON state for now as Uint8Array - try { - const str = new TextDecoder().decode(update); - const obj = JSON.parse(str); - // Should be a plain object: { clientID: state, ... } - for (const [k, v] of Object.entries(obj)) { - this._states.set(+k, v); - } - for (const cb of this._updateHandlers) { - cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin); - } - } catch (e) { - console.warn('SimpleAwareness: Could not decode update:', e); - } - } - encodeUpdate(clients) { - // Encodes the states for the given clientIDs as Uint8Array (JSON) - const obj = {}; - for (const id of clients || Array.from(this._states.keys())) { - const st = this._states.get(id); - if (st) obj[id] = st; - } - const json = JSON.stringify(obj); - return new TextEncoder().encode(json); - } - } - - // Yjs collaboration extension - const YjsCollaboration = Extension.create({ - name: 'yjsCollaboration', - - addProseMirrorPlugins() { - if (!collaboration || !yXmlFragment) return []; - - const plugins = [ - ySyncPlugin(yXmlFragment), - yUndoPlugin(), - keymap({ - 'Mod-z': undo, - 'Mod-y': redo, - 'Mod-Shift-z': redo - }) - ]; - - if (awareness) { - plugins.push(yCursorPlugin(awareness)); - } - - return plugins; - } - }); - - function initializeCollaboration() { - if (!collaboration) return; - - // Create Yjs document - ydoc = new Y.Doc(); - yXmlFragment = ydoc.getXmlFragment('prosemirror'); - awareness = new SimpleAwareness(ydoc); - - // Create custom Socket.IO provider - provider = new SocketIOProvider(ydoc, documentId, socket, user); - } - - let floatingMenuElement = null; - let bubbleMenuElement = null; - let element; + let floatingMenuElement: Element | null = null; + let bubbleMenuElement: Element | null = null; + let element: Element | null = null; const options = { throwOnError: false @@ -970,8 +644,9 @@ console.log('content', content); - if (collaboration) { - initializeCollaboration(); + if (collaboration && documentId && socket && user) { + const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration'); + provider = new SocketIOCollaborationProvider(documentId, socket, user, content); } console.log(bubbleMenuElement, floatingMenuElement); @@ -1064,7 +739,7 @@ }) ] : []), - ...(collaboration ? [YjsCollaboration] : []) + ...(collaboration && provider ? [provider.getEditorExtension()] : []) ], content: collaboration ? undefined : content, autofocus: messageInput ? true : false, @@ -1337,6 +1012,8 @@ enablePasteRules: richText }); + provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue })); + if (messageInput) { selectTemplate(); } diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts new file mode 100644 index 0000000000..7c7b7a48d9 --- /dev/null +++ b/src/lib/components/common/RichTextInput/Collaboration.ts @@ -0,0 +1,337 @@ +import * as Y from 'yjs'; +import { + ySyncPlugin, + yCursorPlugin, + yUndoPlugin, + undo, + redo, + prosemirrorJSONToYDoc +} from 'y-prosemirror'; +import type { Socket } from 'socket.io-client'; +import type { Awareness } from 'y-protocols/awareness'; +import type { SessionUser } from '$lib/stores'; +import { Editor, Extension } from '@tiptap/core'; +import { keymap } from 'prosemirror-keymap'; +import { tick } from 'svelte'; + +const USER_COLORS = [ + '#FF6B6B', + '#4ECDC4', + '#45B7D1', + '#96CEB4', + '#FFEAA7', + '#DDA0DD', + '#98D8C8', + '#F7DC6F', + '#BB8FCE', + '#85C1E9' +]; +const generateUserColor = () => { + return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)]; +}; + +export type EditorContentGetter = () => { + md: string; + html: string; + json: string; +}; + +// Custom Yjs Socket.IO provider +export class SocketIOCollaborationProvider { + private readonly doc = new Y.Doc(); + private readonly awareness = new SimpleAwareness(this.doc); + private isConnected = false; + private synced = false; + private editor: Editor | null = null; + private editorContentGetter: EditorContentGetter | null = null; + + constructor( + private readonly documentId: string, + private readonly socket: Socket, + private readonly user: SessionUser, + private readonly initialContent: string | null = null + ) { + this.setupEventListeners(); + } + + public getEditorExtension() { + return Extension.create({ + name: 'yjsCollaboration', + + addProseMirrorPlugins: () => { + const yXmlFragment = this.doc.getXmlFragment('prosemirror'); + if (!yXmlFragment) return []; + + const plugins = [ + ySyncPlugin(yXmlFragment), + yUndoPlugin(), + keymap({ + 'Mod-z': undo, + 'Mod-y': redo, + 'Mod-Shift-z': redo + }) + ]; + + plugins.push(yCursorPlugin(this.awareness as unknown as Awareness)); + + return plugins; + } + }); + } + + public setEditor(editor: Editor, editorContentGetter: EditorContentGetter) { + this.editor = editor; + this.editorContentGetter = editorContentGetter; + } + + private joinDocument() { + const userColor = generateUserColor(); + this.socket.emit('ydoc:document:join', { + document_id: this.documentId, + user_id: this.user?.id, + user_name: this.user?.name, + user_color: userColor + }); + + // Set user awareness info + if (this.user) { + this.awareness.setLocalStateField('user', { + name: `${this.user.name}`, + color: userColor, + id: this.socket.id + }); + } + } + + private setupEventListeners() { + // Listen for document updates from server + this.socket.on('ydoc:document:update', (data) => { + if (data.document_id === this.documentId && data.socket_id !== this.socket.id) { + try { + const update = new Uint8Array(data.update); + Y.applyUpdate(this.doc, update); + } catch (error) { + console.error('Error applying Yjs update:', error); + } + } + }); + + // Listen for document state from server + this.socket.on('ydoc:document:state', async (data) => { + if (data.document_id === this.documentId) { + try { + if (data.state) { + const state = new Uint8Array(data.state); + + if (state.length === 2 && state[0] === 0 && state[1] === 0) { + // Empty state, check if we have content to initialize + // check if editor empty as well + // const editor = await getEditorInstance(); + + const isEmptyEditor = !this.editor?.getText().trim(); + if (isEmptyEditor && this.editor) { + if (this.initialContent && (data?.sessions ?? ['']).length === 1) { + const editorYdoc = prosemirrorJSONToYDoc(this.editor.schema, this.initialContent); + if (editorYdoc) { + Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); + } + } + } else { + // If the editor already has content, we don't need to send an empty state + if (this.doc.getXmlFragment('prosemirror').length > 0) { + this.socket.emit('ydoc:document:update', { + document_id: this.documentId, + user_id: this.user?.id, + socket_id: this.socket.id, + update: Y.encodeStateAsUpdate(this.doc) + }); + } else { + console.warn('Yjs document is empty, not sending state.'); + } + } + } else { + Y.applyUpdate(this.doc, state, 'server'); + } + } + this.synced = true; + } catch (error) { + console.error('Error applying Yjs state:', error); + + this.synced = false; + this.socket.emit('ydoc:document:state', { + document_id: this.documentId + }); + } + } + }); + + // Listen for awareness updates + this.socket.on('ydoc:awareness:update', (data) => { + if (data.document_id === this.documentId) { + try { + const awarenessUpdate = new Uint8Array(data.update); + this.awareness.applyUpdate(awarenessUpdate, 'server'); + } catch (error) { + console.error('Error applying awareness update:', error); + } + } + }); + + // Handle connection events + this.socket.on('connect', this.onConnect); + this.socket.on('disconnect', this.onDisconnect); + + // Listen for document updates from Yjs + this.doc.on('update', async (update, origin) => { + if (this.editor && origin !== 'server' && this.isConnected) { + await tick(); // Ensure the DOM is updated before sending + this.socket.emit('ydoc:document:update', { + document_id: this.documentId, + user_id: this.user?.id, + socket_id: this.socket.id, + update: Array.from(update), + data: { + content: this.editorContentGetter?.() ?? { + md: '', + html: '', + json: '' + } + } + }); + } + }); + + // Listen for awareness updates from Yjs + this.awareness.on( + 'change', + ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: string + ) => { + if (origin !== 'server' && this.isConnected) { + const changedClients = added.concat(updated).concat(removed); + const awarenessUpdate = this.awareness.encodeUpdate(changedClients); + this.socket.emit('ydoc:awareness:update', { + document_id: this.documentId, + user_id: this.socket.id, + update: Array.from(awarenessUpdate) + }); + } + } + ); + + if (this.socket.connected) { + this.isConnected = true; + this.joinDocument(); + } + } + + private readonly onConnect = () => { + this.isConnected = true; + this.joinDocument(); + }; + + private readonly onDisconnect = () => { + this.isConnected = false; + this.synced = false; + }; + + public destroy() { + this.socket.off('ydoc:document:update'); + this.socket.off('ydoc:document:state'); + this.socket.off('ydoc:awareness:update'); + this.socket.off('connect', this.onConnect); + this.socket.off('disconnect', this.onDisconnect); + + if (this.isConnected) { + this.socket.emit('ydoc:document:leave', { + document_id: this.documentId, + user_id: this.user?.id + }); + } + + this.editor = null; + this.editorContentGetter = null; + } +} + +// Simple awareness implementation +class SimpleAwareness { + public readonly clientID: number; + private readonly _states: Map; + private readonly _updateHandlers: any[]; + private readonly _localState: any; + + public constructor(public readonly doc: Y.Doc) { + // Yjs awareness expects clientID (not clientId) property + this.clientID = doc.clientID ? doc.clientID : Math.floor(Math.random() * 0xffffffff); + // Map from clientID (number) to state (object) + this._states = new Map(); // _states, not states; will make getStates() for compat + this._updateHandlers = []; + this._localState = {}; + // As in Yjs Awareness, add our local state to the states map from the start: + this._states.set(this.clientID, this._localState); + } + + public on(event: string, handler: any) { + if (event === 'change') this._updateHandlers.push(handler); + } + + public off(event: string, handler: any) { + if (event === 'change') { + const i = this._updateHandlers.indexOf(handler); + if (i !== -1) this._updateHandlers.splice(i, 1); + } + } + + public getLocalState() { + return this._states.get(this.clientID) || null; + } + + public getStates() { + // Yjs returns a Map (clientID->state) + return this._states; + } + + public setLocalStateField(field: string, value: any) { + let localState = this._states.get(this.clientID); + if (!localState) { + localState = {}; + this._states.set(this.clientID, localState); + } + localState[field] = value; + // After updating, fire 'update' event to all handlers + for (const cb of this._updateHandlers) { + // Follows Yjs Awareness ({ added, updated, removed }, origin) + cb({ added: [], updated: [this.clientID], removed: [] }, 'local'); + } + } + + public applyUpdate(update: Uint8Array, origin: string) { + // Very simple: Accepts a serialized JSON state for now as Uint8Array + try { + const str = new TextDecoder().decode(update); + const obj = JSON.parse(str); + // Should be a plain object: { clientID: state, ... } + for (const [k, v] of Object.entries(obj)) { + this._states.set(+k, v); + } + for (const cb of this._updateHandlers) { + cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin); + } + } catch (e) { + console.warn('SimpleAwareness: Could not decode update:', e); + } + } + + public encodeUpdate(clients: number[]) { + // Encodes the states for the given clientIDs as Uint8Array (JSON) + const obj: Record = {}; + for (const id of clients || Array.from(this._states.keys())) { + const st = this._states.get(id); + if (st) obj[id] = st; + } + const json = JSON.stringify(obj); + return new TextEncoder().encode(json); + } +} diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 4945d4bed0..40563a6765 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -277,7 +277,7 @@ type PromptSuggestion = { title: [string, string]; }; -type SessionUser = { +export type SessionUser = { permissions: any; id: string; email: string;