# Add presence and comments

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`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-engine-sync-classes#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](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/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`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-qwik#grafloriaflow). Wrap each live transport configuration in `noSerialize()` and create it in a visible task; the [Qwik live-cursors demo source](https://github.com/grafloria/grafloria/blob/6538c1506102712e4265e25bd8ffbb240b3e5038/apps/demos-qwik/gallery/demos/live-cursors.tsx) shows the binding.

[`bindPresence`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) 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`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#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`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) 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`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) broadcasts the presenter’s camera, and [`followPresenter`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-element-core) 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](https://grafloria.com/demos/collab/presentation-mode.html).

The presentation helpers accept the same host shape used by a mounted [`DiagramInstance`](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/grafloria-renderer-instance-diagraminstance#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](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/react-quick-start) and [Vue quick start](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/vue-quick-start). This page focuses on the added reply, so the thread panel shows a conversation rather than only its opening comment.

:::code-group
```tsx title="Qwik"
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>
  );
});
```
```ts title="Angular"
import { 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](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/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](https://grafloria.com/demos/collab/comments.html).

## 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.
- `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

- [Live cursors](https://grafloria.com/demos/collab/live-cursors.html) — move over either pane to see the remote cursor and selection.
- [Presentation mode](https://grafloria.com/demos/collab/presentation-mode.html) — pan and zoom the presenter pane to drive the follower’s view.
- [Threaded comments](https://grafloria.com/demos/collab/comments.html) — start a comment and reply in its thread.

Related: [Synchronize diagrams](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/synchronize-diagrams), [the graph model and document](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/graph-model-and-document), and [the instance and data flow](https://bench-grafloria-6l.atloria.app/p/bench-grafloria-6l-dqcsEdUbfS/developer/instance-and-data-flow).
