# Add collaboration

Connect two mounted diagrams to the same room and watch edits, presence, comments, reconnects, and local undo converge.

## When to use this

Use the `collab` prop on [`GrafloriaFlow`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-react#grafloriaflow) when each mounted diagram has a transport and an actor id. The canvas joins when it mounts and leaves when it unmounts. Local edits become CRDT operations; incoming operations merge per property, so a move and a rename on the same node can both survive.

`BroadcastChannelTransport` is the shipped no-server choice for two tabs in one browser. Use `WebSocketTransport` when your application supplies a server-backed channel. Grafloria supplies convergence, presence, comments, and the operation log; your application supplies rooms, authentication, and storage.

## Connect two diagrams

Use the same channel name for both transports and a different stable actor for each peer. Give each canvas a real height so both mounted diagrams render.

:::code-group
```js title="JavaScript"
import { render } from '@grafloria/element';
import { BroadcastChannelTransport, createSyncSession } from '@grafloria/engine';

document.body.innerHTML = '<div id="left" style="height:400px"></div><div id="right" style="height:400px"></div>';

const nodes = [
  { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
  { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
const room = 'diagram-' + Math.random().toString(36).slice(2);

const left = render({ nodes, edges }, '#left');
const right = render({ nodes: nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } })), edges: edges.map((edge) => ({ ...edge })) }, '#right');
createSyncSession(left.getModel(), new BroadcastChannelTransport({ name: room, actor: 'ana' }), { actor: 'ana' }).join();
createSyncSession(right.getModel(), new BroadcastChannelTransport({ name: room, actor: 'ben' }), { actor: 'ben' }).join();

left.getModel().getNode('ingest')?.setPosition(100, 180);
right.fitView();
```
```tsx title="React"
import { useMemo } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { BroadcastChannelTransport } from '@grafloria/engine';

const nodes = [
  { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
  { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];

export function CollaborativeDiagram() {
  const collab = useMemo(() => {
    const room = 'diagram-' + Math.random().toString(36).slice(2);
    return {
      ana: { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } },
      ben: { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } },
    };
  }, []);
  return <div style={{ display: 'flex', height: 400 }}>
    <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={collab.ana} style={{ flex: 1 }} />
    <GrafloriaFlow defaultNodes={nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }))} defaultEdges={edges.map((edge) => ({ ...edge }))} collab={collab.ben} style={{ flex: 1 }} />
  </div>;
}
```
```vue title="Vue"
<script setup lang="ts">
import { GrafloriaFlow } from '@grafloria/vue';
import { BroadcastChannelTransport } from '@grafloria/engine';

const nodes = [
  { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
  { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
const room = 'diagram-' + Math.random().toString(36).slice(2);
const collabA = { transport: new BroadcastChannelTransport({ name: room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } };
const collabB = { transport: new BroadcastChannelTransport({ name: room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } };
const nodesB = nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }));
</script>

<template>
  <div style="display:flex; height:400px">
    <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :collab="collabA" style="flex:1" />
    <GrafloriaFlow :default-nodes="nodesB" :default-edges="edges" :collab="collabB" style="flex:1" />
  </div>
</template>
```
```tsx title="Qwik"
import { component$, noSerialize, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow, type GrafloriaCollabOptions } from '@grafloria/qwik';
import { BroadcastChannelTransport } from '@grafloria/engine';

const nodes = [
  { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
  { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
];
const edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];

export default component$(() => {
  const room = useSignal('diagram-' + Math.random().toString(36).slice(2));
  const ana = noSerialize<GrafloriaCollabOptions>({ transport: new BroadcastChannelTransport({ name: room.value, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } });
  const ben = noSerialize<GrafloriaCollabOptions>({ transport: new BroadcastChannelTransport({ name: room.value, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } });
  const nodesB = nodes.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }));
  return <div style={{ display: 'flex', height: '400px' }}>
    <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} collab={ana} style={{ flex: '1' }} />
    <GrafloriaFlow defaultNodes={nodesB} defaultEdges={edges} collab={ben} style={{ flex: '1' }} />
  </div>;
});
```
```ts title="Angular"
import { Component } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { BroadcastChannelTransport } from '@grafloria/engine';

@Component({ standalone: true, imports: [DiagramCanvasComponent], template: `<div style="display:flex;height:400px"><grafloria-diagram-canvas [nodes]="nodesA" [edges]="edges" [collab]="collabA" style="display:block;flex:1" /><grafloria-diagram-canvas [nodes]="nodesB" [edges]="edges" [collab]="collabB" style="display:block;flex:1" /></div>` })
export class AppComponent {
  nodesA = [
    { id: 'ingest', label: 'Ingest', position: { x: 60, y: 60 }, size: { width: 150, height: 66 } },
    { id: 'publish', label: 'Publish', position: { x: 320, y: 60 }, size: { width: 150, height: 66 } },
  ];
  nodesB = this.nodesA.map((node) => ({ ...node, position: { ...node.position }, size: { ...node.size } }));
  edges = [{ id: 'e1', source: 'ingest', target: 'publish' }];
  private room = 'diagram-' + Math.random().toString(36).slice(2);
  collabA = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ana' }), actor: 'ana', presence: { name: 'Ana' } };
  collabB = { transport: new BroadcastChannelTransport({ name: this.room, actor: 'ben' }), actor: 'ben', presence: { name: 'Ben' } };
}
```
:::

Each pair renders the same connected diagram. Drag a node in either pane: the other pane follows and displays the other actor's presence. Camera changes remain local to each pane; document edits synchronize.

See the [live two-tabs demo](https://grafloria.com/demos/collab/two-tabs-live.html) and its [source](https://github.com/grafloria/grafloria/blob/ef2bcc55237d4d1f1643b6fea68bd996aa37a9e9/demos/collab/two-tabs-live.html).

## Add comments

Set `comments` to `true` to create the canvas's comment store. Read that store from the mounted instance and give it to the ready-made panel. This sample mounts the panel with an empty store, so it displays “Comments — No comments yet.”

For React, Vue, and Qwik, use the binding's [`GrafloriaCommentPanel`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-react#grafloriacommentpanel); for Angular, use [`GrafloriaCommentPanelComponent`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-angular-components-classes#grafloriacommentpanelcomponent) as `<grafloria-comment-panel>`.

```tsx
import { useState } from 'react';
import { GrafloriaFlow, GrafloriaCommentPanel } from '@grafloria/react';
import type { CommentStore } from '@grafloria/engine';

export function Comments() {
  const [store, setStore] = useState<CommentStore | null>(null);
  return <div style={{ display: 'flex', height: 400 }}>
    <GrafloriaFlow defaultNodes={[{ id: 'review', label: 'Review', position: { x: 80, y: 100 } }]} comments onInit={(instance) => setStore(instance.getCommentStore())} style={{ flex: 1 }} />
    {store && <GrafloriaCommentPanel store={store} />}
  </div>;
}
```

The Angular shape is:

```html
<grafloria-diagram-canvas #canvas [(nodes)]="nodes" [comments]="true" style="display:block;height:400px" />
@if (canvas.getCommentStore(); as store) {
  <grafloria-comment-panel [store]="store" (threadSelect)="selectedThread = $event" />
}
```

```ts
import { Component } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaCommentPanelComponent } from '@grafloria/angular';

@Component({
  standalone: true,
  imports: [DiagramCanvasComponent, GrafloriaCommentPanelComponent],
  template: `<div style="display:flex;height:400px"><grafloria-diagram-canvas #canvas [(nodes)]="nodes" [comments]="true" style="display:block;flex:1"></grafloria-diagram-canvas>@if (canvas.getCommentStore(); as store) {<grafloria-comment-panel [store]="store" (threadSelect)="selectedThread = $event"></grafloria-comment-panel>}</div>`,
  styles: [':host { display: block; height: 400px; }'],
})
export class CommentsComponent {
  nodes = [{ id: 'review', label: 'Review', position: { x: 80, y: 100 } }];
  selectedThread: string | null = null;
}
```

Use the equivalent `GrafloriaFlow` and `GrafloriaCommentPanel` bindings in Vue and Qwik. In Qwik, mark the live store and transport with `noSerialize()`; they hold subscribers and channel state, not JSON data.

## Reconnect without losing edits

Use a transport that reports connection status. When it reconnects, the session runs anti-entropy and exchanges the operations each peer missed. A transport that never reports status cannot be caught up automatically.

With [`MemoryHub`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-engine-sync-classes#memoryhub), you can test this without a server: disconnect both `MemoryTransport` ports, edit both mounted models, then reconnect and run the session's sync round. Both diagrams then contain both offline edits. See the [offline and reconnect demo](https://grafloria.com/demos/collab/offline-and-reconnect.html).

## Undo your own edit

Collaboration keeps local history separate from remote history. At the engine level, [`Replica`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-engine-collab-replica#replica) captures local edits and applies remote operations without adding them to your undo stack. `undo()` therefore undoes your last edit, not the most recent edit made by another peer; `redo()` reapplies your last undone edit. Use `transact()` when several model mutations must become one undo step.

For a mounted diagram, obtain its model through [`DiagramInstance`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-renderer-instance-diagraminstance#diagraminstance) and keep the rendered instance as the shared handle. The rendered peers converge again when the undo operation crosses the transport.

## Choose the layer

- Use [`GrafloriaFlow`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-react#grafloriaflow) when the framework owns the mounted canvas; use its Vue, Qwik, or Angular binding in those frameworks.
- Use [`DiagramCanvasComponent`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-angular-components-diagramcanvascomponent#diagramcanvascomponent) in Angular.
- Use [`render`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-element-core#render) when your application mounts diagrams directly.
- Use [`MemoryHub`](https://atloria.dev/p/grafloria-h7YM7amryF/developer/grafloria-engine-sync-classes#memoryhub) for deterministic in-page or test peers.

## Pitfalls

- Give every peer a different actor id. The actor id participates in deterministic ordering.
- Create a transport and room once per mounted session. In React and Qwik, do not create them during every render; Qwik transports are live objects, so wrap them with `noSerialize()`.
- Collaboration does not synchronize viewport changes.
- A canvas with no resolved height appears blank; see [Style a diagram](https://atloria.dev/p/grafloria-h7YM7amryF/developer/style-a-diagram).

## Live examples

- [Two tabs, live](https://grafloria.com/demos/collab/two-tabs-live.html)
- [Offline and reconnect](https://grafloria.com/demos/collab/offline-and-reconnect.html)
- [Live cursors](https://grafloria.com/demos/collab/live-cursors.html)
- [Comments](https://grafloria.com/demos/collab/comments.html)
- [Conflict resolution](https://grafloria.com/demos/collab/conflict-resolution.html)

Related: [Commands, events, and undo](https://atloria.dev/p/grafloria-h7YM7amryF/developer/commands-events-and-undo), [Model and document](https://atloria.dev/p/grafloria-h7YM7amryF/developer/model-and-document), and [Save and restore diagrams](https://atloria.dev/p/grafloria-h7YM7amryF/developer/save-and-restore-diagrams).
