import type { TLStore } from "tldraw"; import * as Y from "yjs"; /** Y.Text name used under the shared Y.Doc for whole-document snapshots. */ export const TLDRAW_Y_TEXT_KEY = "tldraw-document-snapshot"; export type SyncTldrawWithYjsOptions = { /** Debounce pushing local store changes to Yjs (ms). Default 120. */ debounceMs?: number; /** Called when applying a remote snapshot from Yjs fails (e.g. migration). */ onError?: (error: unknown) => void; }; /** * Bidirectional sync between a tldraw `TLStore` (document scope) and a Yjs `Y.Text` field. * * Strategy: serialize the full document snapshot as JSON in Y.Text. Yjs/Hocuspocus replicate * the string; last-writer wins per character merge is **not** ideal for concurrent edits — this * is a practical stub that demonstrates the wiring. Production multiplayer usually syncs * granular records or uses tldraw's sync packages. * * @returns Unsubscribe/cleanup function. */ export function syncTldrawStoreWithYjs( store: TLStore, yDoc: Y.Doc, options?: SyncTldrawWithYjsOptions, ): () => void { const debounceMs = options?.debounceMs ?? 120; const yText = yDoc.getText(TLDRAW_Y_TEXT_KEY); let applyingRemote = false; let lastSent = ""; const pushToYjs = () => { if (applyingRemote) return; try { const snapshot = store.getStoreSnapshot("document"); const json = JSON.stringify(snapshot); if (json === lastSent) return; lastSent = json; yDoc.transact(() => { const len = yText.length; if (len > 0) yText.delete(0, len); if (json.length > 0) yText.insert(0, json); }, "tldraw"); } catch (e) { options?.onError?.(e); } }; let debounceTimer: ReturnType | null = null; const schedulePush = () => { if (applyingRemote) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { debounceTimer = null; pushToYjs(); }, debounceMs); }; const unsubStore = store.listen( () => { schedulePush(); }, { source: "user", scope: "document" }, ); const onYjsText = (_: Y.YTextEvent, transaction: Y.Transaction) => { if (transaction.local) return; const raw = yText.toString(); if (!raw) return; applyingRemote = true; try { const parsed = JSON.parse(raw) as Parameters[0]; store.mergeRemoteChanges(() => { store.loadStoreSnapshot(parsed); }); lastSent = raw; } catch (e) { options?.onError?.(e); } finally { applyingRemote = false; } }; yText.observe(onYjsText); // Initial hydrate if Yjs already has server state if (yText.length > 0) { try { const raw = yText.toString(); const parsed = JSON.parse(raw) as Parameters[0]; applyingRemote = true; store.mergeRemoteChanges(() => { store.loadStoreSnapshot(parsed); }); lastSent = raw; } catch (e) { options?.onError?.(e); } finally { applyingRemote = false; } } else { // Seed Yjs from the empty default store so peers share the same baseline pushToYjs(); } return () => { if (debounceTimer) clearTimeout(debounceTimer); yText.unobserve(onYjsText); unsubStore(); }; }