Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` tag / CDN use. No build step required. |
| `system-canvas-collab` | Yjs-backed multi-user collaboration. Binds a Y.Doc to the renderer's props + awareness → conflict-free multiplayer; you supply the transport. |

## Install

Expand Down Expand Up @@ -172,6 +173,40 @@ Interactions in editable mode:
- **Hover** a node to reveal four connection handles (one per side); **drag** from a handle to another node to create an edge. Groups don't participate in edge creation.
- **Click** to select, **Delete** or **Backspace** to remove the selected node or edge. **Escape** clears selection.

## Multiplayer (`system-canvas-collab`)

Conflict-free multi-user editing, backed by [Yjs](https://yjs.dev). The
library owns the merge; you supply a transport (y-websocket, a hosted
provider, or your own HTTP+pub/sub bridge).

```bash
npm install system-canvas-collab yjs
```

```tsx
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { SystemCanvas } from "system-canvas-react";
import { useYjsCanvas, useCollaborators } from "system-canvas-collab";

const doc = new Y.Doc();
const provider = new WebsocketProvider("wss://…", "room-id", doc);

function Board() {
const { canvas, ...handlers } = useYjsCanvas(doc); // Y.Doc ⇄ CanvasData
const { collaborators, setLocalCursor } = useCollaborators(provider.awareness);
return (
<SystemCanvas canvas={canvas} editable collaborators={collaborators} {...handlers} />
);
}
```

`useYjsCanvas` returns the controlled `canvas` plus every edit callback
`<SystemCanvas>` needs, routing edits into the Y.Doc (field-level merge;
per-user undo via `Y.UndoManager`). `useCollaborators` maps a Yjs
`Awareness` to the `collaborators` prop for live cursors/selection. The
renderer core is unchanged — collaboration is entirely opt-in.

## Props

| Prop | Type | Description |
Expand Down
6 changes: 5 additions & 1 deletion demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
"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"
"react-dom": "^19.0.0",
"y-websocket": "^2.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
Expand Down
171 changes: 171 additions & 0 deletions demo/src/CollabDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import type { Awareness } from "y-protocols/awareness";
import { SystemCanvas } from "system-canvas-react";
import { darkTheme, screenToCanvas } from "system-canvas";
import type { CanvasData, CanvasSelection, ViewportState } from "system-canvas";
import {
seedYDoc,
useCollaborators,
useYjsCanvas,
} from "system-canvas-collab";
import { BroadcastChannelProvider } from "./broadcastProvider";

const ROOM = "system-canvas-collab-demo";

// Transport is chosen by URL:
// ?mode=collab → BroadcastChannel (same-browser tabs, zero setup)
// ?mode=collab&ws=1 → y-websocket (ANY browser/window/machine; needs the
// local relay: `npx y-websocket` on port 1234)
const USE_WS =
new URLSearchParams(window.location.search).get("ws") === "1";
const WS_URL = "ws://localhost:1234";

/** The subset both providers expose that this demo needs. */
interface Transport {
awareness: Awareness;
destroy(): void;
}

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<Transport>(() =>
USE_WS
? new WebsocketProvider(WS_URL, ROOM, doc)
: new BroadcastChannelProvider(ROOM, doc),
);
useEffect(() => () => provider.destroy(), [provider]);

const { canvas, ...handlers } = useYjsCanvas(doc);
const { collaborators, setLocalUser, setLocalSelection, setLocalCursor } =
useCollaborators(provider.awareness);

// Live cursor sharing, computed demo-side (the library has no local-
// cursor-out prop yet — that's the clean follow-up). We track the
// viewport via onViewportChange and convert screen→canvas coords.
const wrapperRef = useRef<HTMLDivElement | null>(null);
const viewportRef = useRef<ViewportState>({ x: 0, y: 0, zoom: 1 });
const lastCursorSentRef = useRef(0);

const handlePointerMove = (e: React.PointerEvent) => {
const now = e.timeStamp;
if (now - lastCursorSentRef.current < 40) return; // ~25/s throttle
lastCursorSentRef.current = now;
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) return;
const pos = screenToCanvas(
e.clientX - rect.left,
e.clientY - rect.top,
viewportRef.current,
);
setLocalCursor({ x: pos.x, y: pos.y });
};

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 (
<div
ref={wrapperRef}
onPointerMove={handlePointerMove}
onPointerLeave={() => setLocalCursor(null)}
style={{ position: "fixed", inset: 0, background: "#0b0e14" }}
>
<div
style={{
position: "absolute",
top: 12,
left: 12,
zIndex: 10,
padding: "6px 10px",
borderRadius: 6,
background: "rgba(0,0,0,0.6)",
color: user.color,
font: "12px system-ui, sans-serif",
pointerEvents: "none",
}}
>
You are <b>{user.name}</b> · {collaborators.length} other
{collaborators.length === 1 ? "" : "s"} here ·{" "}
{USE_WS
? "WebSocket — open in ANY browser/window"
: "BroadcastChannel — open a 2nd tab in THIS browser"}
</div>
<SystemCanvas
canvas={canvas}
theme={darkTheme}
editable
zoomNavigation
collaborators={collaborators}
onViewportChange={(vp) => {
viewportRef.current = vp;
}}
onSelectionChange={onSelectionChange}
onNodeAdd={handlers.onNodeAdd}
onNodeUpdate={handlers.onNodeUpdate}
onNodesUpdate={handlers.onNodesUpdate}
onNodeDelete={handlers.onNodeDelete}
onNodesDelete={handlers.onNodesDelete}
onEdgeAdd={handlers.onEdgeAdd}
onEdgeUpdate={handlers.onEdgeUpdate}
onEdgeDelete={handlers.onEdgeDelete}
/>
</div>
);
}
99 changes: 99 additions & 0 deletions demo/src/broadcastProvider.ts
Original file line number Diff line number Diff line change
@@ -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<Message>): 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();
}
}
8 changes: 7 additions & 1 deletion demo/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CanvasTheme> = {
dark: darkTheme,
Expand Down Expand Up @@ -687,4 +688,9 @@ function App() {
}

const root = createRoot(document.getElementById('root')!)
root.render(<App />)
// `?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 ? <CollabDemo /> : <App />)
3 changes: 2 additions & 1 deletion demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 3 additions & 0 deletions demo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
})
Loading
Loading