From ab3bb5097096a5591fd09c91b51db2847f55cd45 Mon Sep 17 00:00:00 2001 From: kelmith Date: Mon, 13 Jul 2026 19:08:33 +0530 Subject: [PATCH 1/3] feat: multi-user CRDT using Y on the canvas --- package-lock.json | 90 ++++++++++- package.json | 7 +- packages/collab/package.json | 50 ++++++ packages/collab/src/doc.test.ts | 151 ++++++++++++++++++ packages/collab/src/doc.ts | 200 ++++++++++++++++++++++++ packages/collab/src/index.ts | 39 +++++ packages/collab/src/useCollaborators.ts | 98 ++++++++++++ packages/collab/src/useYjsCanvas.ts | 139 ++++++++++++++++ packages/collab/tsconfig.json | 10 ++ packages/collab/vitest.config.ts | 10 ++ 10 files changed, 785 insertions(+), 9 deletions(-) create mode 100644 packages/collab/package.json create mode 100644 packages/collab/src/doc.test.ts create mode 100644 packages/collab/src/doc.ts create mode 100644 packages/collab/src/index.ts create mode 100644 packages/collab/src/useCollaborators.ts create mode 100644 packages/collab/src/useYjsCanvas.ts create mode 100644 packages/collab/tsconfig.json create mode 100644 packages/collab/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index a248107..d751c99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2462,6 +2462,17 @@ "dev": true, "license": "MIT" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2595,6 +2606,28 @@ "node": ">=6" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3215,6 +3248,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/system-canvas-collab": { + "resolved": "packages/collab", + "link": true + }, "node_modules/system-canvas-demo": { "resolved": "demo", "link": true @@ -4217,9 +4254,50 @@ "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "packages/collab": { + "name": "system-canvas-collab", + "version": "0.2.57", + "license": "MIT", + "devDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^19.0.0", + "system-canvas": "^0.2.57", + "typescript": "^5.4.0", + "vitest": "^4.1.7", + "yjs": "^13.6.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "system-canvas": "^0.2.57", + "yjs": "^13.6.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "packages/core": { "name": "system-canvas", - "version": "0.2.54", + "version": "0.2.57", "license": "MIT", "devDependencies": { "typescript": "^5.4.0" @@ -4227,13 +4305,13 @@ }, "packages/react": { "name": "system-canvas-react", - "version": "0.2.54", + "version": "0.2.57", "license": "MIT", "dependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", "d3-zoom": "^3.0.0", - "system-canvas": "^0.2.54" + "system-canvas": "^0.2.57" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -4257,13 +4335,13 @@ }, "packages/standalone": { "name": "system-canvas-standalone", - "version": "0.2.54", + "version": "0.2.57", "license": "MIT", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", - "system-canvas": "^0.2.54", - "system-canvas-react": "^0.2.54" + "system-canvas": "^0.2.57", + "system-canvas-react": "^0.2.57" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/package.json b/package.json index 3005c39..5510d55 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "demo" ], "scripts": { - "build": "npm run build:core && npm run build:react && npm run build:standalone", + "build": "npm run build:core && npm run build:react && npm run build:standalone && npm run build:collab", "build:core": "npm run build -w system-canvas", "build:react": "npm run build -w system-canvas-react", "build:standalone": "npm run build -w system-canvas-standalone", + "build:collab": "npm run build -w system-canvas-collab", "dev": "npm run dev -w system-canvas-demo", - "test": "npm run test -w system-canvas-react", - "typecheck": "npm run typecheck -w system-canvas && npm run typecheck -w system-canvas-react && npm run typecheck -w system-canvas-standalone && npm run typecheck -w system-canvas-demo", + "test": "npm run test -w system-canvas-react && npm run test -w system-canvas-collab", + "typecheck": "npm run typecheck -w system-canvas && npm run typecheck -w system-canvas-react && npm run typecheck -w system-canvas-standalone && npm run typecheck -w system-canvas-collab && npm run typecheck -w system-canvas-demo", "sync-versions": "node scripts/sync-versions.mjs", "release:minor": "npm version minor --no-git-tag-version && npm run sync-versions && git add -A && git commit -m \"release: v$(node -p \\\"require('./package.json').version\\\")\" && git push", "release:major": "npm version major --no-git-tag-version && npm run sync-versions && git add -A && git commit -m \"release: v$(node -p \\\"require('./package.json').version\\\")\" && git push" diff --git a/packages/collab/package.json b/packages/collab/package.json new file mode 100644 index 0000000..e8008a9 --- /dev/null +++ b/packages/collab/package.json @@ -0,0 +1,50 @@ +{ + "name": "system-canvas-collab", + "version": "0.2.57", + "description": "Yjs-backed multi-user collaboration for system-canvas. Binds a Y.Doc to the renderer's controlled props so any consumer gets conflict-free multiplayer by supplying only a transport.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "keywords": [ + "system-canvas", + "yjs", + "crdt", + "collaboration", + "multiplayer" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "system-canvas": "^0.2.57", + "yjs": "^13.6.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^19.0.0", + "system-canvas": "^0.2.57", + "typescript": "^5.4.0", + "vitest": "^4.1.7", + "yjs": "^13.6.0" + } +} diff --git a/packages/collab/src/doc.test.ts b/packages/collab/src/doc.test.ts new file mode 100644 index 0000000..ca8e097 --- /dev/null +++ b/packages/collab/src/doc.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import type { CanvasData, CanvasNode } from "system-canvas"; +import { + addEdge, + addNode, + removeNode, + seedYDoc, + updateNode, + yDocToCanvasData, +} from "./doc.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function node(id: string, extra: Partial = {}): CanvasNode { + return { id, type: "text", x: 0, y: 0, text: id, ...extra }; +} + +/** Full bidirectional merge — after this, a and b have converged. */ +function sync(a: Y.Doc, b: Y.Doc): void { + const ua = Y.encodeStateAsUpdate(a); + const ub = Y.encodeStateAsUpdate(b); + Y.applyUpdate(a, ub); + Y.applyUpdate(b, ua); +} + +function getNode(data: CanvasData, id: string): CanvasNode | undefined { + return data.nodes?.find((n) => n.id === id); +} + +/** Two docs seeded from the same initial state (both start identical). */ +function makePair(initial: CanvasData): [Y.Doc, Y.Doc] { + const a = new Y.Doc(); + seedYDoc(a, initial); + const b = new Y.Doc(); + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)); + return [a, b]; +} + +// --------------------------------------------------------------------------- +// Round-trip +// --------------------------------------------------------------------------- + +describe("round-trip", () => { + it("preserves all fields including customData and edge extras", () => { + const doc = new Y.Doc(); + seedYDoc(doc, { + nodes: [ + node("a", { x: 5, y: 6, width: 100, category: "note", customData: { status: "todo", n: 3 } }), + ], + edges: [ + // `connectionId` is an informal consumer extension — must survive. + { id: "e1", fromNode: "a", toNode: "b", label: "L", customData: { connectionId: "c-9" } } as never, + ], + }); + const out = yDocToCanvasData(doc); + expect(getNode(out, "a")).toEqual( + node("a", { x: 5, y: 6, width: 100, category: "note", customData: { status: "todo", n: 3 } }), + ); + expect(out.edges?.[0]).toEqual({ + id: "e1", + fromNode: "a", + toNode: "b", + label: "L", + customData: { connectionId: "c-9" }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Convergence — the plug-and-play conflict-free proof +// --------------------------------------------------------------------------- + +describe("conflict-free convergence", () => { + it("concurrent moves of DIFFERENT nodes both survive", () => { + const [a, b] = makePair({ nodes: [node("a"), node("b")], edges: [] }); + updateNode(a, "a", { x: 100, y: 100 }); // user A drags node a + updateNode(b, "b", { x: 200, y: 200 }); // user B drags node b + sync(a, b); + const da = yDocToCanvasData(a); + const db = yDocToCanvasData(b); + expect(da).toEqual(db); // converged + expect(getNode(da, "a")).toMatchObject({ x: 100, y: 100 }); + expect(getNode(da, "b")).toMatchObject({ x: 200, y: 200 }); // neither clobbered + }); + + it("concurrent edits to DIFFERENT fields of the SAME node both survive", () => { + const [a, b] = makePair({ nodes: [node("a")], edges: [] }); + updateNode(a, "a", { x: 100 }); // A moves it + updateNode(b, "a", { text: "renamed" }); // B renames it — field-level merge + sync(a, b); + const da = yDocToCanvasData(a); + expect(yDocToCanvasData(b)).toEqual(da); + expect(getNode(da, "a")).toMatchObject({ x: 100, text: "renamed" }); + }); + + it("concurrent ADD of different nodes both survive", () => { + const [a, b] = makePair({ nodes: [], edges: [] }); + addNode(a, node("c")); + addNode(b, node("d")); + sync(a, b); + const da = yDocToCanvasData(a); + expect(yDocToCanvasData(b)).toEqual(da); + expect(da.nodes?.map((n) => n.id).sort()).toEqual(["c", "d"]); + }); + + it("concurrent add + update converge", () => { + const [a, b] = makePair({ nodes: [node("a")], edges: [] }); + addNode(a, node("c", { x: 9 })); + updateNode(b, "a", { text: "hi" }); + sync(a, b); + const da = yDocToCanvasData(a); + expect(yDocToCanvasData(b)).toEqual(da); + expect(getNode(da, "c")).toMatchObject({ x: 9 }); + expect(getNode(da, "a")).toMatchObject({ text: "hi" }); + }); + + it("delete-vs-update on the same node CONVERGES (deterministic winner)", () => { + const [a, b] = makePair({ nodes: [node("a"), node("b")], edges: [] }); + removeNode(a, "a"); // A deletes + updateNode(b, "a", { text: "still here?" }); // B edits concurrently + sync(a, b); + // We don't assert who wins — only that both replicas agree. + expect(yDocToCanvasData(a)).toEqual(yDocToCanvasData(b)); + }); + + it("concurrent edge adds both survive and converge", () => { + const [a, b] = makePair({ nodes: [node("a"), node("b"), node("c")], edges: [] }); + addEdge(a, { id: "e1", fromNode: "a", toNode: "b" }); + addEdge(b, { id: "e2", fromNode: "b", toNode: "c" }); + sync(a, b); + const da = yDocToCanvasData(a); + expect(yDocToCanvasData(b)).toEqual(da); + expect(da.edges?.map((e) => e.id).sort()).toEqual(["e1", "e2"]); + }); + + it("order of update application does not affect the converged result", () => { + const [a, b] = makePair({ nodes: [node("a")], edges: [] }); + updateNode(a, "a", { x: 1 }); + updateNode(b, "a", { y: 2 }); + // apply in the opposite order to prove commutativity + const ua = Y.encodeStateAsUpdate(a); + const ub = Y.encodeStateAsUpdate(b); + Y.applyUpdate(b, ua); + Y.applyUpdate(a, ub); + expect(yDocToCanvasData(a)).toEqual(yDocToCanvasData(b)); + expect(getNode(yDocToCanvasData(a), "a")).toMatchObject({ x: 1, y: 2 }); + }); +}); diff --git a/packages/collab/src/doc.ts b/packages/collab/src/doc.ts new file mode 100644 index 0000000..af64594 --- /dev/null +++ b/packages/collab/src/doc.ts @@ -0,0 +1,200 @@ +/** + * Framework-agnostic Y.Doc <-> CanvasData binding. + * + * The authored canvas is represented as two top-level Y.Maps: + * - `nodes`: nodeId -> Y.Map of that node's fields + * - `edges`: edgeId -> Y.Map of that edge's fields + * + * Storing each node/edge as its own Y.Map is what makes editing + * conflict-free at field granularity: two users changing DIFFERENT + * fields of the same node (or different nodes) both survive; only a + * true same-field collision falls back to last-writer-wins. Every field + * of the source object is round-tripped verbatim, so consumer-specific + * extras (`customData`, an edge's informal `connectionId`, etc.) survive + * untouched — the binding has no opinion on the domain. + * + * `text` / `label` are stored as plain string values (whole-label LWW); + * character-level merge (Y.Text) is a deliberate later enhancement. + * + * Pure Yjs, no React — unit-testable headlessly. + */ +import * as Y from "yjs"; +import type { CanvasData, CanvasEdge, CanvasNode } from "system-canvas"; + +export const NODES_KEY = "nodes"; +export const EDGES_KEY = "edges"; + +/** + * Origin tag stamped on this-client mutations. A `Y.UndoManager` + * configured with `trackedOrigins: new Set([LOCAL_ORIGIN])` will then + * undo only local edits, never a remote collaborator's — per-user undo. + */ +export const LOCAL_ORIGIN = Symbol.for("system-canvas-collab/local"); + +export function nodesMap(doc: Y.Doc): Y.Map> { + return doc.getMap(NODES_KEY); +} + +export function edgesMap(doc: Y.Doc): Y.Map> { + return doc.getMap(EDGES_KEY); +} + +function recordToYMap(record: Record): Y.Map { + const m = new Y.Map(); + for (const [k, v] of Object.entries(record)) { + if (v !== undefined) m.set(k, v); + } + return m; +} + +function yMapToRecord(m: Y.Map): T { + const out: Record = {}; + m.forEach((v, k) => { + out[k] = v; + }); + return out as T; +} + +/** Apply a partial patch to a node/edge's field map. `undefined` clears a field; `id` is never reassigned. */ +function applyPatch(m: Y.Map, patch: Record): void { + for (const [k, v] of Object.entries(patch)) { + if (k === "id") continue; + if (v === undefined) m.delete(k); + else m.set(k, v); + } +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +/** + * Snapshot the Y.Doc as a plain `CanvasData`. Nodes/edges are sorted by + * id so the output is deterministic across clients (Y.Map iteration + * order is not guaranteed to match) — matters for stable rendering and + * for equality assertions. + */ +export function yDocToCanvasData(doc: Y.Doc): CanvasData { + const nodes: CanvasNode[] = []; + nodesMap(doc).forEach((m) => nodes.push(yMapToRecord(m))); + const edges: CanvasEdge[] = []; + edgesMap(doc).forEach((m) => edges.push(yMapToRecord(m))); + nodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + edges.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return { nodes, edges }; +} + +// --------------------------------------------------------------------------- +// Seed / migrate +// --------------------------------------------------------------------------- + +/** + * Populate a (typically fresh) Y.Doc from a plain `CanvasData`. Used for + * the one-time migration of an existing JSON blob into a Y.Doc, and for + * bootstrapping a doc in tests. Idempotent per id (re-seeding overwrites). + */ +export function seedYDoc( + doc: Y.Doc, + data: CanvasData, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + const nodes = nodesMap(doc); + const edges = edgesMap(doc); + for (const n of data.nodes ?? []) { + nodes.set(n.id, recordToYMap(n as unknown as Record)); + } + for (const e of data.edges ?? []) { + edges.set(e.id, recordToYMap(e as unknown as Record)); + } + }, origin); +} + +// --------------------------------------------------------------------------- +// Mutations — each wrapped in one transaction with an origin tag. +// --------------------------------------------------------------------------- + +export function addNode( + doc: Y.Doc, + node: CanvasNode, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + nodesMap(doc).set(node.id, recordToYMap(node as unknown as Record)); + }, origin); +} + +export function updateNode( + doc: Y.Doc, + id: string, + patch: Partial, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + const m = nodesMap(doc).get(id); + if (m) applyPatch(m, patch as Record); + }, origin); +} + +export function updateNodes( + doc: Y.Doc, + updates: { id: string; patch: Partial }[], + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + const nodes = nodesMap(doc); + for (const u of updates) { + const m = nodes.get(u.id); + if (m) applyPatch(m, u.patch as Record); + } + }, origin); +} + +export function removeNode( + doc: Y.Doc, + id: string, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => nodesMap(doc).delete(id), origin); +} + +export function removeNodes( + doc: Y.Doc, + ids: string[], + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + const nodes = nodesMap(doc); + for (const id of ids) nodes.delete(id); + }, origin); +} + +export function addEdge( + doc: Y.Doc, + edge: CanvasEdge, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + edgesMap(doc).set(edge.id, recordToYMap(edge as unknown as Record)); + }, origin); +} + +export function updateEdge( + doc: Y.Doc, + id: string, + patch: Partial, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => { + const m = edgesMap(doc).get(id); + if (m) applyPatch(m, patch as Record); + }, origin); +} + +export function removeEdge( + doc: Y.Doc, + id: string, + origin: unknown = LOCAL_ORIGIN, +): void { + doc.transact(() => edgesMap(doc).delete(id), origin); +} diff --git a/packages/collab/src/index.ts b/packages/collab/src/index.ts new file mode 100644 index 0000000..cf46fc4 --- /dev/null +++ b/packages/collab/src/index.ts @@ -0,0 +1,39 @@ +/** + * system-canvas-collab — Yjs-backed multi-user collaboration for + * system-canvas. + * + * A consumer gets conflict-free multiplayer by: + * 1. holding a `Y.Doc` per canvas (one per sub-canvas ref), + * 2. binding it with `useYjsCanvas(doc)` and spreading the result onto + * ``, + * 3. syncing the doc over ANY transport (this package is transport- + * agnostic — plug in y-websocket, a hosted provider, or a custom + * HTTP+pubsub bridge), + * 4. optionally wiring presence with `useCollaborators(awareness)`. + * + * The Y.Doc is the source of truth + merge; the renderer stays a pure + * controlled component (no library-core changes required). + */ +export { + NODES_KEY, + EDGES_KEY, + LOCAL_ORIGIN, + nodesMap, + edgesMap, + seedYDoc, + yDocToCanvasData, + addNode, + updateNode, + updateNodes, + removeNode, + removeNodes, + addEdge, + updateEdge, + removeEdge, +} from "./doc.js"; + +export { useYjsCanvas } from "./useYjsCanvas.js"; +export type { UseYjsCanvasResult } from "./useYjsCanvas.js"; + +export { useCollaborators } from "./useCollaborators.js"; +export type { AwarenessLike, CollabUser } from "./useCollaborators.js"; diff --git a/packages/collab/src/useCollaborators.ts b/packages/collab/src/useCollaborators.ts new file mode 100644 index 0000000..9a4b1fb --- /dev/null +++ b/packages/collab/src/useCollaborators.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from "react"; +import type { CollaboratorInfo } from "system-canvas"; + +/** + * Structural subset of a Yjs `Awareness` (from `y-protocols/awareness`) + * that this hook uses. Declared locally so the collab package doesn't + * force a `y-protocols` dependency on consumers — the real Awareness + * satisfies this shape. Presence is ephemeral: never persisted, never in + * the Y.Doc. + */ +export interface AwarenessLike { + clientID: number; + getStates(): Map>; + on(event: "change", cb: () => void): void; + off(event: "change", cb: () => void): void; + setLocalStateField(field: string, value: unknown): void; +} + +/** The per-client presence payload this package reads/writes on awareness. */ +export interface CollabUser { + id: string; + name: string; + color: string; + image?: string | null; +} + +const USER_FIELD = "user"; +const CURSOR_FIELD = "cursor"; +const SELECTION_FIELD = "selectedNodeId"; + +function statesToCollaborators( + states: Map>, + localClientId: number, +): CollaboratorInfo[] { + const out: CollaboratorInfo[] = []; + states.forEach((state, clientId) => { + if (clientId === localClientId) return; // exclude self + const user = state[USER_FIELD] as CollabUser | undefined; + if (!user || typeof user.id !== "string") return; // no identity → skip + const cursor = state[CURSOR_FIELD] as { x: number; y: number } | null | undefined; + const selectedNodeId = state[SELECTION_FIELD] as string | null | undefined; + out.push({ + id: user.id, + name: user.name, + color: user.color, + image: user.image ?? null, + cursor: cursor ?? null, + selectedNodeId: selectedNodeId ?? null, + }); + }); + return out; +} + +/** + * Map a Yjs Awareness to the renderer's `CollaboratorInfo[]` — feed the + * result straight into `` (or + * the `collaborators` prop of ``). Returns setters for this + * client's identity, cursor, and selection. + */ +export function useCollaborators(awareness: AwarenessLike): { + collaborators: CollaboratorInfo[]; + setLocalUser: (user: CollabUser) => void; + setLocalCursor: (cursor: { x: number; y: number } | null) => void; + setLocalSelection: (nodeId: string | null) => void; +} { + const [collaborators, setCollaborators] = useState(() => + statesToCollaborators(awareness.getStates(), awareness.clientID), + ); + + useEffect(() => { + const onChange = () => + setCollaborators( + statesToCollaborators(awareness.getStates(), awareness.clientID), + ); + onChange(); + awareness.on("change", onChange); + return () => { + awareness.off("change", onChange); + }; + }, [awareness]); + + const setLocalUser = useCallback( + (user: CollabUser) => awareness.setLocalStateField(USER_FIELD, user), + [awareness], + ); + const setLocalCursor = useCallback( + (cursor: { x: number; y: number } | null) => + awareness.setLocalStateField(CURSOR_FIELD, cursor), + [awareness], + ); + const setLocalSelection = useCallback( + (nodeId: string | null) => + awareness.setLocalStateField(SELECTION_FIELD, nodeId), + [awareness], + ); + + return { collaborators, setLocalUser, setLocalCursor, setLocalSelection }; +} diff --git a/packages/collab/src/useYjsCanvas.ts b/packages/collab/src/useYjsCanvas.ts new file mode 100644 index 0000000..302dc58 --- /dev/null +++ b/packages/collab/src/useYjsCanvas.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import * as Y from "yjs"; +import type { + CanvasData, + CanvasEdge, + CanvasNode, + EdgeUpdate, + NodeUpdate, +} from "system-canvas"; +import { + addEdge, + addNode, + edgesMap, + LOCAL_ORIGIN, + nodesMap, + removeEdge, + removeNode, + removeNodes, + updateEdge, + updateNode, + updateNodes, + yDocToCanvasData, +} from "./doc.js"; + +/** + * The controlled `canvas` + edit callbacks a `` needs, + * backed by a Y.Doc. The returned object is designed to be spread onto + * the renderer: + * + * const { canvas, ...handlers } = useYjsCanvas(doc) + * + * + * Every edit callback mutates the Y.Doc (conflict-free); `canvas` + * re-derives on any doc change (local OR remote), so remote collaborators' + * edits render live without touching the consumer's own state, viewport, + * or in-flight drag. One hook == one canvas (== one Y.Doc); a consumer + * with sub-canvases holds one doc per ref. + */ +export interface UseYjsCanvasResult { + canvas: CanvasData; + onNodeAdd: (node: CanvasNode, canvasRef?: string) => void; + onNodeUpdate: (id: string, patch: NodeUpdate, canvasRef?: string) => void; + onNodesUpdate: ( + updates: { id: string; patch: NodeUpdate }[], + canvasRef?: string, + ) => void; + onNodeDelete: (id: string, canvasRef?: string) => void; + onNodesDelete: (ids: string[], canvasRef?: string) => void; + onEdgeAdd: (edge: CanvasEdge, canvasRef?: string) => void; + onEdgeUpdate: (id: string, patch: EdgeUpdate, canvasRef?: string) => void; + onEdgeDelete: (id: string, canvasRef?: string) => void; + /** Undo/redo scoped to THIS client's edits (never a collaborator's). */ + undo: () => void; + redo: () => void; + undoManager: Y.UndoManager; +} + +export function useYjsCanvas(doc: Y.Doc): UseYjsCanvasResult { + const [canvas, setCanvas] = useState(() => yDocToCanvasData(doc)); + + // Re-derive the render document on every doc change — local edits and + // remote merges alike. Yjs `update` fires after each transaction. + useEffect(() => { + const onUpdate = () => setCanvas(yDocToCanvasData(doc)); + onUpdate(); // resync immediately if `doc` changed identity + doc.on("update", onUpdate); + return () => { + doc.off("update", onUpdate); + }; + }, [doc]); + + // Per-user undo: only transactions stamped with LOCAL_ORIGIN are tracked, + // so undo never reverts a remote collaborator's edit. + const undoManager = useMemo( + () => + new Y.UndoManager([nodesMap(doc), edgesMap(doc)], { + trackedOrigins: new Set([LOCAL_ORIGIN]), + }), + [doc], + ); + useEffect(() => () => undoManager.destroy(), [undoManager]); + + const onNodeAdd = useCallback( + (node: CanvasNode) => addNode(doc, node), + [doc], + ); + const onNodeUpdate = useCallback( + (id: string, patch: NodeUpdate) => + updateNode(doc, id, patch as Partial), + [doc], + ); + const onNodesUpdate = useCallback( + (updates: { id: string; patch: NodeUpdate }[]) => + updateNodes( + doc, + updates.map((u) => ({ id: u.id, patch: u.patch as Partial })), + ), + [doc], + ); + const onNodeDelete = useCallback( + (id: string) => removeNode(doc, id), + [doc], + ); + const onNodesDelete = useCallback( + (ids: string[]) => removeNodes(doc, ids), + [doc], + ); + const onEdgeAdd = useCallback( + (edge: CanvasEdge) => addEdge(doc, edge), + [doc], + ); + const onEdgeUpdate = useCallback( + (id: string, patch: EdgeUpdate) => + updateEdge(doc, id, patch as Partial), + [doc], + ); + const onEdgeDelete = useCallback( + (id: string) => removeEdge(doc, id), + [doc], + ); + + const undo = useCallback(() => undoManager.undo(), [undoManager]); + const redo = useCallback(() => undoManager.redo(), [undoManager]); + + return { + canvas, + onNodeAdd, + onNodeUpdate, + onNodesUpdate, + onNodeDelete, + onNodesDelete, + onEdgeAdd, + onEdgeUpdate, + onEdgeDelete, + undo, + redo, + undoManager, + }; +} diff --git a/packages/collab/tsconfig.json b/packages/collab/tsconfig.json new file mode 100644 index 0000000..3da91b3 --- /dev/null +++ b/packages/collab/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2020", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/collab/vitest.config.ts b/packages/collab/vitest.config.ts new file mode 100644 index 0000000..faf2b5a --- /dev/null +++ b/packages/collab/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The core Y.Doc <-> CanvasData binding + convergence tests are pure + // (Yjs only, no DOM). Node environment keeps them fast and headless. + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); From 6d984a327a2b63eb642b98253cbddb5a8ad053c1 Mon Sep 17 00:00:00 2001 From: kelmith Date: Mon, 13 Jul 2026 22:14:59 +0530 Subject: [PATCH 2/3] feat: demo for new collab work --- demo/package.json | 3 + demo/src/CollabDemo.tsx | 120 ++++++++++++++++++++++++++++++++++ demo/src/broadcastProvider.ts | 99 ++++++++++++++++++++++++++++ demo/src/main.tsx | 8 ++- demo/tsconfig.json | 3 +- demo/vite.config.ts | 3 + package-lock.json | 28 ++++++-- 7 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 demo/src/CollabDemo.tsx create mode 100644 demo/src/broadcastProvider.ts diff --git a/demo/package.json b/demo/package.json index 652c90f..45fc4f5 100644 --- a/demo/package.json +++ b/demo/package.json @@ -10,6 +10,9 @@ "dependencies": { "system-canvas": "*", "system-canvas-react": "*", + "system-canvas-collab": "*", + "yjs": "^13.6.0", + "y-protocols": "^1.0.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/demo/src/CollabDemo.tsx b/demo/src/CollabDemo.tsx new file mode 100644 index 0000000..177ed25 --- /dev/null +++ b/demo/src/CollabDemo.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from "react"; +import * as Y from "yjs"; +import { SystemCanvas } from "system-canvas-react"; +import { darkTheme } from "system-canvas"; +import type { CanvasData, CanvasSelection } from "system-canvas"; +import { + seedYDoc, + useCollaborators, + useYjsCanvas, +} from "system-canvas-collab"; +import { BroadcastChannelProvider } from "./broadcastProvider"; + +const ROOM = "system-canvas-collab-demo"; + +const SEED: CanvasData = { + theme: { base: "dark" }, + nodes: [ + { id: "n1", type: "text", x: -260, y: -80, width: 200, height: 90, text: "Drag me\n\nOpen a 2nd tab — I move there too" }, + { id: "n2", type: "text", x: 40, y: -80, width: 200, height: 90, text: "Rename me while\nthe other tab moves me" }, + { id: "n3", type: "text", x: -110, y: 120, width: 200, height: 90, text: "Add / delete nodes —\nboth tabs converge" }, + ], + edges: [ + { id: "e1", fromNode: "n1", toNode: "n2" }, + { id: "e2", fromNode: "n1", toNode: "n3" }, + ], +}; + +/** + * Deterministic seed update: seed a throwaway doc with a FIXED clientID + * and encode it once. Every tab applies these exact same bytes, so all + * tabs share one identical base state (idempotent) — instead of each tab + * seeding independently and creating divergent Y.Map instances for the + * same ids (which makes cross-tab edits silently not merge). + */ +const SEED_UPDATE: Uint8Array = (() => { + const d = new Y.Doc(); + d.clientID = 0; + seedYDoc(d, SEED); + return Y.encodeStateAsUpdate(d); +})(); + +const COLORS = ["#ef4444", "#3b82f6", "#22c55e", "#eab308", "#a855f7", "#ec4899"]; + +function makeUser() { + const n = Math.floor(Math.random() * 10000); + return { id: `u${n}`, name: `User ${n}`, color: COLORS[n % COLORS.length] }; +} + +/** + * A fully self-contained multiplayer canvas: one Y.Doc, synced across + * same-origin tabs via BroadcastChannel, bound to the renderer with + * `useYjsCanvas`. Open this URL in two tabs to see live conflict-free + * merge + selection presence. No server, no backend. + */ +export function CollabDemo() { + // useState lazy-init guarantees ONE stable doc + provider for the + // component's lifetime (useMemo is not a stability guarantee and can + // orphan side-effectful instances on a re-render). + const [doc] = useState(() => new Y.Doc()); + const [provider] = useState(() => new BroadcastChannelProvider(ROOM, doc)); + useEffect(() => () => provider.destroy(), [provider]); + + const { canvas, ...handlers } = useYjsCanvas(doc); + const { collaborators, setLocalUser, setLocalSelection } = useCollaborators( + provider.awareness, + ); + + const user = useMemo(makeUser, []); + useEffect(() => { + setLocalUser(user); + }, [setLocalUser, user]); + + // Apply the shared deterministic seed on mount. Idempotent, so every + // tab converges to the same base whether it opened first or synced in. + useEffect(() => { + Y.applyUpdate(doc, SEED_UPDATE); + }, [doc]); + + const onSelectionChange = (sel: CanvasSelection) => { + setLocalSelection(sel && sel.kind === "node" ? sel.node.id : null); + }; + + return ( +
+
+ You are {user.name} · {collaborators.length} other + {collaborators.length === 1 ? "" : "s"} here · open this URL in a 2nd tab +
+ +
+ ); +} diff --git a/demo/src/broadcastProvider.ts b/demo/src/broadcastProvider.ts new file mode 100644 index 0000000..f8aaa37 --- /dev/null +++ b/demo/src/broadcastProvider.ts @@ -0,0 +1,99 @@ +import * as Y from "yjs"; +import { + Awareness, + applyAwarenessUpdate, + encodeAwarenessUpdate, + removeAwarenessStates, +} from "y-protocols/awareness"; + +/** + * Zero-server Yjs transport for same-origin browser tabs, over + * `BroadcastChannel`. Syncs both the Y.Doc and a y-protocols `Awareness`. + * + * This is the SIMPLEST possible transport adapter — it exists to prove + * multiplayer in the demo with no backend. A real consumer (e.g. hive) + * swaps this class for a WebSocket / HTTP+pubsub provider that speaks the + * same three moves: (1) broadcast local doc updates, (2) apply remote + * ones, (3) exchange full state on join. The rest of the stack — + * `useYjsCanvas`, `useCollaborators`, the renderer — is unchanged. + * + * Echo guard: updates applied FROM the channel are tagged with `origin = + * this`, and the local update handler skips anything with that origin, so + * a received update is never rebroadcast. + */ +type Message = + | { type: "doc"; update: Uint8Array } + | { type: "awareness"; update: Uint8Array } + | { type: "sync-request" }; + +export class BroadcastChannelProvider { + readonly awareness: Awareness; + private readonly channel: BroadcastChannel; + private readonly doc: Y.Doc; + + constructor(room: string, doc: Y.Doc) { + this.doc = doc; + this.awareness = new Awareness(doc); + this.channel = new BroadcastChannel(room); + + doc.on("update", this.handleDocUpdate); + this.awareness.on("update", this.handleAwarenessUpdate); + this.channel.onmessage = this.handleMessage; + window.addEventListener("beforeunload", this.handleUnload); + + // Ask any already-open tab for its current doc + awareness state. + this.post({ type: "sync-request" }); + } + + private post(msg: Message): void { + this.channel.postMessage(msg); + } + + private handleDocUpdate = (update: Uint8Array, origin: unknown): void => { + if (origin === this) return; // came from the channel — don't echo + this.post({ type: "doc", update }); + }; + + private handleAwarenessUpdate = ( + changes: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ): void => { + if (origin === this) return; + const changed = changes.added.concat(changes.updated, changes.removed); + this.post({ + type: "awareness", + update: encodeAwarenessUpdate(this.awareness, changed), + }); + }; + + private handleMessage = (ev: MessageEvent): void => { + const msg = ev.data; + if (msg.type === "doc") { + Y.applyUpdate(this.doc, msg.update, this); + } else if (msg.type === "awareness") { + applyAwarenessUpdate(this.awareness, msg.update, this); + } else if (msg.type === "sync-request") { + // A new tab joined — send it our full doc + awareness snapshot. + this.post({ type: "doc", update: Y.encodeStateAsUpdate(this.doc) }); + this.post({ + type: "awareness", + update: encodeAwarenessUpdate(this.awareness, [ + ...this.awareness.getStates().keys(), + ]), + }); + } + }; + + private handleUnload = (): void => { + removeAwarenessStates(this.awareness, [this.doc.clientID], "unload"); + }; + + destroy(): void { + this.doc.off("update", this.handleDocUpdate); + this.awareness.off("update", this.handleAwarenessUpdate); + window.removeEventListener("beforeunload", this.handleUnload); + this.handleUnload(); + this.channel.close(); + this.awareness.destroy(); + } +} diff --git a/demo/src/main.tsx b/demo/src/main.tsx index 056076c..52103fb 100644 --- a/demo/src/main.tsx +++ b/demo/src/main.tsx @@ -33,6 +33,7 @@ import { swimlaneRoot, swimlaneCanvasMap, swimlaneTheme } from './swimlane.js' import { nestedRoot, nestedCanvasMap } from './nested.js' import { showcaseRoot, showcaseCanvasMap, showcaseTheme } from './showcase.js' import { gatewayRoot, gatewayCanvasMap, gatewayTheme } from './gateway.js' +import { CollabDemo } from './CollabDemo.js' const allThemes: Record = { dark: darkTheme, @@ -687,4 +688,9 @@ function App() { } const root = createRoot(document.getElementById('root')!) -root.render() +// `?mode=collab` mounts the self-contained Yjs multiplayer demo +// (open in two tabs). It's a separate top-level route so it stays +// isolated from the single-user `Mode` machinery above. +const isCollab = + new URLSearchParams(window.location.search).get('mode') === 'collab' +root.render(isCollab ? : ) diff --git a/demo/tsconfig.json b/demo/tsconfig.json index 1faef73..06e0d95 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -6,7 +6,8 @@ "baseUrl": ".", "paths": { "system-canvas": ["../packages/core/src/index.ts"], - "system-canvas-react": ["../packages/react/src/index.ts"] + "system-canvas-react": ["../packages/react/src/index.ts"], + "system-canvas-collab": ["../packages/collab/src/index.ts"] } }, "include": ["src"] diff --git a/demo/vite.config.ts b/demo/vite.config.ts index e459802..af7926e 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ alias: { 'system-canvas': path.resolve(__dirname, '../packages/core/src'), 'system-canvas-react': path.resolve(__dirname, '../packages/react/src'), + 'system-canvas-collab': path.resolve(__dirname, '../packages/collab/src'), }, + // Single React instance across the aliased-from-source packages. + dedupe: ['react', 'react-dom'], }, }) diff --git a/package-lock.json b/package-lock.json index d751c99..6541408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,10 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "system-canvas": "*", - "system-canvas-react": "*" + "system-canvas-collab": "*", + "system-canvas-react": "*", + "y-protocols": "^1.0.6", + "yjs": "^13.6.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -2466,7 +2469,6 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", - "dev": true, "license": "MIT", "funding": { "type": "GitHub Sponsors ❤", @@ -2610,7 +2612,6 @@ "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", - "dev": true, "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" @@ -4247,6 +4248,26 @@ "dev": true, "license": "MIT" }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4258,7 +4279,6 @@ "version": "13.6.31", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", - "dev": true, "license": "MIT", "dependencies": { "lib0": "^0.2.99" From 145ebfa3fdda64caf19bd5d97c6fe7b43a0b073a Mon Sep 17 00:00:00 2001 From: kelmith Date: Tue, 14 Jul 2026 00:31:27 +0530 Subject: [PATCH 3/3] feat: publishable system canvas collab package --- README.md | 35 +++ demo/package.json | 3 +- demo/src/CollabDemo.tsx | 69 +++++- package-lock.json | 462 +++++++++++++++++++++++++++++++++++ packages/collab/package.json | 5 + scripts/sync-versions.mjs | 2 + 6 files changed, 566 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d2c5486..512a762 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Interactive, infinitely zoomable, editable SVG diagrams from JSON Canvas documen | `system-canvas` | Pure TypeScript core. Types, themes, edge routing, viewport math. Zero dependencies. | | `system-canvas-react` | React components. Pan/zoom viewport, node renderers, breadcrumb navigation. | | `system-canvas-standalone` | Self-contained IIFE bundle for `