Use collaboration when several canvases edit the same document. Pass a shipped transport and a unique actor id through collab: dragging a node in one canvas moves it in the other. The engine merges document edits per property; the framework binding mounts the renderer and owns the session lifecycle.
1. Choose a transport and seed both peers
Use BroadcastChannelTransport for tabs on the same browser origin, without a server. Use WebSocketTransport for editors connected through your server. Each peer needs a different actor id, the same room, and matching initial document ids and content.
Install the binding you use, together with the packages the samples import.
JavaScript:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
Angular:
bashnpm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
Qwik:
bashnpm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik
React:
bashnpm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom
Vue:
bashnpm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue
Put this shared file beside your component. The typed NodeSpec and EdgeSpec arrays draw two connected nodes. Each editor gets its own copy of the seed.
tsimport { BroadcastChannelTransport } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
export function seedNodes(): NodeSpec[] {
return [
{ id: 'a', label: 'Ingest', position: { x: 40, y: 80 },
size: { width: 140, height: 66 } },
{ id: 'b', label: 'Publish', position: { x: 240, y: 80 },
size: { width: 140, height: 66 } },
];
}
export function seedEdges(): EdgeSpec[] {
return [{ id: 'e1', source: 'a', target: 'b' }];
}
export function makePeer(room: string, actor: string) {
return {
transport: new BroadcastChannelTransport({ name: room, actor }),
actor,
presence: true,
};
}
Each sample renders two side-by-side canvases with Ingest connected to Publish and an Undo Ana button. For separate tabs, use a shared document-specific room name instead of generating a new room in each tab, and generate a distinct actor id for each editor.
2. Mount the editors and keep actor-local undo
Pass the options through your framework's canvas component. The binding joins at mount and leaves at unmount. In JavaScript, join explicitly after mounting the instance.
Keep the session delivered by the binding's collaboration-ready event. Its SyncAdapter exposes the local Replica. Call replica.undo() for collaboration-aware undo: it reverses this actor's edit, not a later edit made by another actor. The Undo Ana button below uses that path.
render returns the mounted DiagramInstance. createSyncSession attaches collaboration to that instance's live model.
DiagramCanvasComponent uses two-way node and edge bindings and emits collabReady with the session.
For Qwik, give the two GrafloriaFlow components separate GrafloriaCollabOptions for the same room and retain Ana's session through onCollabReady$ for actor-local undo; see Documents and kits for browser-only setup and resumable state.
GrafloriaFlow accepts collab and calls onCollabReady. Keep the session in a ref and use uncontrolled defaults so the instance owns the graph.
GrafloriaFlow accepts :collab and emits collab-ready. Keep the session in a shallow ref.
tsimport { render } from '@grafloria/element';
import { createSyncSession } from '@grafloria/engine';
import { makePeer, seedNodes, seedEdges } from './shared';
export function mountEditors(container: HTMLElement): () => void {
const room = 'editors-' + Math.random().toString(36).slice(2);
const toolbar = document.createElement('div');
const undo = document.createElement('button');
undo.textContent = 'Undo Ana';
toolbar.append(undo);
const panes = document.createElement('div');
panes.style.cssText = 'display:flex;gap:12px;height:400px';
container.append(toolbar, panes);
function mount(actor: string) {
const host = document.createElement('div');
host.style.cssText = 'flex:1;min-width:0;height:400px';
host.setAttribute('aria-label', actor);
panes.append(host);
const instance = render({ nodes: seedNodes(), edges: seedEdges() }, host);
const options = makePeer(room, actor);
const session = createSyncSession(instance.getModel(), options.transport,
{ actor });
session.join();
return { instance, session, transport: options.transport };
}
const ana = mount('ana');
const ben = mount('ben');
undo.onclick = () => { ana.session.replica.undo(); };
return () => {
for (const peer of [ana, ben]) {
peer.session.dispose();
peer.session.replica.dispose();
peer.transport.close();
peer.instance.dispose();
}
toolbar.remove();
panes.remove();
};
}
const container = document.createElement('div');
document.body.append(container);
const unmountEditors = mountEditors(container);
const close = document.createElement('button');
close.textContent = 'Close editors';
close.onclick = () => { unmountEditors(); container.remove(); close.remove(); };
document.body.append(close);
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';
@Component({
selector: 'app-root',
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<button (click)="undoAna()">Undo Ana</button>
<div style="display:flex;gap:12px;height:400px">
<grafloria-diagram-canvas
[(nodes)]="nodesA" [(edges)]="edgesA" [collab]="collabA"
(collabReady)="sessionA = $event"
style="display:block;flex:1;min-width:0;height:400px" />
<grafloria-diagram-canvas
[(nodes)]="nodesB" [(edges)]="edgesB" [collab]="collabB"
style="display:block;flex:1;min-width:0;height:400px" />
</div>
`,
})
export class AppComponent {
private readonly room = 'editors-' + Math.random().toString(36).slice(2);
nodesA: NodeSpec[] = seedNodes();
edgesA: EdgeSpec[] = seedEdges();
nodesB: NodeSpec[] = seedNodes();
edgesB: EdgeSpec[] = seedEdges();
readonly collabA = makePeer(this.room, 'ana');
readonly collabB = makePeer(this.room, 'ben');
sessionA?: SyncAdapter;
undoAna(): void { this.sessionA?.replica.undo(); }
}
tsximport { component$, noSerialize, useSignal, useVisibleTask$,
type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';
export default component$(() => {
const collabA = useSignal<NoSerialize<GrafloriaCollabOptions>>();
const collabB = useSignal<NoSerialize<GrafloriaCollabOptions>>();
const sessionA = useSignal<NoSerialize<SyncAdapter>>();
const nodesA: NodeSpec[] = seedNodes();
const nodesB: NodeSpec[] = seedNodes();
const edgesA: EdgeSpec[] = seedEdges();
const edgesB: EdgeSpec[] = seedEdges();
useVisibleTask$(() => {
const room = 'editors-' + Math.random().toString(36).slice(2);
collabA.value = noSerialize(makePeer(room, 'ana'));
collabB.value = noSerialize(makePeer(room, 'ben'));
});
return <div>
<button onClick$={() => { sessionA.value?.replica.undo(); }}>Undo Ana</button>
<div style={{ display: 'flex', gap: '12px', height: '400px' }}>
{collabA.value && collabB.value && <>
<GrafloriaFlow defaultNodes={nodesA} defaultEdges={edgesA}
collab={collabA.value}
onCollabReady$={(session) => { sessionA.value = noSerialize(session); }}
style={{ flex: '1', minWidth: '0', height: '400px' }} />
<GrafloriaFlow defaultNodes={nodesB} defaultEdges={edgesB}
collab={collabB.value}
style={{ flex: '1', minWidth: '0', height: '400px' }} />
</>}
</div>
</div>;
});
tsximport { useMemo, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { SyncAdapter } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { makePeer, seedNodes, seedEdges } from './shared';
export default function Editors() {
const sessionA = useRef<SyncAdapter | null>(null);
const peers = useMemo(() => {
const room = 'editors-' + Math.random().toString(36).slice(2);
const nodesA: NodeSpec[] = seedNodes();
const nodesB: NodeSpec[] = seedNodes();
const edgesA: EdgeSpec[] = seedEdges();
const edgesB: EdgeSpec[] = seedEdges();
return { a: makePeer(room, 'ana'), b: makePeer(room, 'ben'),
nodesA, nodesB, edgesA, edgesB };
}, []);
return <div>
<button onClick={() => { sessionA.current?.replica.undo(); }}>Undo Ana</button>
<div style={{ display: 'flex', gap: 12, height: 400 }}>
<GrafloriaFlow defaultNodes={peers.nodesA} defaultEdges={peers.edgesA}
collab={peers.a} onCollabReady={(session) => { sessionA.current = session; }}
style={{ flex: 1, minWidth: 0, height: 400 }} />
<GrafloriaFlow defaultNodes={peers.nodesB} defaultEdges={peers.edgesB}
collab={peers.b} style={{ flex: 1, minWidth: 0, height: 400 }} />
</div>
</div>;
}
vue<script setup lang="ts"> import { shallowRef } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { SyncAdapter } from '@grafloria/engine'; import type { NodeSpec, EdgeSpec } from '@grafloria/renderer'; import { makePeer, seedNodes, seedEdges } from './shared'; const room = 'editors-' + Math.random().toString(36).slice(2); const nodesA: NodeSpec[] = seedNodes(); const nodesB: NodeSpec[] = seedNodes(); const edgesA: EdgeSpec[] = seedEdges(); const edgesB: EdgeSpec[] = seedEdges(); const collabA = makePeer(room, 'ana'); const collabB = makePeer(room, 'ben'); const sessionA = shallowRef<SyncAdapter>(); function ready(session: SyncAdapter): void { sessionA.value = session; } function undoAna(): void { sessionA.value?.replica.undo(); } </script> <template> <button @click="undoAna">Undo Ana</button> <div style="display:flex;gap:12px;height:400px"> <GrafloriaFlow :default-nodes="nodesA" :default-edges="edgesA" :collab="collabA" @collab-ready="ready" style="flex:1;min-width:0;height:400px" /> <GrafloriaFlow :default-nodes="nodesB" :default-edges="edgesB" :collab="collabB" style="flex:1;min-width:0;height:400px" /> </div> </template>
Angular renders the same pair through two canvas components.
Qwik mounts the pair after creating the transports in the browser.
React seeds each flow with its own node and edge arrays.
Vue renders the pair with uncontrolled defaults.
Drag Ingest in Ana's left canvas, then Publish in Ben's right canvas. Both canvases show both moves. Click Undo Ana: it reverses Ana's last captured position write on both peers, while Ben's Publish move stays. These samples do not group drag updates into a replica transaction, so one click does not undo the whole drag. Remote changes do not enter Ana's replica undo stack. If another actor has already superseded Ana's write to the same property, undo skips that write instead of restoring stale state.
For your own multi-mutation action, use session.replica.transact() to group its mutations into one local undo step. undo() and redo() return the operations they emit; those inverse edits travel through the same synchronization path. See Commands and history for the separate engine command API.
3. Handle conflicts and reconnects
The merge unit is a property path, not a whole node. A move and a rename of the same node survive together because position and label are separate registers. Two writes to the same register resolve by Lamport clock, then actor id as the tie-breaker—not by network arrival order. Try the Conflict resolution demo to stage a move and rename before exchanging edits.
Read transport.status for the current TransportStatus, and subscribe with onStatus() for changes. It reports connected or disconnected; it is not a document-convergence indicator. A WebSocket session's collaboration-ready callback follows join(), not necessarily the socket opening.
disconnect() deliberately drops a transport without destroying it. Edits still enter the live replica's log while disconnected. connect() reopens the channel; the session listens for the connected transition and exchanges missing operations automatically. Use close() only when you no longer need the transport. Reconnect catch-up needs another peer that still holds the history: it is not durable storage.
For a server-backed editor, replace each makePeer() call with options containing a WebSocket transport. Use the same room URL for both peers, with different actor ids:
tsimport { WebSocketTransport } from '@grafloria/engine';
export function makeSocketPeer(url: string, actor: string) {
return {
transport: new WebSocketTransport({ url }),
actor,
presence: true,
};
}
Pass your relay's URL, such as wss://api.example.com/diagrams/ingest, as url. The relay broadcasts each received frame verbatim to every other socket in that room. Your product owns room membership, authentication and persistence; Grafloria does not ship that server.
An unexpected socket close retries automatically with exponential backoff. A deliberate disconnect() does not retry; call connect() to return. See the Offline and reconnect demo for edits made on both sides of a dropped connection.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
collab.transport | SyncTransport | Required when collaboration is enabled | Carries messages and connection status. |
collab.actor | string | Required when collaboration is enabled | Identifies this peer; keep it unique across peers. |
collab.presence | boolean or BindPresenceOptions | No binding when omitted | true mounts live cursors and remote selection outlines with default settings. |
Broadcast channel name | string | Required | Names the shared room; namespace it by document id. |
WebSocket url | string | Required | Connects to your relay. |
WebSocket reconnect | boolean | Enabled unless false | Retries unexpected closes. |
WebSocket reconnectBaseMs | number | 250 | Initial retry delay, reset after a successful open. |
WebSocket reconnectMaxMs | number | 10000 | Caps the doubling retry delay. |
Pitfalls
- Keep
collabstable for the mounted canvas. To change rooms or actors, remount the editor; the binding attaches the session for the instance's lifetime. - Do not use the same actor id for two peers. Ordering depends on actor identity, and the broadcast transport filters messages from its own actor.
- Start both peers from the same document. Creating the session captures subsequent edits; it does not turn independently seeded content into a shared initial operation history.
- If you switch to controlled React data, wire the change-event return path described in the React quick start.
- Do not confuse presence with saved document edits. Cursor and selection awareness travels separately and does not enter the operation log.
Live demos and related pages
- Two tabs, live — drag between two independent editors over a real broadcast channel; source.
- Collaboration-aware undo — reverse one actor's edit while preserving the other actor's move.
- Save and restore documents — persist the live document rather than a framework projection.
- Commands and history — execute user-facing edits through the engine's command stack.
Was this page helpful?