Use presence and anchored comments when people review the same document. Use presentation mode when one person drives the camera and followers must not edit. The examples below show two peers on one browser page: move the pointer in Ana's canvas to see her cursor in Bo's canvas, and open the seeded Review conversation to read and reply. The presentation example shares the camera separately and locks the follower's document.
1. Create a room and a conversation
Install the binding you use, together with its peers.
JavaScript:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
Angular:
bashnpm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element
Qwik:
bashnpm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element
React:
bashnpm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element
Vue:
bashnpm install @grafloria/vue @grafloria/engine @grafloria/renderer vue @grafloria/element
The shipped MemoryHub connects peers in one JavaScript context without a server. One hub represents one room. Give each peer a distinct actor id. A comment viewer identifies the person, not that session: use commentsViewer for a canvas-created store, or construct a CommentStore with viewer yourself.
Put this shared file beside your component. NodeSpec and EdgeSpec describe the two boxes and their connection. seedConversation() writes to the displayed document's store. JavaScript, React, Vue and Qwik show its Review pin; the Angular example shows the conversation panel without canvas pins.
tsimport { CommentStore, MemoryHub } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
export function nodes(): NodeSpec[] {
return [
{ id: 'plan', label: 'Plan', position: { x: 30, y: 100 },
size: { width: 110, height: 60 } },
{ id: 'review', label: 'Review', position: { x: 200, y: 100 },
size: { width: 110, height: 60 } },
];
}
export function edges(): EdgeSpec[] {
return [{ id: 'plan-review', source: 'plan', target: 'review' }];
}
export function makeRoom() {
const hub = new MemoryHub();
return {
ana: { transport: hub.connect('ana-session'), actor: 'ana-session',
batch: false, awarenessThrottleMs: 0,
presence: { name: 'Ana', smoothing: 0 } },
bo: { transport: hub.connect('bo-session'), actor: 'bo-session',
batch: false, awarenessThrottleMs: 0,
presence: { name: 'Bo', smoothing: 0 } },
};
}
export function seedConversation(store: CommentStore): void {
const ben = new CommentStore(store.diagram, {
viewer: 'ben', now: () => Date.now() - 60_000,
});
const threadId = ben.createThread(
{ kind: 'node', id: 'review' }, 'Can we tighten the hero copy?',
);
store.reply(threadId, 'On it — draft by Friday.');
ben.dispose();
}
createThread() returns a thread id; reply() returns a message id. Each reply occupies its own register, so concurrent replies do not replace each other. The temporary Ben store authors the opener on the same live document; disposing it removes its subscriptions, not its messages.
2. Mount peers, pins and the conversation panel
In React, pass collab, comments and commentsViewer to GrafloriaFlow. Get its store in onInit and pass it to GrafloriaCommentPanel.
In Vue, use GrafloriaFlow with @init, and keep the live store in a shallow ref for GrafloriaCommentPanel.
In Qwik, pass each peer's collab and commentsViewer to GrafloriaFlow, then seed the Review conversation through the live DiagramInstance's getCommentStore() and supply that store to GrafloriaCommentPanel; see Documents and kits for browser-side setup and nonserializable state.
In Angular, DiagramCanvasComponent has no commentsViewer input. Construct a store on the bound engine's document and feed it to GrafloriaCommentPanelComponent. Two-way node and edge bindings return edits to your component.
In JavaScript, render() mounts each diagram. Join it with createSyncSession, then use bindPresence on the live instance. CommentPanelView is the shipped DOM panel used by the framework components.
Known issue: Angular's intended
[comments]="anaStore"binding can leave canvas pins invisible: renderer recreation does not reattach an existing comment overlay. Delaying the binding until after the first render does not reliably restore the pins. Until it is fixed, display the shared conversation through<grafloria-comment-panel [store]="anaStore" />without promising canvas pins; the Angular example uses this panel-only fallback.
In Vue, @init runs before the sync session joins. Wait for both @collab-ready events before seeding the conversation so both peers receive its comment operations.
ts// main.ts — run in the browser; call unmount when removing this view.
import { render } from '@grafloria/element';
import { createSyncSession } from '@grafloria/engine';
import { bindPresence, CommentPanelView } from '@grafloria/renderer';
import { nodes, edges, makeRoom, seedConversation } from './room';
const root = document.createElement('div');
root.style.cssText = 'display:flex;height:420px';
document.body.append(root);
const room = makeRoom();
function peer(config: typeof room.ana, viewer: string) {
const host = document.createElement('div');
host.style.cssText = 'flex:1;min-width:0;height:420px';
root.append(host);
const instance = render({ nodes: nodes(), edges: edges() }, host,
{ comments: true, commentsViewer: viewer });
const session = createSyncSession(instance.getModel(), config.transport, {
actor: config.actor, batch: false, awarenessThrottleMs: 0,
});
session.join();
const presence = bindPresence(instance, session, config.presence);
return { instance, session, presence };
}
const ana = peer(room.ana, 'ana');
const bo = peer(room.bo, 'bo');
const store = ana.instance.getCommentStore();
if (!store) throw new Error('Comments are not enabled');
seedConversation(store);
const sidebar = document.createElement('div');
sidebar.style.cssText = 'width:280px;overflow:auto';
root.append(sidebar);
const panel = new CommentPanelView(sidebar, store);
const off = store.onChange(() => panel.update());
export function unmount(): void {
off();
panel.dispose();
for (const peer of [ana, bo]) {
peer.presence.dispose();
peer.session.leave();
peer.session.dispose();
peer.instance.dispose();
}
room.ana.transport.close();
room.bo.transport.close();
root.remove();
}
ts// review.component.ts
import { Component, AfterViewInit, OnDestroy } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular';
import { DiagramEngine, CommentStore } from '@grafloria/engine';
import { nodes, edges, makeRoom, seedConversation } from './room';
@Component({
selector: 'app-review', standalone: true,
imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent],
template: `
<div style="display:flex;height:420px">
<grafloria-diagram-canvas [engine]="anaEngine"
[(nodes)]="anaNodes" [(edges)]="anaEdges"
[collab]="room.ana"
style="display:block;flex:1;min-width:0;height:100%" />
<grafloria-diagram-canvas [engine]="boEngine"
[(nodes)]="boNodes" [(edges)]="boEdges"
[collab]="room.bo"
style="display:block;flex:1;min-width:0;height:100%" />
<grafloria-comment-panel [store]="anaStore"
style="width:280px;overflow:auto" />
</div>`,
})
export class ReviewComponent implements AfterViewInit, OnDestroy {
readonly room = makeRoom();
readonly anaEngine = new DiagramEngine();
readonly boEngine = new DiagramEngine();
readonly anaStore = new CommentStore(this.anaEngine.createDiagram('Review'), { viewer: 'ana' });
readonly boStore = new CommentStore(this.boEngine.createDiagram('Review'), { viewer: 'bo' });
anaNodes = nodes();
anaEdges = edges();
boNodes = nodes();
boEdges = edges();
ngAfterViewInit(): void { seedConversation(this.anaStore); }
ngOnDestroy(): void {
this.anaStore.dispose();
this.boStore.dispose();
this.anaEngine.dispose();
this.boEngine.dispose();
this.room.ana.transport.close();
this.room.bo.transport.close();
}
}
tsx// review.tsx
import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow, GrafloriaCommentPanel } from '@grafloria/qwik';
import type { CommentStore } from '@grafloria/engine';
import { nodes, edges, makeRoom, seedConversation } from './room';
export default component$(() => {
const room = useSignal<NoSerialize<ReturnType<typeof makeRoom>>>();
const store = useSignal<NoSerialize<CommentStore>>();
useVisibleTask$(({ cleanup }) => {
const live = makeRoom();
room.value = noSerialize(live);
cleanup(() => {
live.ana.transport.close();
live.bo.transport.close();
});
});
return (
<div style={{ display: 'flex', height: '420px' }}>
{room.value && <>
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
collab={noSerialize(room.value.ana)} comments commentsViewer="ana"
style={{ flex: '1', minWidth: '0', height: '100%' }}
onInit$={(instance) => {
const live = instance.getCommentStore();
if (live) { seedConversation(live); store.value = noSerialize(live); }
}} />
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
collab={noSerialize(room.value.bo)} comments commentsViewer="bo"
style={{ flex: '1', minWidth: '0', height: '100%' }} />
</>}
{store.value && <div style={{ width: '280px', overflow: 'auto' }}>
<GrafloriaCommentPanel store={store.value} />
</div>}
</div>
);
});
tsx// Review.tsx
import { useEffect, useMemo, useState } from 'react';
import { GrafloriaFlow, GrafloriaCommentPanel } from '@grafloria/react';
import type { CommentStore } from '@grafloria/engine';
import { nodes, edges, makeRoom, seedConversation } from './room';
export default function Review() {
const room = useMemo(makeRoom, []);
const [store, setStore] = useState<CommentStore | null>(null);
useEffect(() => () => {
room.ana.transport.close();
room.bo.transport.close();
}, [room]);
return (
<div style={{ display: 'flex', height: 420 }}>
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
collab={room.ana} comments commentsViewer="ana"
style={{ flex: 1, minWidth: 0, height: '100%' }}
onInit={(instance) => {
const live = instance.getCommentStore();
if (live) { seedConversation(live); setStore(live); }
}} />
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
collab={room.bo} comments commentsViewer="bo"
style={{ flex: 1, minWidth: 0, height: '100%' }} />
{store && <div style={{ width: 280, overflow: 'auto' }}>
<GrafloriaCommentPanel store={store} />
</div>}
</div>
);
}
vue<!-- Review.vue --> <script setup lang="ts"> import { shallowRef, onUnmounted } from 'vue'; import { GrafloriaFlow, GrafloriaCommentPanel } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import type { CommentStore } from '@grafloria/engine'; import { nodes, edges, makeRoom, seedConversation } from './room'; const room = makeRoom(); const anaNodes = nodes(), anaEdges = edges(); const boNodes = nodes(), boEdges = edges(); const store = shallowRef<CommentStore | null>(null); let joined = 0; function init(instance: DiagramInstance): void { const live = instance.getCommentStore(); if (live) store.value = live; } function collabReady(): void { joined += 1; if (joined === 2 && store.value) seedConversation(store.value); } onUnmounted(() => { room.ana.transport.close(); room.bo.transport.close(); }); </script> <template> <div style="display:flex;height:420px"> <div style="flex:1;min-width:0"> <GrafloriaFlow :default-nodes="anaNodes" :default-edges="anaEdges" :collab="room.ana" :comments="true" comments-viewer="ana" @init="init" @collab-ready="collabReady" /> </div> <div style="flex:1;min-width:0"> <GrafloriaFlow :default-nodes="boNodes" :default-edges="boEdges" :collab="room.bo" :comments="true" comments-viewer="bo" @collab-ready="collabReady" /> </div> <div v-if="store" style="width:280px;overflow:auto"> <GrafloriaCommentPanel :store="store" /> </div> </div> </template>
The panel initially lists the Review thread. Select it to open the messages and reply form. Move across either canvas to publish that peer's cursor; leaving the canvas clears it. Select a node to show its remote selection outline. Comments are document data carried by the sync session; read state belongs to each viewer and is not synced. Separate peers use stores over their own synced models, not one store pointing at the other peer's model.
Try the live cursors demo and the threaded comments demo.
3. Follow a presenter and lock the follower
presentTo broadcasts a host's camera. followPresenter applies its centre and zoom while retaining the follower's canvas size. Both return a handle with stop(). Use the shipped InMemoryViewportChannel for two canvases on one page.
Call lockDocument on the follower's engine. It drives the engine's mode rather than hiding editing controls: commands, guarded model mutators and document-editing gestures refuse writes. Pan and zoom remain available. Unlock with lockDocument(engine, false) when your application ends the read-only session.
This shared controller waits for both mounted hosts. It locks the follower before subscribing, frames the presenter, and keeps both handles for unmount. PresentationHost needs only a viewport and render(); a diagram instance already satisfies it. Angular supplies those from its canvas. DiagramEngine supplies the document lock.
tsimport {
InMemoryViewportChannel, presentTo, followPresenter, lockDocument,
} from '@grafloria/renderer';
import type { PresentationHost } from '@grafloria/renderer';
import type { DiagramEngine } from '@grafloria/engine';
export function makePresentation() {
const channel = new InMemoryViewportChannel();
let presenter: PresentationHost | undefined;
let follower: PresentationHost | undefined;
let frame: (() => void) | undefined;
let stops: Array<() => void> = [];
function wire(): void {
if (!presenter || !follower || stops.length) return;
const sending = presentTo(presenter, channel, { presenterId: 'ana' });
const following = followPresenter(follower, channel, { ignorePresenterId: 'bo' });
stops = [sending.stop, following.stop];
frame?.();
}
return {
presenter(host: PresentationHost, fit: () => void): void {
presenter = host; frame = fit; wire();
},
follower(host: PresentationHost, engine: DiagramEngine): void {
lockDocument(engine, true); follower = host; wire();
},
dispose(): void {
for (const stop of stops) stop();
channel.dispose();
},
};
}
Replace the review component with the matching presentation component below. These canvases share only the camera, not document edits: dragging Plan in the presenter does not move the follower's Plan. Pan or zoom the presenter to move the follower's view; try dragging a follower node to see the lock refuse the edit.
Known issue: Angular's canvas-to-camera effect can replace the camera's measured dimensions with the viewport signal's default dimensions, so the follower frames a different region. Until it is fixed, copy each measured camera rectangle into its canvas's
viewportsignal before wiring presentation.
ts// main.ts — browser entry for presentation mode.
import { render } from '@grafloria/element';
import { nodes, edges } from './room';
import { makePresentation } from './presentation';
const root = document.createElement('div');
root.style.cssText = 'display:flex;height:420px';
document.body.append(root);
function mount(label: string) {
const pane = document.createElement('div');
pane.style.cssText = 'flex:1;min-width:0';
const heading = document.createElement('div');
heading.textContent = label;
const host = document.createElement('div');
host.style.height = '390px';
pane.append(heading, host);
root.append(pane);
return render({ nodes: nodes(), edges: edges() }, host);
}
const presenter = mount('Presenter');
const follower = mount('Follower — read-only');
const presentation = makePresentation();
follower.viewport.syncCanvasSize(follower.container.getBoundingClientRect());
presentation.presenter(presenter, () => {
presenter.viewport.syncCanvasSize(presenter.container.getBoundingClientRect());
presenter.fitView(60);
});
presentation.follower(follower, follower.getEngine());
export function unmount(): void {
presentation.dispose();
presenter.dispose();
follower.dispose();
root.remove();
}
ts// presentation.component.ts
import { Component, AfterViewInit, OnDestroy, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { nodes, edges } from './room';
import { makePresentation } from './presentation';
@Component({
selector: 'app-presentation', standalone: true, imports: [DiagramCanvasComponent],
template: `
<div style="display:flex;height:420px">
<div style="flex:1;min-width:0">Presenter
<grafloria-diagram-canvas #a [(nodes)]="nodesA" [(edges)]="edgesA"
style="display:block;height:390px" />
</div>
<div style="flex:1;min-width:0">Follower — read-only
<grafloria-diagram-canvas #b [(nodes)]="nodesB" [(edges)]="edgesB"
style="display:block;height:390px" />
</div>
</div>`,
})
export class PresentationComponent implements AfterViewInit, OnDestroy {
readonly a = viewChild.required<DiagramCanvasComponent>('a');
readonly b = viewChild.required<DiagramCanvasComponent>('b');
readonly presentation = makePresentation();
nodesA = nodes(); edgesA = edges();
nodesB = nodes(); edgesB = edges();
ngAfterViewInit(): void {
const a = this.a(), b = this.b();
const viewportA = a.viewportController(), viewportB = b.viewportController();
const engineB = b.activeEngine();
if (!viewportA || !viewportB || !engineB) return;
// Preserve measured dimensions when the canvas-to-camera effect runs.
a.viewport.set(viewportA.getViewport());
b.viewport.set(viewportB.getViewport());
this.presentation.presenter({ viewport: viewportA, render: () => a.scheduleRender() },
() => a.fitToContent(60));
this.presentation.follower({ viewport: viewportB, render: () => b.scheduleRender() }, engineB);
}
ngOnDestroy(): void { this.presentation.dispose(); }
}
tsx// presentation-view.tsx
import { component$, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import { nodes, edges } from './room';
import { makePresentation } from './presentation';
export default component$(() => {
const presentation = useSignal<NoSerialize<ReturnType<typeof makePresentation>>>();
useVisibleTask$(({ cleanup }) => {
const live = makePresentation();
presentation.value = noSerialize(live);
cleanup(() => live.dispose());
});
return <div style={{ display: 'flex', height: '420px' }}>
{presentation.value && <>
<div style={{ flex: '1', minWidth: '0' }}>Presenter
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
style={{ height: '390px' }}
onInit$={(instance) => {
presentation.value?.presenter(instance, () => instance.fitView(60));
}} />
</div>
<div style={{ flex: '1', minWidth: '0' }}>Follower — read-only
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
style={{ height: '390px' }}
onInit$={(instance) => {
presentation.value?.follower(instance, instance.getEngine());
}} />
</div>
</>}
</div>;
});
tsx// Presentation.tsx
import { useEffect, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { nodes, edges } from './room';
import { makePresentation } from './presentation';
export default function Presentation() {
const presentation = useRef(makePresentation());
useEffect(() => {
const live = presentation.current;
return () => live.dispose();
}, []);
return <div style={{ display: 'flex', height: 420 }}>
<div style={{ flex: 1, minWidth: 0 }}>Presenter
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
style={{ height: 390 }} onInit={(instance) => {
presentation.current.presenter(instance, () => instance.fitView(60));
}} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>Follower — read-only
<GrafloriaFlow defaultNodes={nodes()} defaultEdges={edges()}
style={{ height: 390 }} onInit={(instance) => {
presentation.current.follower(instance, instance.getEngine());
}} />
</div>
</div>;
}
vue<!-- Presentation.vue --> <script setup lang="ts"> import { onUnmounted } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges } from './room'; import { makePresentation } from './presentation'; const presentation = makePresentation(); const nodesA = nodes(), edgesA = edges(); const nodesB = nodes(), edgesB = edges(); function initA(instance: DiagramInstance): void { presentation.presenter(instance, () => instance.fitView(60)); } function initB(instance: DiagramInstance): void { presentation.follower(instance, instance.getEngine()); } onUnmounted(() => presentation.dispose()); </script> <template> <div style="display:flex;height:420px"> <div style="flex:1;min-width:0">Presenter <GrafloriaFlow :default-nodes="nodesA" :default-edges="edgesA" style="height:390px" @init="initA" /> </div> <div style="flex:1;min-width:0">Follower — read-only <GrafloriaFlow :default-nodes="nodesB" :default-edges="edgesB" style="height:390px" @init="initB" /> </div> </div> </template>
See the live presentation mode demo and its source.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
comments | boolean or CommentStore | No store when omitted | Creates a store and pins, or uses your supplied store. Qwik requires a nonserializable store. |
commentsViewer | string | 'local' | Identifies the viewer of a comments: true store in JavaScript, React, Vue and Qwik. |
collab.presence | boolean or presence options | No presence binding when omitted | Enables live cursors and remote selection outlines. |
presence.name | string | Not supplied | Labels your cursor on other peers. |
presence.smoothing | number | 0.35 | Sets interpolation; 0 snaps to each received cursor position. |
presentTo options: throttleMs | number | 50 | Coalesces broadcasts, including a trailing send with the final camera position. |
followPresenter options: ignorePresenterId | string | Not supplied | Ignores your own presenter id to avoid camera echoes. |
Panel options: showResolved | boolean | false | Includes resolved threads in the conversation list. |
Pitfalls and related tasks
- A presence cursor is awareness, not a document edit. It uses a separate DOM overlay; cursor updates do not belong in your node specs.
- Deleting a comment's node leaves an orphaned thread, not a deleted conversation. Use
reanchor(threadId, { kind: 'node', id: 'plan' })to rescue it onto a live node. Resolving a thread hides it from the default panel;reopen()makes it unresolved again. - Presentation and locking are independent. Following a camera does not lock a document or synchronize its nodes. Keep the explicit lock call for a read-only follower.
- A transport does not supply your product's authentication, rooms or storage. For remote peers and reconnect handling, continue with Synchronize editors.
- For persistence, save the live document rather than framework projections: Save and restore documents. For user-facing edits and undo, see Commands and history.
Was this page helpful?