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); } }