Connect two mounted diagrams to the same room and watch edits, presence, comments, reconnects, and local undo converge.
When to use this
Use the collab prop on GrafloriaFlow when each mounted diagram has a transport and an actor id. The canvas joins when it mounts and leaves when it unmounts. Local edits become CRDT operations; incoming operations merge per property, so a move and a rename on the same node can both survive.
BroadcastChannelTransport is the shipped no-server choice for two tabs in one browser. Use WebSocketTransport when your application supplies a server-backed channel. Grafloria supplies convergence, presence, comments, and the operation log; your application supplies rooms, authentication, and storage.
Connect two diagrams
Use the same channel name for both transports and a different stable actor for each peer. Give each canvas a real height so both mounted diagrams render.
jsimport { render } from '@grafloria/element';
import { BroadcastChannelTransport, createSyncSession } from '@grafloria/engine';
document.body.innerHTML = '<div id="left" style="height:400px"></div><div id="right" style="height:400px"></div>';
const nodes = [
{ id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
{ id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
const room = 'diagram-' + Math.random().toString(36).slice(2);
const left = render({ nodes, edges }, '#left');
const right = render({ nodes: nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } })), edges: edges.map((edge) => ({ ...edge })) }, '#right');
createSyncSession(left.getModel(), new BroadcastChannelTransport({ name: room, actor: 'ana' }), { actor: 'ana' }).join();
createSyncSession(right.getModel(), new BroadcastChannelTransport({ name: room, actor: 'ben' }), { actor: 'ben' }).join();
left.getModel().getNode('ingest')?.setPosition(100, 180);
right.fitView();
tsximport { useMemo } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
{ id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
export function CollaborativeDiagram() {
const collab = useMemo(() => {
const room = 'diagram-' + Math.random().toString(36).slice(2);
return {
ana: { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } },
ben: { 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.ana} style={{ flex: 1 }} />
<GrafloriaFlow defaultNodes={nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }))} defaultEdges={edges.map((edge) => ({ ...edge }))} collab={collab.ben} style={{ flex: 1 }} />
</div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import { BroadcastChannelTransport } from '@grafloria/engine'; const nodes = [ { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } }, { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } }, ]; const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }]; const room = 'diagram-' + Math.random().toString(36).slice(2); 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' } }; const nodesB = nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } })); </script> <template> <div style="display:flex; height:400px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :collab="collabA" style="flex:1" /> <GrafloriaFlow :default-nodes="nodesB" :default-edges="edges" :collab="collabB" style="flex:1" /> </div> </template>
tsximport { component$, noSerialize, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import { BroadcastChannelTransport } from '@grafloria/engine';
const nodes = [
{ id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
{ id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
export default component$(() => {
const room = useSignal('diagram-' + Math.random().toString(36).slice(2));
const ana = noSerialize<GrafloriaCollabOptions>({ transport: new BroadcastChannelTransport({ name: room.value, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } });
const ben = noSerialize<GrafloriaCollabOptions>({ transport: new BroadcastChannelTransport({ name: room.value, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } });
const nodesB = nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }));
return <div style={{ display: 'flex', height: '400px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={ana} style={{ flex: '1' }} />
<GrafloriaFlow defaultNodes={nodesB} defaultEdges={edges} collab={ben} style={{ flex: '1' }} />
</div>;
});
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="display:block;flex:1" /><grafloria-diagram-canvas [nodes]="nodesB" [edges]="edges" [collab]="collabB" style="display:block;flex:1" /></div>` })
export class AppComponent {
nodesA = [
{ id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
{ id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
nodesB = this.nodesA.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }));
edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
private room = 'diagram-' + Math.random().toString(36).slice(2);
collabA = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } };
collabB = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } };
}
Each pair renders the same connected diagram. Drag a node in either pane: the other pane follows and displays the other actor's presence. Camera changes remain local to each pane; document edits synchronize.
See the live two-tabs demo and its source.
Add comments
Set comments to true to create the canvas's comment store. Read that store from the mounted instance and give it to the ready-made panel. This sample mounts the panel with an empty store, so it displays “Comments — No comments yet.”
For React, Vue, and Qwik, use the binding's GrafloriaCommentPanel; for Angular, use GrafloriaCommentPanelComponent as <grafloria-comment-panel>.
tsximport { useState } from 'react';
import { GrafloriaFlow, GrafloriaCommentPanel } from '@grafloria/react';
import type { CommentStore } from '@grafloria/engine';
export function Comments() {
const [store, setStore] = useState<CommentStore | null>(null);
return <div style={{ display: 'flex', height: 400 }}>
<GrafloriaFlow defaultNodes={[{ id: 'review', label: 'Review', position: { x: 80, y: 100 } }]} comments onInit={(instance) => setStore(instance.getCommentStore())} style={{ flex: 1 }} />
{store && <GrafloriaCommentPanel store={store} />}
</div>;
}
The Angular shape is:
html<grafloria-diagram-canvas #canvas [(nodes)]="nodes" [comments]="true" style="display:block;height:400px" />
@if (canvas.getCommentStore(); as store) {
<grafloria-comment-panel [store]="store" (threadSelect)="selectedThread = $event" />
}
tsimport { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular';
@Component({
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent],
template: `<div style="display:flex;height:400px"><grafloria-diagram-canvas #canvas [(nodes)]="nodes" [comments]="true" style="display:block;flex:1"></grafloria-diagram-canvas>@if (canvas.getCommentStore(); as store) {<grafloria-comment-panel [store]="store" (threadSelect)="selectedThread = $event"></grafloria-comment-panel>}</div>`,
styles: [':host { display: block; height: 400px; }'],
})
export class CommentsComponent {
nodes = [{ id: 'review', label: 'Review', position: { x: 80, y: 100 } }];
selectedThread: string | null = null;
}
Use the equivalent GrafloriaFlow and GrafloriaCommentPanel bindings in Vue and Qwik. In Qwik, mark the live store and transport with noSerialize(); they hold subscribers and channel state, not JSON data.
Reconnect without losing edits
Use a transport that reports connection status. When it reconnects, the session runs anti-entropy and exchanges the operations each peer missed. A transport that never reports status cannot be caught up automatically.
With MemoryHub, you can test this without a server: disconnect both MemoryTransport ports, edit both mounted models, then reconnect and run the session's sync round. Both diagrams then contain both offline edits. See the offline and reconnect demo.
Undo your own edit
Collaboration keeps local history separate from remote history. At the engine level, Replica captures local edits and applies remote operations without adding them to your undo stack. undo() therefore undoes your last edit, not the most recent edit made by another peer; redo() reapplies your last undone edit. Use transact() when several model mutations must become one undo step.
For a mounted diagram, obtain its model through DiagramInstance and keep the rendered instance as the shared handle. The rendered peers converge again when the undo operation crosses the transport.
Choose the layer
- Use
GrafloriaFlowwhen the framework owns the mounted canvas; use its Vue, Qwik, or Angular binding in those frameworks. - Use
DiagramCanvasComponentin Angular. - Use
renderwhen your application mounts diagrams directly. - Use
MemoryHubfor deterministic in-page or test peers.
Pitfalls
- Give every peer a different actor id. The actor id participates in deterministic ordering.
- Create a transport and room once per mounted session. In React and Qwik, do not create them during every render; Qwik transports are live objects, so wrap them with
noSerialize(). - Collaboration does not synchronize viewport changes.
- A canvas with no resolved height appears blank; see Style a diagram.
Live examples
Related: Commands, events, and undo, Model and document, and Save and restore diagrams.
Was this page helpful?