117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
|
|
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();
|
||
|
|
};
|
||
|
|
}
|