ubiquitous-invention/apps/web/components/whiteboard/yjs-store.ts
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

116 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();
};
}