Skip to content
D
Documentation

Share presence and comments

how-to
5 min readUpdated

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:

bash
npm install @grafloria/element @grafloria/engine @grafloria/renderer

Angular:

bash
npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element

Qwik:

bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @builder.io/qwik @grafloria/element

React:

bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer react react-dom @grafloria/element

Vue:

bash
npm 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.

ts
import { 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();
}
The JavaScript review view shows two connected nodes in each pane, Review comment pins and a conversation list with one unread message.

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.

ts
import {
  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 viewport signal 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();
}
The React presentation view shows Plan connected to Review in both the presenter and read-only follower panes.

See the live presentation mode demo and its source.

Options that matter

OptionTypeDefaultWhat it does
commentsboolean or CommentStoreNo store when omittedCreates a store and pins, or uses your supplied store. Qwik requires a nonserializable store.
commentsViewerstring'local'Identifies the viewer of a comments: true store in JavaScript, React, Vue and Qwik.
collab.presenceboolean or presence optionsNo presence binding when omittedEnables live cursors and remote selection outlines.
presence.namestringNot suppliedLabels your cursor on other peers.
presence.smoothingnumber0.35Sets interpolation; 0 snaps to each received cursor position.
presentTo options: throttleMsnumber50Coalesces broadcasts, including a trailing send with the final camera position.
followPresenter options: ignorePresenterIdstringNot suppliedIgnores your own presenter id to avoid camera echoes.
Panel options: showResolvedbooleanfalseIncludes resolved threads in the conversation list.
  • 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?

Share presence and comments — Grafloria