Connect two mounted diagrams to the same transport and give each peer a unique actor id. Local edits then cross the channel as CRDT operations; the other diagram repaints with the converged document. Presence metadata also identifies the peers whose cursors are visible.
Choose a transport
Use the shipped BroadcastChannelTransport for two tabs in one browser. It needs a shared channel name and a distinct actor for each peer. For an in-page test or a controlled offline test, use MemoryHub and connect one transport per actor. For a server-backed product, use the shipped WebSocket transport and keep room, authentication, and storage in your application.
The framework bindings join and leave the session when the component mounts and unmounts. In plain JavaScript, create the session after render returns with createSyncSession and call join().
Mount two peers
The following examples render the same two-node document twice. Change ROOM only once per pair of peers; change actor for every peer. The containers have height so the diagrams have a drawing area.
html<div id="left" style="height:400px"></div>
<div id="right" style="height:400px"></div>
<script type="module">
import { render } from '@grafloria/element';
import { BroadcastChannelTransport, createSyncSession } from '@grafloria/engine';
const nodes = [
{ id: 'a', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
{ id: 'b', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
const ROOM = 'diagram-room';
function peer(targetId, actor, name) {
const target = document.getElementById(targetId);
const instance = render({ nodes: structuredClone(nodes), edges: structuredClone(edges) }, target);
const transport = new BroadcastChannelTransport({ name: ROOM, actor });
const session = createSyncSession(instance.getModel(), transport, {
actor,
});
session.join();
return { instance, session, name };
}
const left = peer('left', 'ana', 'Ana');
const right = peer('right', 'ben', 'Ben');
// The two mounted peers now share document edits. Keep these handles while the page is open.
void left;
void right;
</script>
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { BroadcastChannelTransport } from '@grafloria/engine';
@Component({
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<div style="display:flex; height:400px">
<grafloria-diagram-canvas [nodes]="nodesA" [edges]="edges" [collab]="collabA" style="flex:1" />
<grafloria-diagram-canvas [nodes]="nodesB" [edges]="edges" [collab]="collabB" style="flex:1" />
</div>
`,
})
export class CollaborativeDiagramComponent {
private readonly room = `angular-${Math.random().toString(36).slice(2, 8)}`;
readonly nodesA = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
readonly nodesB = structuredClone(this.nodesA);
readonly edges = [{ id: 'e1', source: 'a', target: 'b' }];
readonly collabA = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } };
readonly collabB = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } };
}
tsximport { component$, noSerialize, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
export default component$(() => {
const room = `qwik-${Math.random().toString(36).slice(2, 8)}`;
const collab = useSignal({
a: noSerialize({ transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } }),
b: noSerialize({ transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } }),
});
return <div style={{ display: 'flex', height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={collab.value.a} style={{ flex: '1' }} />
<GrafloriaFlow defaultNodes={structuredClone(nodes)} defaultEdges={structuredClone(edges)} collab={collab.value.b} style={{ flex: '1' }} />
</div>;
});
tsximport { useMemo } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } },
{ id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
export default function CollaborativeDiagram() {
const collab = useMemo(() => {
const room = `react-${Math.random().toString(36).slice(2, 8)}`;
return {
a: { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } },
b: { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } },
};
}, []);
return <div style={{ display: 'flex', height: 400 }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={collab.a} style={{ flex: 1 }} />
<GrafloriaFlow defaultNodes={structuredClone(nodes)} defaultEdges={structuredClone(edges)} collab={collab.b} style={{ flex: 1 }} />
</div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import { BroadcastChannelTransport } from '@grafloria/engine'; const room = `vue-${Math.random().toString(36).slice(2, 8)}`; const nodes = [ { id: 'a', position: { x: 60, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Ingest' } }, { id: 'b', position: { x: 320, y: 60 }, size: { width: 150, height: 66 }, data: { label: 'Publish' } }, ]; const edges = [{ id: 'e1', source: 'a', target: 'b' }]; const collabA = { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } }; const collabB = { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } }; </script> <template> <div style="display:flex; height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :collab="collabA" /> <GrafloriaFlow :default-nodes="structuredClone(nodes)" :default-edges="structuredClone(edges)" :collab="collabB" /> </div> </template>
Each pane renders the same two nodes. Drag a node in either pane: the corresponding node moves in the other pane. The presence value gives the remote cursor its peer name; presence is not part of the document operation log.
Handle interruptions and conflicts
Use disconnect() and connect() on a MemoryTransport when you need to simulate a dropped link. Reconnecting triggers the session's anti-entropy exchange; edits made while disconnected converge after both peers reconnect. The transport must report status for this catch-up path.
Concurrent edits are merged per property. A move on one peer and a rename on another therefore survive together after synchronization, rather than one whole-node edit replacing the other. Viewports remain local: document edits synchronize, but panning and zooming do not.
Options that matter
| option | type | default | what it does |
|---|---|---|---|
transport | SyncTransport | — | Carries messages between peers. |
actor | ActorId | — | Identifies the peer; it must be unique across peers. |
presence | object | — | Supplies presence information such as a collaborator name. |
batch | false or batch options | — | Controls whether local operations are sent individually or batched. |
Pitfalls
- Give every peer a different actor id. The total order depends on that identity.
- Use the same channel or hub for peers that must collaborate.
- Do not expect a disconnected transport to send:
send()is a no-op while disconnected. - Collaboration provides convergence, presence, comments, and an operation log; rooms, authentication, and persistence remain application responsibilities.
See the live two-tabs demo, the offline and reconnect demo, and the conflict-resolution demo.
Was this page helpful?