import * as Y from 'yjs'; import { BlockType, DocumentBlock } from './types'; // Convert Uint8Array to Base64 export function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = ''; const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); } // Convert Base64 to Uint8Array export function base64ToUint8Array(base64: string): Uint8Array { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } const COLLAB_COLORS = [ '#3B82F6', // Blue '#10B981', // Emerald '#F59E0B', // Amber '#8B5CF6', // Purple '#EC4899', // Pink '#06B6D4', // Cyan '#F97316', // Orange '#14B8A6', // Teal ]; export function getCollaboratorColor(userId: string): string { if (!userId) return COLLAB_COLORS[0]; let hash = 0; for (let i = 0; i < userId.length; i++) { hash = (hash << 5) - hash + userId.charCodeAt(i); hash |= 0; } const idx = Math.abs(hash) % COLLAB_COLORS.length; return COLLAB_COLORS[idx]; } export interface CollaboratorAwareness { userId: string; userName: string; activeBlockId: string | null; color: string; lastActive: number; } export class DocumentCRDTSession { public docId: string; public doc: Y.Doc; private order: Y.Array; private blocks: Y.Map>; private destroyed: boolean = false; public onLocalUpdate?: (base64Update: string) => void; public onRemoteChange?: (blocks: DocumentBlock[]) => void; constructor(docId: string) { this.docId = docId; this.doc = new Y.Doc({ guid: docId }); this.order = this.doc.getArray('blockOrder'); this.blocks = this.doc.getMap>('blocks'); this.doc.on('update', (update: Uint8Array, origin: any) => { if (this.destroyed) return; if (origin !== 'remote') { const b64 = uint8ArrayToBase64(update); if (this.onLocalUpdate) { this.onLocalUpdate(b64); } } else { if (this.onRemoteChange) { this.onRemoteChange(this.getBlocks()); } } }); } public destroy(): void { this.destroyed = true; this.doc.destroy(); } public getBlocks(): DocumentBlock[] { const orderList = this.order.toArray(); const result: DocumentBlock[] = []; for (const id of orderList) { const bMap = this.blocks.get(id); if (bMap) { result.push({ id, docId: this.docId, type: (bMap.get('type') || 'bullet') as BlockType, content: bMap.get('content') || '', level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0, checked: !!bMap.get('checked'), collapsed: !!bMap.get('collapsed'), lang: bMap.get('lang') || '', sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0, updatedAt: bMap.get('updatedAt') || undefined, }); } } return result; } public initFromBlocks(initialBlocks: DocumentBlock[]): void { this.doc.transact(() => { if (this.order.length === 0 && initialBlocks.length > 0) { const ids: string[] = []; for (const b of initialBlocks) { ids.push(b.id); const bMap = new Y.Map(); bMap.set('id', b.id); bMap.set('type', b.type || 'bullet'); bMap.set('content', b.content || ''); bMap.set('level', b.level || 0); bMap.set('checked', !!b.checked); bMap.set('collapsed', !!b.collapsed); bMap.set('lang', b.lang || ''); bMap.set('sortOrder', b.sortOrder || 0); bMap.set('updatedAt', b.updatedAt || new Date().toISOString()); this.blocks.set(b.id, bMap); } this.order.push(ids); } }, 'init'); } public updateBlock(id: string, updates: Partial): void { this.doc.transact(() => { const bMap = this.blocks.get(id); if (bMap) { if (updates.type !== undefined) bMap.set('type', updates.type); if (updates.content !== undefined) bMap.set('content', updates.content); if (updates.level !== undefined) bMap.set('level', updates.level); if (updates.checked !== undefined) bMap.set('checked', updates.checked); if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed); if (updates.lang !== undefined) bMap.set('lang', updates.lang); if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder); bMap.set('updatedAt', new Date().toISOString()); } }, 'local'); } public insertBlock(index: number, block: DocumentBlock): void { this.doc.transact(() => { const bMap = new Y.Map(); bMap.set('id', block.id); bMap.set('type', block.type || 'bullet'); bMap.set('content', block.content || ''); bMap.set('level', block.level || 0); bMap.set('checked', !!block.checked); bMap.set('collapsed', !!block.collapsed); bMap.set('lang', block.lang || ''); bMap.set('sortOrder', block.sortOrder || 0); bMap.set('updatedAt', new Date().toISOString()); this.blocks.set(block.id, bMap); if (index >= 0 && index <= this.order.length) { this.order.insert(index, [block.id]); } else { this.order.push([block.id]); } }, 'local'); } public deleteBlock(id: string): void { this.doc.transact(() => { const currentOrder = this.order.toArray(); const idx = currentOrder.indexOf(id); if (idx !== -1) { this.order.delete(idx, 1); } this.blocks.delete(id); }, 'local'); } public deleteMultipleBlocks(ids: string[]): void { if (ids.length === 0) return; const toDeleteSet = new Set(ids); this.doc.transact(() => { const currentOrder = this.order.toArray(); // Delete from order backwards to preserve indices for (let i = currentOrder.length - 1; i >= 0; i--) { if (toDeleteSet.has(currentOrder[i])) { this.order.delete(i, 1); } } for (const id of ids) { this.blocks.delete(id); } }, 'local'); } public reorderBlocks(newOrder: string[]): void { this.doc.transact(() => { this.order.delete(0, this.order.length); this.order.push(newOrder); }, 'local'); } public syncFullBlocks(blocks: DocumentBlock[]): void { this.doc.transact(() => { const nextIds = blocks.map((b) => b.id); const currentIds = new Set(this.order.toArray()); // Upsert all blocks for (const b of blocks) { let bMap = this.blocks.get(b.id); if (!bMap) { bMap = new Y.Map(); this.blocks.set(b.id, bMap); } bMap.set('id', b.id); bMap.set('type', b.type || 'bullet'); bMap.set('content', b.content || ''); bMap.set('level', b.level || 0); bMap.set('checked', !!b.checked); bMap.set('collapsed', !!b.collapsed); bMap.set('lang', b.lang || ''); bMap.set('sortOrder', b.sortOrder || 0); bMap.set('updatedAt', b.updatedAt || new Date().toISOString()); } // Delete blocks no longer present const nextIdSet = new Set(nextIds); currentIds.forEach((id) => { if (!nextIdSet.has(id)) { this.blocks.delete(id); } }); // Update order if different const currentOrder = this.order.toArray(); const orderChanged = currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]); if (orderChanged) { this.order.delete(0, this.order.length); this.order.push(nextIds); } }, 'local'); } public applyRemoteUpdate(base64Update: string): void { try { const bytes = base64ToUint8Array(base64Update); Y.applyUpdate(this.doc, bytes, 'remote'); } catch (err) { console.error('[CRDT] Failed to apply remote update:', err); } } public applyRemoteUpdates(base64Updates: string[]): void { for (const b64 of base64Updates) { this.applyRemoteUpdate(b64); } } public encodeState(): string { const bytes = Y.encodeStateAsUpdate(this.doc); return uint8ArrayToBase64(bytes); } }