Use presence when participants need to see each other’s cursors and selections, and use anchored comment threads when discussion belongs to a node or link. A presenter can also broadcast a viewport so followers see the same world region and zoom.
Show participants’ presence
Give each canvas a collaboration transport and a unique actor id. The flow joins its sync session when it mounts; setting presence adds live cursors and remote selection outlines. For a local, server-free example, connect two peers through MemoryHub. Move the pointer over either canvas: the other pane shows that participant’s labelled cursor. Cursor presence is separate from diagram edits; it does not add cursor updates to the operation log.
jsimport { MemoryHub } from '@grafloria/engine';
import { render } from '@grafloria/element';
import { createSyncSession } from '@grafloria/engine';
import { bindPresence } from '@grafloria/renderer';
const hub = new MemoryHub();
const anaHost = document.createElement('div');
const boHost = document.createElement('div');
anaHost.style.cssText = 'height:400px; flex:1';
boHost.style.cssText = 'height:400px; flex:1';
document.body.append(anaHost, boHost);
const nodes = [
{ id: 'plan', label: 'Plan', position: { x: 70, y: 90 }, size: { width: 150, height: 66 } },
{ id: 'build', label: 'Build', position: { x: 320, y: 90 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'plan', target: 'build' }];
const ana = render({ nodes, edges }, anaHost);
const bo = render({ nodes, edges }, boHost);
const sessionAna = createSyncSession(ana.getModel(), hub.connect('ana'), { actor: 'ana' });
const sessionBo = createSyncSession(bo.getModel(), hub.connect('bo'), { actor: 'bo' });
sessionAna.join();
sessionBo.join();
bindPresence(ana, sessionAna, { name: 'Ana' });
bindPresence(bo, sessionBo, { name: 'Bo' });
Give both targets a real height:
html<div style="display:flex; height:400px">
<div id="ana" style="flex:1"></div>
<div id="bo" style="flex:1"></div>
</div>
The two-peer setup for React, Vue, Qwik, and Angular is shown in Synchronize diagrams. For presence, add a display identity to each peer's collab value, such as presence: { name: 'Ana' }; the remote canvas then labels that participant's cursor.
In Qwik, set the same collab prop on each GrafloriaFlow. Wrap each live transport configuration in noSerialize() and create it in a visible task; the Qwik live-cursors demo source shows the binding.
bindPresence accepts a mounted instance and a sync session; its options include the display name and cursor smoothing. Use it when you need to bind presence yourself rather than enabling it through a framework’s collab prop. DiagramInstance exposes the model used by a session and the viewport used by the renderer.
Follow a presenter
Use a viewport channel when one participant drives the camera and others follow. InMemoryViewportChannel is the shipped in-process channel for two canvases on one page; replace it with a channel backed by your application’s network transport when participants are on separate clients. presentTo broadcasts the presenter’s camera, and followPresenter applies it to a follower. The follower retains its own canvas size while matching the presenter’s world center and zoom.
jsimport { render } from '@grafloria/element';
import { InMemoryViewportChannel, followPresenter, presentTo } from '@grafloria/element';
const spec = {
nodes: [
{ id: 'a', label: 'Plan', position: { x: 60, y: 80 }, size: { width: 130, height: 60 } },
{ id: 'b', label: 'Build', position: { x: 320, y: 80 }, size: { width: 130, height: 60 } },
],
edges: [{ id: 'e1', source: 'a', target: 'b' }],
};
const presenterHost = document.createElement('div');
const followerHost = document.createElement('div');
presenterHost.style.cssText = 'height:400px; flex:1';
followerHost.style.cssText = 'height:400px; flex:1';
document.body.append(presenterHost, followerHost);
const presenter = render(spec, presenterHost);
const follower = render(spec, followerHost);
const channel = new InMemoryViewportChannel();
const presenting = presentTo(presenter, channel, { presenterId: 'ana' });
const following = followPresenter(follower, channel, { ignorePresenterId: 'bo' });
presenter.fitView(60);
Give both mount targets a height as in the earlier example. Panning or zooming the presenter then moves the follower’s camera to the corresponding view. Keep the returned handles and call their stop() methods when presentation ends. To make a follower read-only as well, lock its engine with the presentation-mode API; camera following and document edit permission are separate concerns.
Live demo: Presentation mode.
The presentation helpers accept the same host shape used by a mounted DiagramInstance, so the setup can run from a framework’s init/ready callback. The wrapper-specific way to capture that instance is shown in the React, Vue, and Qwik bindings’ onInit callback and the Angular canvas component’s instance accessors.
Add anchored comment threads
Enable comments on the canvas, get its store from the mounted instance, and pass that store to the shipped comment panel. The panel renders the thread list and composer; createThread() starts a thread anchored to a node or link, and reply() adds a message to it. In this example, the panel shows a two-message conversation attached to the Review node.
React and Vue use the same comment-panel setup as their quick starts: React quick start and Vue quick start. This page focuses on the added reply, so the thread panel shows a conversation rather than only its opening comment.
tsximport { $, component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaCommentPanel, GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik';
import type { CommentStore } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'design', position: { x: 80, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Design' } },
{ id: 'review', position: { x: 330, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Review' } },
{ id: 'ship', position: { x: 580, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Ship' } },
];
const edges: EdgeSpec[] = [
{ id: 'e1', source: 'design', target: 'review' },
{ id: 'e2', source: 'review', target: 'ship' },
];
function panelBound(root: HTMLElement | undefined): Promise<void> {
return new Promise((resolve) => {
const poll = () => {
if (root?.querySelector('[data-grafloria-comment-panel]')) resolve();
else requestAnimationFrame(poll);
};
poll();
});
}
export default component$(() => {
const root = useSignal<HTMLDivElement>();
const store = useSignal<NoSerialize<CommentStore>>();
return (
<div ref={root} style={{ display: 'flex', height: '400px' }}>
<GrafloriaFlow
defaultNodes={nodes}
defaultEdges={edges}
comments
style={{ flex: 1 }}
onInit$={$(async (instance: DiagramInstance) => {
const comments = instance.getCommentStore();
if (!comments) return;
store.value = noSerialize(comments);
await panelBound(root.value);
const thread = comments.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?');
comments.reply(thread, 'On it — draft by Friday.');
})}
/>
{store.value && <GrafloriaCommentPanel store={store.value} />}
</div>
);
});
tsimport { AfterViewInit, Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular';
import type { CommentStore } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
@Component({
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent],
template: `
<div style="display:flex; height:400px">
<grafloria-diagram-canvas [nodes]="nodes" [edges]="edges" [comments]="true" style="display:block; flex:1" />
@if (store) { <grafloria-comment-panel [store]="store" /> }
</div>
`,
})
export class CommentsDemoComponent implements AfterViewInit {
canvas = viewChild.required(DiagramCanvasComponent);
store: CommentStore | null = null;
nodes: NodeSpec[] = [
{ id: 'design', position: { x: 80, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Design' } },
{ id: 'review', position: { x: 330, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Review' } },
{ id: 'ship', position: { x: 580, y: 120 }, size: { width: 150, height: 66 }, data: { label: 'Ship' } },
];
edges: EdgeSpec[] = [
{ id: 'e1', source: 'design', target: 'review' },
{ id: 'e2', source: 'review', target: 'ship' },
];
ngAfterViewInit() {
this.store = this.canvas().getCommentStore();
if (this.store) {
const thread = this.store.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?');
this.store.reply(thread, 'On it — draft by Friday.');
}
}
}
For Qwik's component, store, and panel setup, see the Qwik quick start; this page adds a reply so the anchored thread contains a conversation.
For plain JavaScript, mount a store on the rendered model and render a conversation from its thread view:
jsimport { CommentStore } from '@grafloria/engine';
import { render } from '@grafloria/element';
const diagramHost = document.createElement('div');
const conversationHost = document.createElement('div');
diagramHost.style.height = '400px';
document.body.append(diagramHost, conversationHost);
const instance = render({
nodes: [
{ id: 'review', label: 'Review', position: { x: 120, y: 90 }, size: { width: 180, height: 70 } },
],
}, diagramHost);
const store = new CommentStore(instance.getModel(), { viewer: 'ana' });
const threadId = store.createThread({ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?');
store.reply(threadId, 'On it — draft by Friday.');
const thread = store.thread(threadId);
if (thread) conversationHost.textContent = thread.messages.map((message) => message.body).join('\n');
The framework examples use the shipped comment panel instead of hand-building the conversation UI.
Live demo: Threaded comments.
Options and behavior
| Option or call | Type | Default | What it does |
|---|---|---|---|
collab | transport, actor id, and optional sync settings | unset | Joins a collaboration session for the mounted canvas. A framework flow leaves the session when it unmounts. |
presence in collab | boolean | BindPresenceOptions | off | Enables live cursor and remote selection presence; an object can set identity and cursor smoothing. |
comments | boolean | CommentStore | unset | Enables anchored comments; true creates a store, or pass a shared store. |
commentsViewer | string | 'local' for a created store | Sets the viewer id for a store created by the canvas. |
bindPresence() | (instance, syncSession, options?) => PresenceBinding | — | Connects a live instance to session awareness; the returned binding has dispose(). |
presentTo() | (host, channel, options?) => { stop } | 50 ms broadcast throttle | Publishes viewport center and zoom; the trailing update sends the final camera position. |
followPresenter() | (host, channel, options?) => { stop } | — | Applies presenter center and zoom while retaining the follower’s viewport dimensions. |
createThread() | (anchor, body) => string | — | Creates a thread and returns its id. |
reply() | (threadId, body) => string | — | Adds a reply and returns the new message id. |
The collaboration layer supplies convergence, presence, and comments. Your application still provides rooms, authentication, storage, and a network transport for clients on different devices. MemoryHub and InMemoryViewportChannel are in-process examples, not network transports.
Pitfalls
- Give each simultaneous peer its own actor id, while keeping peers in the same room on transports that connect them to one another.
collabis fixed for the lifetime of a mounted framework instance; supply the transport and actor before mounting.- In Qwik, transports and comment stores are live objects. Keep them out of serializable component state with
noSerialize()and create them on the client where needed. - A presenter channel broadcasts only camera state. It does not synchronize the document or enforce read-only mode. Use collaboration sync for document changes and the presentation lock when followers must not edit.
Demos
- Live cursors — move over either pane to see the remote cursor and selection.
- Presentation mode — pan and zoom the presenter pane to drive the follower’s view.
- Threaded comments — start a comment and reply in its thread.
Related: Synchronize diagrams, the graph model and document, and the instance and data flow.
Was this page helpful?