| import * as Y from 'yjs'; |
| import { |
| ySyncPlugin, |
| yCursorPlugin, |
| yUndoPlugin, |
| undo, |
| redo, |
| prosemirrorJSONToYDoc |
| } from 'y-prosemirror'; |
| import type { Socket } from 'socket.io-client'; |
| 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; |
| }; |
|
|
| |
| 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)); |
|
|
| 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 |
| }); |
|
|
| |
| if (this.user) { |
| this.awareness.setLocalStateField('user', { |
| name: `${this.user.name}`, |
| color: userColor, |
| id: this.socket.id |
| }); |
| } |
| } |
|
|
| private setupEventListeners() { |
| |
| 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); |
| } |
| } |
| }); |
|
|
| |
| 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) { |
| |
| |
| |
|
|
| const isEmptyEditor = !this.editor?.getText().trim(); |
| if (isEmptyEditor && this.editor) { |
| if (this.initialContent && (data?.sessions ?? ['']).length === 1) { |
| |
| if (typeof this.initialContent === 'string') { |
| |
| this.editor.commands.setContent(this.initialContent); |
| |
| } else { |
| |
| const editorYdoc = prosemirrorJSONToYDoc( |
| this.editor.schema, |
| this.initialContent |
| ); |
| if (editorYdoc) { |
| Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); |
| } |
| } |
| } |
| } else { |
| |
| 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 |
| }); |
| } |
| } |
| }); |
|
|
| |
| 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); |
| } |
| } |
| }); |
|
|
| |
| this.socket.on('connect', this.onConnect); |
| this.socket.on('disconnect', this.onDisconnect); |
|
|
| |
| this.doc.on('update', async (update, origin) => { |
| if (this.editor && origin !== 'server' && this.isConnected) { |
| await tick(); |
| 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: '' |
| } |
| } |
| }); |
| } |
| }); |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| class SimpleAwareness { |
| public readonly clientID: number; |
| private readonly _states: Map<number, any>; |
| private readonly _updateHandlers: any[]; |
| private readonly _localState: any; |
|
|
| public constructor(public readonly doc: Y.Doc) { |
| |
| this.clientID = doc.clientID ? doc.clientID : Math.floor(Math.random() * 0xffffffff); |
| |
| this._states = new Map(); |
| this._updateHandlers = []; |
| this._localState = {}; |
| |
| 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() { |
| |
| 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; |
| |
| for (const cb of this._updateHandlers) { |
| |
| cb({ added: [], updated: [this.clientID], removed: [] }, 'local'); |
| } |
| } |
|
|
| public applyUpdate(update: Uint8Array, origin: string) { |
| |
| try { |
| const str = new TextDecoder().decode(update); |
| const obj = JSON.parse(str); |
| |
| 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[]) { |
| |
| const obj: Record<number, any> = {}; |
| 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); |
| } |
| } |
|
|