ubiquitous-invention/apps/web/components/whiteboard/yjs-store.ts

117 lines
3.3 KiB
TypeScript
Raw Permalink Normal View History

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<typeof setTimeout> | 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<TLStore["loadStoreSnapshot"]>[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<TLStore["loadStoreSnapshot"]>[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();
};
}