atproto blogging
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}