Skip to content
D
Documentation

Add canvas tools and controls

how-to
2 min readUpdated

Use the mounted DiagramInstance to add editor chrome, then register a tool against that same live canvas. The result is a diagram with a dotted background, minimap, zoom/fit controls, and a rotation gesture that runs through the canvas interaction lifecycle.

Before you start

Install the packages for the JavaScript entry point:

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

Give the canvas a height. A mounted diagram without a sized container has no drawing area.

1. Mount the minimap and controls

Call render with data, then pass its returned instance to attachCanvasPlugins. The plugins mount into the diagram: the minimap mirrors the nodes and camera, the controls provide zoom and fit actions, and the background draws dots.

The JavaScript sample renders the diagram with its minimap, dotted background, and viewport controls.
The mounted canvas shows nodes, a dotted background, minimap, and viewport controls.
js
import { render } from '@grafloria/element';
import { attachCanvasPlugins } from '@grafloria/renderer';

const host = document.getElementById('canvas');
if (!host) throw new Error('Missing #canvas');
host.style.height = '400px';

const instance = render({
  nodes: [
    { id: 'a', position: { x: 60, y: 80 }, size: { width: 120, height: 60 }, data: { label: 'A' } },
    { id: 'b', position: { x: 300, y: 80 }, size: { width: 120, height: 60 }, data: { label: 'B' } },
  ],
  edges: [{ id: 'ab', source: 'a', target: 'b' }],
}, host);

const plugins = attachCanvasPlugins(instance, {
  background: { variant: 'dots' },
  minimap: true,
  controls: true,
});

instance.renderNow();

function disposeCanvas() {
  plugins.dispose();
  instance.dispose();
}

The JavaScript call returns a CanvasPlugins object. Its dispose function removes the background, minimap, controls, and their listeners. Framework bindings mount the same plugins with the plugins prop and clean them up with the component.

The shipped demo is Minimap & controls, with source.

Plugin options

OptionTypeDefaultWhat it does
backgroundboolean | BackgroundOptionsoffMounts the background; true uses defaults.
minimapboolean | MiniMapOptionsoffMounts the minimap; its visibility also follows the store's showMinimap.
controlsboolean | ControlsOptionsoffMounts the zoom, fit, and lock toolbar.
bindToStorebooleantrueKeeps gridEnabled and showMinimap synchronized with the mounted components.

2. Register a tool on the live instance

Use registerTool with a CanvasTool. The sample uses NodeModel for the live node. hitTest claims the pointer gesture; once claimed, that tool receives the gesture's move, up, and cancel lifecycle. This example rotates a node around its centre and gives the tool an explicit priority above the built-in ladder.

ts
import { render } from '@grafloria/element';
import { registerTool, type CanvasTool, type NodeModel } from '@grafloria/renderer';

const host = document.getElementById('canvas');
if (!host) throw new Error('Missing #canvas');
host.style.height = '400px';

const instance = render({
  nodes: [{ id: 'turn', position: { x: 160, y: 120 }, size: { width: 180, height: 80 }, data: { label: 'Turn me' } }],
  edges: [],
}, host);

let grab: { node: NodeModel; x: number; y: number; startAngle: number; startRotation: number } | null = null;
const rotateTool: CanvasTool = {
  id: 'example-rotate',
  priority: 10,
  hitTest: (_event, hit) => !!hit.node,
  onPointerDown: (event, hit) => {
    const node = hit.node;
    if (!node) return;
    const centre = { x: node.position.x + node.size.width / 2, y: node.position.y + node.size.height / 2 };
    grab = {
      node,
      x: centre.x,
      y: centre.y,
      startAngle: Math.atan2(event.world.y - centre.y, event.world.x - centre.x),
      startRotation: node.rotation,
    };
  },
  onPointerMove: (event) => {
    if (!grab) return;
    const angle = Math.atan2(event.world.y - grab.y, event.world.x - grab.x);
    grab.node.setRotation(grab.startRotation + (angle - grab.startAngle) * 180 / Math.PI);
    instance.renderNow();
  },
  onPointerUp: () => { grab = null; },
  onCancel: () => { grab = null; },
};

const removeTool = registerTool(rotateTool);
instance.renderNow();

function disposeCanvas() {
  removeTool();
  instance.dispose();
}
The mounted tool sample renders the node that the rotation gesture controls.

The mounted instance receives the pointer gesture, and the node rotates as you drag over it. The disposer restores a previous tool with the same id; otherwise it unregisters this tool. Set an explicit priority whenever tools can claim the same gesture: the highest claiming priority wins, while registration order is only a fallback.

For a framework binding, keep the instance supplied by its initialization callback and register the tool there. Store the returned Disposer in the component's teardown path rather than disposing it immediately after registration. The framework-specific mounted surfaces are:

  • Angular: use onInit/the live canvas instance and call the disposer in ngOnDestroy.
  • Qwik: use onInit$ on GrafloriaFlow and retain the disposer in the component's cleanup path.
  • React: use onInit and retain the disposer in a ref; call it from effect cleanup.
  • Vue: use @init and call the disposer when the component unmounts.

What you have

The JavaScript sample renders the diagram with its minimap, dotted background, and viewport controls.
The mounted tool sample renders the node that the rotation gesture controls.

The canvas now renders real diagram data with a live minimap, dotted background, and viewport controls. A custom tool participates in the mounted pointer lifecycle without replacing the renderer or writing a second event pipeline.

Was this page helpful?