at main 1092 lines 31 kB view raw
1/** 2 * Collaborative editor with Loro CRDT and iroh P2P. 3 * 4 * Usage: 5 * ```typescript 6 * import { createCollabEditor } from '@weaver.sh/editor-collab'; 7 * 8 * const editor = await createCollabEditor({ 9 * container: document.getElementById('editor')!, 10 * resourceUri: 'at://did:plc:abc/sh.weaver.notebook.entry/xyz', 11 * onChange: () => console.log('changed'), 12 * onSessionNeeded: async (session) => { 13 * // Create session record on PDS, return URI 14 * return 'at://did:plc:abc/sh.weaver.edit.session/123'; 15 * }, 16 * onPeersNeeded: async (resourceUri) => { 17 * // Query index/backlinks for peer session records 18 * return [{ nodeId: 'peer-node-id' }]; 19 * }, 20 * }); 21 * 22 * // Get Loro snapshot for saving 23 * const snapshot = editor.exportSnapshot(); 24 * 25 * // Cleanup 26 * await editor.stopCollab(); 27 * editor.destroy(); 28 * ``` 29 */ 30 31import type { 32 CollabEditor, 33 CollabEditorConfig, 34 CursorRect, 35 EditorAction, 36 EntryJson, 37 EventResult, 38 FinalizedImage, 39 ParagraphRender, 40 PeerInfo, 41 PendingImage, 42 PresenceSnapshot, 43 Selection, 44 SelectionRect, 45 SessionInfo, 46 UserInfo, 47} from "./types"; 48 49// ============================================================ 50// Color utilities 51// ============================================================ 52 53/** Convert RGBA u32 (0xRRGGBBAA) to CSS rgba() string. */ 54function rgbaToCss(color: number): string { 55 const r = (color >>> 24) & 0xff; 56 const g = (color >>> 16) & 0xff; 57 const b = (color >>> 8) & 0xff; 58 const a = (color & 0xff) / 255; 59 return `rgba(${r}, ${g}, ${b}, ${a})`; 60} 61 62/** Convert RGBA u32 to CSS rgba() string with custom alpha. */ 63function rgbaToCssAlpha(color: number, alpha: number): string { 64 const r = (color >>> 24) & 0xff; 65 const g = (color >>> 16) & 0xff; 66 const b = (color >>> 8) & 0xff; 67 return `rgba(${r}, ${g}, ${b}, ${alpha})`; 68} 69 70// ============================================================ 71// Worker message types (must match Rust WorkerInput/WorkerOutput) 72// ============================================================ 73 74type WorkerInput = 75 | { type: "Init"; snapshot: number[]; draft_key: string } 76 | { type: "ApplyUpdates"; updates: number[] } 77 | { 78 type: "ExportSnapshot"; 79 cursor_offset: number; 80 editing_uri: string | null; 81 editing_cid: string | null; 82 notebook_uri: string | null; 83 } 84 | { type: "StartCollab"; topic: number[]; bootstrap_peers: string[] } 85 | { type: "BroadcastUpdate"; data: number[] } 86 | { type: "AddPeers"; peers: string[] } 87 | { type: "BroadcastJoin"; did: string; display_name: string } 88 | { type: "BroadcastCursor"; position: number; selection: [number, number] | null } 89 | { type: "StopCollab" }; 90 91type WorkerOutput = 92 | { type: "Ready" } 93 | { 94 type: "Snapshot"; 95 draft_key: string; 96 b64_snapshot: string; 97 content: string; 98 title: string; 99 cursor_offset: number; 100 editing_uri: string | null; 101 editing_cid: string | null; 102 notebook_uri: string | null; 103 export_ms: number; 104 encode_ms: number; 105 } 106 | { type: "Error"; message: string } 107 | { type: "CollabReady"; node_id: string; relay_url: string | null } 108 | { type: "CollabJoined" } 109 | { type: "RemoteUpdates"; data: number[] } 110 | { type: "PresenceUpdate"; collaborators: PresenceSnapshot["collaborators"]; peer_count: number } 111 | { type: "CollabStopped" } 112 | { type: "PeerConnected" }; 113 114// ============================================================ 115// Worker Bridge 116// ============================================================ 117 118/** 119 * Bridge to communicate with the EditorReactor web worker. 120 * 121 * The worker handles: 122 * - CPU-intensive Loro operations off main thread 123 * - iroh P2P networking for real-time collaboration 124 */ 125class WorkerBridge { 126 private worker: Worker | null = null; 127 private messageHandlers: ((msg: WorkerOutput) => void)[] = []; 128 private pendingReady: ((value: void) => void) | null = null; 129 130 /** 131 * Spawn the worker. Must be called before any other methods. 132 * 133 * @param workerUrl URL to the worker JS file (editor_worker.js) 134 */ 135 async spawn(workerUrl: string): Promise<void> { 136 if (this.worker) { 137 throw new Error("Worker already spawned"); 138 } 139 140 return new Promise((resolve, reject) => { 141 try { 142 this.worker = new Worker(workerUrl); 143 144 this.worker.onmessage = (e: MessageEvent) => { 145 const msg = e.data as WorkerOutput; 146 this.handleMessage(msg); 147 }; 148 149 this.worker.onerror = (e: ErrorEvent) => { 150 console.error("Worker error:", e); 151 reject(new Error(`Worker error: ${e.message}`)); 152 }; 153 154 // Wait for Ready message 155 this.pendingReady = resolve; 156 } catch (err) { 157 reject(err); 158 } 159 }); 160 } 161 162 /** 163 * Send a message to the worker. 164 */ 165 send(msg: WorkerInput): void { 166 if (!this.worker) { 167 throw new Error("Worker not spawned"); 168 } 169 this.worker.postMessage(msg); 170 } 171 172 /** 173 * Register a handler for worker messages. 174 */ 175 onMessage(handler: (msg: WorkerOutput) => void): () => void { 176 this.messageHandlers.push(handler); 177 return () => { 178 const idx = this.messageHandlers.indexOf(handler); 179 if (idx >= 0) { 180 this.messageHandlers.splice(idx, 1); 181 } 182 }; 183 } 184 185 /** 186 * Terminate the worker. 187 */ 188 terminate(): void { 189 if (this.worker) { 190 this.worker.terminate(); 191 this.worker = null; 192 } 193 this.messageHandlers = []; 194 } 195 196 private handleMessage(msg: WorkerOutput): void { 197 // Handle Ready specially to resolve spawn promise 198 if (msg.type === "Ready" && this.pendingReady) { 199 this.pendingReady(); 200 this.pendingReady = null; 201 } 202 203 // Dispatch to all handlers 204 for (const handler of this.messageHandlers) { 205 try { 206 handler(msg); 207 } catch (err) { 208 console.error("Error in worker message handler:", err); 209 } 210 } 211 } 212} 213 214// Internal types for WASM module 215interface JsCollabEditor { 216 mount(container: HTMLElement, onChange?: () => void): void; 217 unmount(): void; 218 isMounted(): boolean; 219 focus(): void; 220 blur(): void; 221 getMarkdown(): string; 222 getSnapshot(): unknown; 223 toEntry(): unknown; 224 setResolvedContent(content: JsResolvedContent): void; 225 getTitle(): string; 226 setTitle(title: string): void; 227 getPath(): string; 228 setPath(path: string): void; 229 getTags(): string[]; 230 setTags(tags: string[]): void; 231 executeAction(action: unknown): void; 232 addPendingImage(image: unknown, dataUrl: string): void; 233 finalizeImage(localId: string, finalized: unknown, blobRkey: string, ident: string): void; 234 removeImage(localId: string): void; 235 getPendingImages(): unknown; 236 getStagingUris(): string[]; 237 addEntryToIndex(title: string, path: string, canonicalUrl: string): void; 238 clearEntryIndex(): void; 239 getCursorOffset(): number; 240 getSelection(): Selection | null; 241 setCursorOffset(offset: number): void; 242 getLength(): number; 243 canUndo(): boolean; 244 canRedo(): boolean; 245 getParagraphs(): unknown; 246 renderAndUpdateDom(): void; 247 248 // Remote cursor positioning 249 getCursorRectRelative(position: number): CursorRect | null; 250 getSelectionRectsRelative(start: number, end: number): SelectionRect[]; 251 handleBeforeInput( 252 inputType: string, 253 data: string | null, 254 targetStart: number | null, 255 targetEnd: number | null, 256 isComposing: boolean, 257 ): EventResult; 258 handleKeydown(key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean): EventResult; 259 handleKeyup(key: string): void; 260 handlePaste(text: string): void; 261 handleCut(): string | null; 262 handleCopy(): string | null; 263 handleBlur(): void; 264 handleCompositionStart(data: string | null): void; 265 handleCompositionUpdate(data: string | null): void; 266 handleCompositionEnd(data: string | null): void; 267 handleAndroidEnter(): void; 268 syncCursor(): void; 269 270 // Loro sync methods 271 exportSnapshot(): Uint8Array; 272 exportUpdatesSince(version: Uint8Array): Uint8Array | null; 273 importUpdates(data: Uint8Array): void; 274 getVersion(): Uint8Array; 275 getCollabTopic(): Uint8Array | null; 276 getResourceUri(): string; 277 278 // Callbacks 279 setOnSessionNeeded(callback: (info: SessionInfo) => Promise<string>): void; 280 setOnSessionRefresh(callback: (uri: string) => Promise<void>): void; 281 setOnSessionEnd(callback: (uri: string) => Promise<void>): void; 282 setOnPeersNeeded(callback: (uri: string) => Promise<PeerInfo[]>): void; 283 setOnPresenceChanged(callback: (presence: PresenceSnapshot) => void): void; 284 setOnRemoteUpdate(callback: () => void): void; 285} 286 287interface JsCollabEditorConstructor { 288 new (resourceUri: string): JsCollabEditor; 289 fromMarkdown(resourceUri: string, content: string): JsCollabEditor; 290 fromSnapshot(resourceUri: string, snapshot: Uint8Array): JsCollabEditor; 291} 292 293interface JsResolvedContent { 294 addEmbed(atUri: string, html: string): void; 295} 296 297interface CollabWasmModule { 298 JsCollabEditor: JsCollabEditorConstructor; 299 create_resolved_content: () => JsResolvedContent; 300} 301 302let wasmModule: CollabWasmModule | null = null; 303 304/** 305 * Initialize the collab WASM module. 306 */ 307export async function initCollabWasm(): Promise<CollabWasmModule> { 308 if (wasmModule) return wasmModule; 309 310 // The collab module is built separately with the collab feature 311 const mod = await import("./bundler/weaver_editor.js"); 312 wasmModule = mod as unknown as CollabWasmModule; 313 return wasmModule; 314} 315 316/** 317 * Create a new collaborative editor instance. 318 * 319 * @param config Editor configuration 320 * @param workerUrl URL to the editor_worker.js file (default: "/worker/editor_worker.js") 321 */ 322export async function createCollabEditor( 323 config: CollabEditorConfig, 324 workerUrl = "/worker/editor_worker.js", 325): Promise<CollabEditor> { 326 const wasm = await initCollabWasm(); 327 328 // Create the inner WASM editor 329 let inner: JsCollabEditor; 330 if (config.initialLoroSnapshot) { 331 inner = wasm.JsCollabEditor.fromSnapshot(config.resourceUri, config.initialLoroSnapshot); 332 } else if (config.initialMarkdown) { 333 inner = wasm.JsCollabEditor.fromMarkdown(config.resourceUri, config.initialMarkdown); 334 } else { 335 inner = new wasm.JsCollabEditor(config.resourceUri); 336 } 337 338 // Set up resolved content if provided 339 if (config.resolvedContent) { 340 const resolved = wasm.create_resolved_content(); 341 for (const [uri, html] of config.resolvedContent.embeds) { 342 resolved.addEmbed(uri, html); 343 } 344 inner.setResolvedContent(resolved); 345 } 346 347 // Create wrapper with worker URL 348 const editor = new CollabEditorImpl(inner, config, workerUrl); 349 350 // Mount to container 351 editor.mountToContainer(config.container); 352 353 return editor; 354} 355 356/** 357 * Internal collab editor implementation. 358 */ 359class CollabEditorImpl implements CollabEditor { 360 private inner: JsCollabEditor; 361 private config: CollabEditorConfig; 362 private container: HTMLElement | null = null; 363 private editorElement: HTMLElement | null = null; 364 private destroyed = false; 365 366 // Worker bridge for P2P collab 367 private workerBridge: WorkerBridge | null = null; 368 private workerUrl: string; 369 private sessionUri: string | null = null; 370 private collabStarted = false; 371 private unsubscribeWorker: (() => void) | null = null; 372 private lastSyncedVersion: Uint8Array | null = null; 373 private lastBroadcastCursor: number = -1; 374 375 // Remote cursor overlay 376 private currentPresence: PresenceSnapshot | null = null; 377 private cursorOverlay: HTMLElement | null = null; 378 379 // Event handler refs for cleanup 380 private boundHandlers: { 381 beforeinput: (e: InputEvent) => void; 382 keydown: (e: KeyboardEvent) => void; 383 keyup: (e: KeyboardEvent) => void; 384 paste: (e: ClipboardEvent) => void; 385 cut: (e: ClipboardEvent) => void; 386 copy: (e: ClipboardEvent) => void; 387 blur: () => void; 388 compositionstart: (e: CompositionEvent) => void; 389 compositionupdate: (e: CompositionEvent) => void; 390 compositionend: (e: CompositionEvent) => void; 391 mouseup: () => void; 392 touchend: () => void; 393 }; 394 395 constructor(inner: JsCollabEditor, config: CollabEditorConfig, workerUrl: string) { 396 this.inner = inner; 397 this.config = config; 398 this.workerUrl = workerUrl; 399 400 // Bind event handlers 401 this.boundHandlers = { 402 beforeinput: this.onBeforeInput.bind(this), 403 keydown: this.onKeydown.bind(this), 404 keyup: this.onKeyup.bind(this), 405 paste: this.onPaste.bind(this), 406 cut: this.onCut.bind(this), 407 copy: this.onCopy.bind(this), 408 blur: this.onBlur.bind(this), 409 compositionstart: this.onCompositionStart.bind(this), 410 compositionupdate: this.onCompositionUpdate.bind(this), 411 compositionend: this.onCompositionEnd.bind(this), 412 mouseup: this.onMouseUp.bind(this), 413 touchend: this.onTouchEnd.bind(this), 414 }; 415 } 416 417 /** Mount to container and set up event listeners. */ 418 mountToContainer(container: HTMLElement): void { 419 this.container = container; 420 421 // Wrap onChange to also sync updates to worker 422 const wrappedOnChange = () => { 423 this.syncToWorker(); 424 this.config.onChange?.(); 425 // Re-render remote cursors after content changes (positions may shift) 426 this.renderRemoteCursors(); 427 }; 428 429 this.inner.mount(container, wrappedOnChange); 430 431 const editorEl = container.querySelector(".weaver-editor-content") as HTMLElement; 432 if (!editorEl) { 433 throw new Error("Failed to find editor element after mount"); 434 } 435 this.editorElement = editorEl; 436 this.attachEventListeners(); 437 438 // Create remote cursors overlay 439 this.cursorOverlay = document.createElement("div"); 440 this.cursorOverlay.className = "remote-cursors-overlay"; 441 container.appendChild(this.cursorOverlay); 442 443 // Initialize synced version 444 this.lastSyncedVersion = this.inner.getVersion(); 445 } 446 447 /** 448 * Sync local changes to the worker for broadcast. 449 */ 450 private syncToWorker(): void { 451 if (!this.workerBridge || !this.collabStarted || !this.lastSyncedVersion) { 452 return; 453 } 454 455 // Export updates since last sync 456 const updates = this.inner.exportUpdatesSince(this.lastSyncedVersion); 457 if (updates) { 458 // Send to worker for broadcast 459 this.workerBridge.send({ 460 type: "BroadcastUpdate", 461 data: Array.from(updates), 462 }); 463 464 // Also send to worker to keep shadow doc in sync 465 this.workerBridge.send({ 466 type: "ApplyUpdates", 467 updates: Array.from(updates), 468 }); 469 470 // Update synced version 471 this.lastSyncedVersion = this.inner.getVersion(); 472 } 473 474 // Also sync cursor 475 this.broadcastCursor(); 476 } 477 478 /** 479 * Render remote collaborator cursors. 480 */ 481 private renderRemoteCursors(): void { 482 if (!this.cursorOverlay || !this.currentPresence) { 483 return; 484 } 485 486 // Clear existing cursors 487 this.cursorOverlay.innerHTML = ""; 488 489 for (const collab of this.currentPresence.collaborators) { 490 if (collab.cursorPosition === undefined) { 491 continue; 492 } 493 494 const rect = this.inner.getCursorRectRelative(collab.cursorPosition); 495 if (!rect) { 496 continue; 497 } 498 499 // Convert color to CSS 500 const colorCss = rgbaToCss(collab.color); 501 const selectionColorCss = rgbaToCssAlpha(collab.color, 0.25); 502 503 // Render selection highlights first (behind cursor) 504 if (collab.selection) { 505 const [start, end] = collab.selection; 506 const [selStart, selEnd] = start <= end ? [start, end] : [end, start]; 507 const selRects = this.inner.getSelectionRectsRelative(selStart, selEnd); 508 509 for (const selRect of selRects) { 510 const selDiv = document.createElement("div"); 511 selDiv.className = "remote-selection"; 512 selDiv.style.cssText = ` 513 left: ${selRect.x}px; 514 top: ${selRect.y}px; 515 width: ${selRect.width}px; 516 height: ${selRect.height}px; 517 background-color: ${selectionColorCss}; 518 `; 519 this.cursorOverlay.appendChild(selDiv); 520 } 521 } 522 523 // Create cursor element 524 const cursorDiv = document.createElement("div"); 525 cursorDiv.className = "remote-cursor"; 526 cursorDiv.style.cssText = ` 527 left: ${rect.x}px; 528 top: ${rect.y}px; 529 --cursor-height: ${rect.height}px; 530 --cursor-color: ${colorCss}; 531 `; 532 533 // Caret line 534 const caretDiv = document.createElement("div"); 535 caretDiv.className = "remote-cursor-caret"; 536 cursorDiv.appendChild(caretDiv); 537 538 // Name label 539 const labelDiv = document.createElement("div"); 540 labelDiv.className = "remote-cursor-label"; 541 labelDiv.textContent = collab.displayName; 542 cursorDiv.appendChild(labelDiv); 543 544 this.cursorOverlay.appendChild(cursorDiv); 545 } 546 } 547 548 /** 549 * Broadcast cursor position to peers. 550 */ 551 private broadcastCursor(): void { 552 if (!this.workerBridge || !this.collabStarted) { 553 return; 554 } 555 556 const cursor = this.inner.getCursorOffset(); 557 const sel = this.inner.getSelection(); 558 559 // Only broadcast if cursor changed 560 if (cursor === this.lastBroadcastCursor && !sel) { 561 return; 562 } 563 564 this.lastBroadcastCursor = cursor; 565 566 this.workerBridge.send({ 567 type: "BroadcastCursor", 568 position: cursor, 569 selection: sel ? [sel.anchor, sel.head] : null, 570 }); 571 } 572 573 private attachEventListeners(): void { 574 const el = this.editorElement; 575 if (!el) return; 576 577 el.addEventListener("beforeinput", this.boundHandlers.beforeinput); 578 el.addEventListener("keydown", this.boundHandlers.keydown); 579 el.addEventListener("keyup", this.boundHandlers.keyup); 580 el.addEventListener("paste", this.boundHandlers.paste); 581 el.addEventListener("cut", this.boundHandlers.cut); 582 el.addEventListener("copy", this.boundHandlers.copy); 583 el.addEventListener("blur", this.boundHandlers.blur); 584 el.addEventListener("compositionstart", this.boundHandlers.compositionstart); 585 el.addEventListener("compositionupdate", this.boundHandlers.compositionupdate); 586 el.addEventListener("compositionend", this.boundHandlers.compositionend); 587 el.addEventListener("mouseup", this.boundHandlers.mouseup); 588 el.addEventListener("touchend", this.boundHandlers.touchend); 589 } 590 591 private detachEventListeners(): void { 592 const el = this.editorElement; 593 if (!el) return; 594 595 el.removeEventListener("beforeinput", this.boundHandlers.beforeinput); 596 el.removeEventListener("keydown", this.boundHandlers.keydown); 597 el.removeEventListener("keyup", this.boundHandlers.keyup); 598 el.removeEventListener("paste", this.boundHandlers.paste); 599 el.removeEventListener("cut", this.boundHandlers.cut); 600 el.removeEventListener("copy", this.boundHandlers.copy); 601 el.removeEventListener("blur", this.boundHandlers.blur); 602 el.removeEventListener("compositionstart", this.boundHandlers.compositionstart); 603 el.removeEventListener("compositionupdate", this.boundHandlers.compositionupdate); 604 el.removeEventListener("compositionend", this.boundHandlers.compositionend); 605 el.removeEventListener("mouseup", this.boundHandlers.mouseup); 606 el.removeEventListener("touchend", this.boundHandlers.touchend); 607 } 608 609 // === Event handlers (same as EditorImpl) === 610 611 private onBeforeInput(e: InputEvent): void { 612 const inputType = e.inputType; 613 const data = e.data ?? null; 614 615 let targetStart: number | null = null; 616 let targetEnd: number | null = null; 617 const ranges = e.getTargetRanges?.(); 618 if (ranges && ranges.length > 0) { 619 const range = ranges[0]; 620 targetStart = this.domOffsetToChar(range.startContainer, range.startOffset); 621 targetEnd = this.domOffsetToChar(range.endContainer, range.endOffset); 622 } 623 624 const isComposing = e.isComposing; 625 const result = this.inner.handleBeforeInput(inputType, data, targetStart, targetEnd, isComposing); 626 627 if (result === "Handled" || result === "HandledAsync") { 628 e.preventDefault(); 629 } 630 } 631 632 private onKeydown(e: KeyboardEvent): void { 633 const result = this.inner.handleKeydown(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey); 634 635 if (result === "Handled") { 636 e.preventDefault(); 637 } 638 } 639 640 private onKeyup(e: KeyboardEvent): void { 641 this.inner.handleKeyup(e.key); 642 } 643 644 private onPaste(e: ClipboardEvent): void { 645 e.preventDefault(); 646 const text = e.clipboardData?.getData("text/plain") ?? ""; 647 this.inner.handlePaste(text); 648 } 649 650 private onCut(e: ClipboardEvent): void { 651 e.preventDefault(); 652 const text = this.inner.handleCut(); 653 if (text && e.clipboardData) { 654 e.clipboardData.setData("text/plain", text); 655 } 656 } 657 658 private onCopy(e: ClipboardEvent): void { 659 e.preventDefault(); 660 const text = this.inner.handleCopy(); 661 if (text && e.clipboardData) { 662 e.clipboardData.setData("text/plain", text); 663 } 664 } 665 666 private onBlur(): void { 667 this.inner.handleBlur(); 668 } 669 670 private onCompositionStart(e: CompositionEvent): void { 671 this.inner.handleCompositionStart(e.data ?? null); 672 } 673 674 private onCompositionUpdate(e: CompositionEvent): void { 675 this.inner.handleCompositionUpdate(e.data ?? null); 676 } 677 678 private onCompositionEnd(e: CompositionEvent): void { 679 this.inner.handleCompositionEnd(e.data ?? null); 680 } 681 682 private onMouseUp(): void { 683 this.inner.syncCursor(); 684 this.broadcastCursor(); 685 } 686 687 private onTouchEnd(): void { 688 this.inner.syncCursor(); 689 this.broadcastCursor(); 690 } 691 692 private domOffsetToChar(node: Node, offset: number): number | null { 693 const editor = this.editorElement; 694 if (!editor) return null; 695 696 let charOffset = 0; 697 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT); 698 699 let currentNode = walker.nextNode(); 700 while (currentNode) { 701 if (currentNode === node) { 702 return charOffset + offset; 703 } 704 charOffset += currentNode.textContent?.length ?? 0; 705 currentNode = walker.nextNode(); 706 } 707 708 if (node.nodeType === Node.ELEMENT_NODE) { 709 for (let i = 0; i < offset && i < node.childNodes.length; i++) { 710 charOffset += node.childNodes[i].textContent?.length ?? 0; 711 } 712 return charOffset; 713 } 714 715 return null; 716 } 717 718 // === Loro sync methods === 719 720 exportSnapshot(): Uint8Array { 721 this.checkDestroyed(); 722 return this.inner.exportSnapshot(); 723 } 724 725 exportUpdatesSince(version: Uint8Array): Uint8Array | null { 726 this.checkDestroyed(); 727 return this.inner.exportUpdatesSince(version); 728 } 729 730 importUpdates(data: Uint8Array): void { 731 this.checkDestroyed(); 732 this.inner.importUpdates(data); 733 } 734 735 getVersion(): Uint8Array { 736 this.checkDestroyed(); 737 return this.inner.getVersion(); 738 } 739 740 getCollabTopic(): Uint8Array | null { 741 this.checkDestroyed(); 742 return this.inner.getCollabTopic(); 743 } 744 745 getResourceUri(): string { 746 this.checkDestroyed(); 747 return this.inner.getResourceUri(); 748 } 749 750 // === Collab lifecycle === 751 752 async startCollab(bootstrapPeers?: string[]): Promise<void> { 753 this.checkDestroyed(); 754 755 if (this.collabStarted) { 756 console.warn("Collab already started"); 757 return; 758 } 759 760 // Spawn worker 761 this.workerBridge = new WorkerBridge(); 762 await this.workerBridge.spawn(this.workerUrl); 763 764 // Set up message handler 765 this.unsubscribeWorker = this.workerBridge.onMessage((msg) => { 766 this.handleWorkerMessage(msg); 767 }); 768 769 // Initialize worker with current Loro snapshot 770 const snapshot = this.inner.exportSnapshot(); 771 this.workerBridge.send({ 772 type: "Init", 773 snapshot: Array.from(snapshot), 774 draft_key: this.config.resourceUri, 775 }); 776 777 // Start collab session 778 const topic = this.inner.getCollabTopic(); 779 if (!topic) { 780 throw new Error("No collab topic available"); 781 } 782 783 this.workerBridge.send({ 784 type: "StartCollab", 785 topic: Array.from(topic), 786 bootstrap_peers: bootstrapPeers ?? [], 787 }); 788 789 this.collabStarted = true; 790 } 791 792 async stopCollab(): Promise<void> { 793 this.checkDestroyed(); 794 795 if (!this.collabStarted || !this.workerBridge) { 796 return; 797 } 798 799 // Send stop to worker 800 this.workerBridge.send({ type: "StopCollab" }); 801 802 // Delete session record via callback 803 if (this.sessionUri && this.config.onSessionEnd) { 804 try { 805 await this.config.onSessionEnd(this.sessionUri); 806 } catch (err) { 807 console.error("Failed to delete session record:", err); 808 } 809 } 810 811 // Clean up 812 if (this.unsubscribeWorker) { 813 this.unsubscribeWorker(); 814 this.unsubscribeWorker = null; 815 } 816 this.workerBridge.terminate(); 817 this.workerBridge = null; 818 this.sessionUri = null; 819 this.collabStarted = false; 820 } 821 822 addPeers(nodeIds: string[]): void { 823 this.checkDestroyed(); 824 825 if (!this.workerBridge || !this.collabStarted) { 826 console.warn("Cannot add peers - collab not started"); 827 return; 828 } 829 830 this.workerBridge.send({ 831 type: "AddPeers", 832 peers: nodeIds, 833 }); 834 } 835 836 /** 837 * Handle messages from the worker. 838 */ 839 private async handleWorkerMessage(msg: WorkerOutput): Promise<void> { 840 switch (msg.type) { 841 case "CollabReady": { 842 // Worker has node ID and relay URL, create session record 843 if (this.config.onSessionNeeded) { 844 try { 845 const sessionInfo: SessionInfo = { 846 nodeId: msg.node_id, 847 relayUrl: msg.relay_url, 848 }; 849 this.sessionUri = await this.config.onSessionNeeded(sessionInfo); 850 851 // Discover peers now that we have a session 852 if (this.config.onPeersNeeded) { 853 const peers = await this.config.onPeersNeeded(this.config.resourceUri); 854 if (peers.length > 0) { 855 this.addPeers(peers.map((p) => p.nodeId)); 856 } 857 } 858 } catch (err) { 859 console.error("Failed to create session record:", err); 860 } 861 } 862 break; 863 } 864 865 case "CollabJoined": 866 // Successfully joined the gossip session 867 break; 868 869 case "RemoteUpdates": { 870 // Apply remote Loro updates to main document 871 const data = new Uint8Array(msg.data); 872 this.inner.importUpdates(data); 873 break; 874 } 875 876 case "PresenceUpdate": { 877 // Store presence and render remote cursors 878 const presence: PresenceSnapshot = { 879 collaborators: msg.collaborators, 880 peerCount: msg.peer_count, 881 }; 882 this.currentPresence = presence; 883 this.renderRemoteCursors(); 884 885 // Forward to callback 886 this.config.onPresenceChanged?.(presence); 887 break; 888 } 889 890 case "PeerConnected": { 891 // A new peer connected, send our Join message with user info 892 if (this.config.onUserInfoNeeded && this.workerBridge) { 893 try { 894 const userInfo = await this.config.onUserInfoNeeded(); 895 this.workerBridge.send({ 896 type: "BroadcastJoin", 897 did: userInfo.did, 898 display_name: userInfo.displayName, 899 }); 900 } catch (err) { 901 console.error("Failed to get user info for Join:", err); 902 } 903 } 904 break; 905 } 906 907 case "CollabStopped": 908 // Worker confirmed collab stopped 909 break; 910 911 case "Error": 912 console.error("Worker error:", msg.message); 913 break; 914 915 case "Ready": 916 case "Snapshot": 917 // Handled elsewhere or not needed for collab 918 break; 919 } 920 } 921 922 // === Public API (same as Editor) === 923 924 getMarkdown(): string { 925 this.checkDestroyed(); 926 return this.inner.getMarkdown(); 927 } 928 929 getSnapshot(): EntryJson { 930 this.checkDestroyed(); 931 return this.inner.getSnapshot() as EntryJson; 932 } 933 934 toEntry(): EntryJson { 935 this.checkDestroyed(); 936 return this.inner.toEntry() as EntryJson; 937 } 938 939 getTitle(): string { 940 this.checkDestroyed(); 941 return this.inner.getTitle(); 942 } 943 944 setTitle(title: string): void { 945 this.checkDestroyed(); 946 this.inner.setTitle(title); 947 } 948 949 getPath(): string { 950 this.checkDestroyed(); 951 return this.inner.getPath(); 952 } 953 954 setPath(path: string): void { 955 this.checkDestroyed(); 956 this.inner.setPath(path); 957 } 958 959 getTags(): string[] { 960 this.checkDestroyed(); 961 return this.inner.getTags(); 962 } 963 964 setTags(tags: string[]): void { 965 this.checkDestroyed(); 966 this.inner.setTags(tags); 967 } 968 969 executeAction(action: EditorAction): void { 970 this.checkDestroyed(); 971 this.inner.executeAction(action); 972 } 973 974 addPendingImage(image: PendingImage, dataUrl: string): void { 975 this.checkDestroyed(); 976 this.inner.addPendingImage(image, dataUrl); 977 this.config.onImageAdd?.(image); 978 } 979 980 finalizeImage(localId: string, finalized: FinalizedImage, blobRkey: string, identifier: string): void { 981 this.checkDestroyed(); 982 this.inner.finalizeImage(localId, finalized, blobRkey, identifier); 983 } 984 985 removeImage(localId: string): void { 986 this.checkDestroyed(); 987 this.inner.removeImage(localId); 988 } 989 990 getPendingImages(): PendingImage[] { 991 this.checkDestroyed(); 992 return this.inner.getPendingImages() as PendingImage[]; 993 } 994 995 getStagingUris(): string[] { 996 this.checkDestroyed(); 997 return this.inner.getStagingUris(); 998 } 999 1000 addEntryToIndex(title: string, path: string, canonicalUrl: string): void { 1001 this.checkDestroyed(); 1002 this.inner.addEntryToIndex(title, path, canonicalUrl); 1003 } 1004 1005 clearEntryIndex(): void { 1006 this.checkDestroyed(); 1007 this.inner.clearEntryIndex(); 1008 } 1009 1010 getCursorOffset(): number { 1011 this.checkDestroyed(); 1012 return this.inner.getCursorOffset(); 1013 } 1014 1015 setCursorOffset(offset: number): void { 1016 this.checkDestroyed(); 1017 this.inner.setCursorOffset(offset); 1018 } 1019 1020 getLength(): number { 1021 this.checkDestroyed(); 1022 return this.inner.getLength(); 1023 } 1024 1025 canUndo(): boolean { 1026 this.checkDestroyed(); 1027 return this.inner.canUndo(); 1028 } 1029 1030 canRedo(): boolean { 1031 this.checkDestroyed(); 1032 return this.inner.canRedo(); 1033 } 1034 1035 focus(): void { 1036 this.checkDestroyed(); 1037 this.inner.focus(); 1038 } 1039 1040 blur(): void { 1041 this.checkDestroyed(); 1042 this.inner.blur(); 1043 } 1044 1045 getParagraphs(): ParagraphRender[] { 1046 this.checkDestroyed(); 1047 return this.inner.getParagraphs() as ParagraphRender[]; 1048 } 1049 1050 renderAndUpdateDom(): void { 1051 this.checkDestroyed(); 1052 this.inner.renderAndUpdateDom(); 1053 } 1054 1055 // === Remote cursor positioning === 1056 1057 getCursorRectRelative(position: number): CursorRect | null { 1058 this.checkDestroyed(); 1059 return this.inner.getCursorRectRelative(position) as CursorRect | null; 1060 } 1061 1062 getSelectionRectsRelative(start: number, end: number): SelectionRect[] { 1063 this.checkDestroyed(); 1064 return this.inner.getSelectionRectsRelative(start, end) as SelectionRect[]; 1065 } 1066 1067 destroy(): void { 1068 if (this.destroyed) return; 1069 this.destroyed = true; 1070 1071 // Stop collab if active (fire and forget) 1072 if (this.collabStarted && this.workerBridge) { 1073 this.workerBridge.send({ type: "StopCollab" }); 1074 if (this.unsubscribeWorker) { 1075 this.unsubscribeWorker(); 1076 } 1077 this.workerBridge.terminate(); 1078 } 1079 1080 this.detachEventListeners(); 1081 this.inner.unmount(); 1082 this.container = null; 1083 this.editorElement = null; 1084 this.workerBridge = null; 1085 } 1086 1087 private checkDestroyed(): void { 1088 if (this.destroyed) { 1089 throw new Error("CollabEditor has been destroyed"); 1090 } 1091 } 1092}