Skip to content
D
Documentation

Synchronize editors

how-to
5 min readUpdated

Use collaboration when several canvases edit the same document. Pass a shipped transport and a unique actor id through collab: dragging a node in one canvas moves it in the other. The engine merges document edits per property; the framework binding mounts the renderer and owns the session lifecycle.

1. Choose a transport and seed both peers

Use BroadcastChannelTransport for tabs on the same browser origin, without a server. Use WebSocketTransport for editors connected through your server. Each peer needs a different actor id, the same room, and matching initial document ids and content.

Install the binding you use, together with the packages the samples import.

JavaScript:

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

Angular:

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

Qwik:

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

React:

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

Vue:

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

Put this shared file beside your component. The typed NodeSpec and EdgeSpec arrays draw two connected nodes. Each editor gets its own copy of the seed.

ts
import { BroadcastChannelTransport } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';

export function seedNodes(): NodeSpec[] {
  return [
    { id: 'a', label: 'Ingest', position: { x: 40, y: 80 },
      size: { width: 140, height: 66 } },
    { id: 'b', label: 'Publish', position: { x: 240, y: 80 },
      size: { width: 140, height: 66 } },
  ];
}

export function seedEdges(): EdgeSpec[] {
  return [{ id: 'e1', source: 'a', target: 'b' }];
}

export function makePeer(room: string, actor: string) {
  return {
    transport: new BroadcastChannelTransport({ name: room, actor }),
    actor,
    presence: true,
  };
}

Each sample renders two side-by-side canvases with Ingest connected to Publish and an Undo Ana button. For separate tabs, use a shared document-specific room name instead of generating a new room in each tab, and generate a distinct actor id for each editor.

2. Mount the editors and keep actor-local undo

Pass the options through your framework's canvas component. The binding joins at mount and leaves at unmount. In JavaScript, join explicitly after mounting the instance.

Keep the session delivered by the binding's collaboration-ready event. Its SyncAdapter exposes the local Replica. Call replica.undo() for collaboration-aware undo: it reverses this actor's edit, not a later edit made by another actor. The Undo Ana button below uses that path.

render returns the mounted DiagramInstance. createSyncSession attaches collaboration to that instance's live model.

DiagramCanvasComponent uses two-way node and edge bindings and emits collabReady with the session.

For Qwik, give the two GrafloriaFlow components separate GrafloriaCollabOptions for the same room and retain Ana's session through onCollabReady$ for actor-local undo; see Documents and kits for browser-only setup and resumable state.

GrafloriaFlow accepts collab and calls onCollabReady. Keep the session in a ref and use uncontrolled defaults so the instance owns the graph.

GrafloriaFlow accepts :collab and emits collab-ready. Keep the session in a shallow ref.

ts
import { render } from '@grafloria/element';
import { createSyncSession } from '@grafloria/engine';
import { makePeer, seedNodes, seedEdges } from './shared';

export function mountEditors(container: HTMLElement): () => void {
  const room = 'editors-' + Math.random().toString(36).slice(2);
  const toolbar = document.createElement('div');
  const undo = document.createElement('button');
  undo.textContent = 'Undo Ana';
  toolbar.append(undo);
  const panes = document.createElement('div');
  panes.style.cssText = 'display:flex;gap:12px;height:400px';
  container.append(toolbar, panes);

  function mount(actor: string) {
    const host = document.createElement('div');
    host.style.cssText = 'flex:1;min-width:0;height:400px';
    host.setAttribute('aria-label', actor);
    panes.append(host);
    const instance = render({ nodes: seedNodes(), edges: seedEdges() }, host);
    const options = makePeer(room, actor);
    const session = createSyncSession(instance.getModel(), options.transport,
      { actor });
    session.join();
    return { instance, session, transport: options.transport };
  }

  const ana = mount('ana');
  const ben = mount('ben');
  undo.onclick = () => { ana.session.replica.undo(); };

  return () => {
    for (const peer of [ana, ben]) {
      peer.session.dispose();
      peer.session.replica.dispose();
      peer.transport.close();
      peer.instance.dispose();
    }
    toolbar.remove();
    panes.remove();
  };
}

const container = document.createElement('div');
document.body.append(container);
const unmountEditors = mountEditors(container);
const close = document.createElement('button');
close.textContent = 'Close editors';
close.onclick = () => { unmountEditors(); container.remove(); close.remove(); };
document.body.append(close);
The JavaScript sample shows two connected-node canvases, an Undo Ana button, and a Close editors button.

Angular renders the same pair through two canvas components.

The Angular sample shows Ingest connected to Publish in each canvas and an Undo Ana button.

Qwik mounts the pair after creating the transports in the browser.

React seeds each flow with its own node and edge arrays.

Vue renders the pair with uncontrolled defaults.

Drag Ingest in Ana's left canvas, then Publish in Ben's right canvas. Both canvases show both moves. Click Undo Ana: it reverses Ana's last captured position write on both peers, while Ben's Publish move stays. These samples do not group drag updates into a replica transaction, so one click does not undo the whole drag. Remote changes do not enter Ana's replica undo stack. If another actor has already superseded Ana's write to the same property, undo skips that write instead of restoring stale state.

For your own multi-mutation action, use session.replica.transact() to group its mutations into one local undo step. undo() and redo() return the operations they emit; those inverse edits travel through the same synchronization path. See Commands and history for the separate engine command API.

3. Handle conflicts and reconnects

The merge unit is a property path, not a whole node. A move and a rename of the same node survive together because position and label are separate registers. Two writes to the same register resolve by Lamport clock, then actor id as the tie-breaker—not by network arrival order. Try the Conflict resolution demo to stage a move and rename before exchanging edits.

Read transport.status for the current TransportStatus, and subscribe with onStatus() for changes. It reports connected or disconnected; it is not a document-convergence indicator. A WebSocket session's collaboration-ready callback follows join(), not necessarily the socket opening.

disconnect() deliberately drops a transport without destroying it. Edits still enter the live replica's log while disconnected. connect() reopens the channel; the session listens for the connected transition and exchanges missing operations automatically. Use close() only when you no longer need the transport. Reconnect catch-up needs another peer that still holds the history: it is not durable storage.

For a server-backed editor, replace each makePeer() call with options containing a WebSocket transport. Use the same room URL for both peers, with different actor ids:

ts
import { WebSocketTransport } from '@grafloria/engine';

export function makeSocketPeer(url: string, actor: string) {
  return {
    transport: new WebSocketTransport({ url }),
    actor,
    presence: true,
  };
}

Pass your relay's URL, such as wss://api.example.com/diagrams/ingest, as url. The relay broadcasts each received frame verbatim to every other socket in that room. Your product owns room membership, authentication and persistence; Grafloria does not ship that server.

An unexpected socket close retries automatically with exponential backoff. A deliberate disconnect() does not retry; call connect() to return. See the Offline and reconnect demo for edits made on both sides of a dropped connection.

Options that matter

OptionTypeDefaultWhat it does
collab.transportSyncTransportRequired when collaboration is enabledCarries messages and connection status.
collab.actorstringRequired when collaboration is enabledIdentifies this peer; keep it unique across peers.
collab.presenceboolean or BindPresenceOptionsNo binding when omittedtrue mounts live cursors and remote selection outlines with default settings.
Broadcast channel namestringRequiredNames the shared room; namespace it by document id.
WebSocket urlstringRequiredConnects to your relay.
WebSocket reconnectbooleanEnabled unless falseRetries unexpected closes.
WebSocket reconnectBaseMsnumber250Initial retry delay, reset after a successful open.
WebSocket reconnectMaxMsnumber10000Caps the doubling retry delay.

Pitfalls

  • Keep collab stable for the mounted canvas. To change rooms or actors, remount the editor; the binding attaches the session for the instance's lifetime.
  • Do not use the same actor id for two peers. Ordering depends on actor identity, and the broadcast transport filters messages from its own actor.
  • Start both peers from the same document. Creating the session captures subsequent edits; it does not turn independently seeded content into a shared initial operation history.
  • If you switch to controlled React data, wire the change-event return path described in the React quick start.
  • Do not confuse presence with saved document edits. Cursor and selection awareness travels separately and does not enter the operation log.

Was this page helpful?