Skip to content
D
Documentation

Add presence and comments

how-to
4 min readUpdated

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.

js
import { 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.

js
import { 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.

tsx
import { $, 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>
  );
});

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:

js
import { 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 callTypeDefaultWhat it does
collabtransport, actor id, and optional sync settingsunsetJoins a collaboration session for the mounted canvas. A framework flow leaves the session when it unmounts.
presence in collabboolean | BindPresenceOptionsoffEnables live cursor and remote selection presence; an object can set identity and cursor smoothing.
commentsboolean | CommentStoreunsetEnables anchored comments; true creates a store, or pass a shared store.
commentsViewerstring'local' for a created storeSets 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 throttlePublishes 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.
  • collab is 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

Related: Synchronize diagrams, the graph model and document, and the instance and data flow.

Was this page helpful?