Skip to content
D
Documentation

Configure editing gestures

how-to
6 min readUpdated

Use interaction settings to choose how users connect nodes, reconnect endpoints and bend wires without writing pointer handlers. In the React example pictured below, A connects to B and C sits below B: reconnect the wire to C, switch to bend editing, or copy and duplicate a selected node. The engine owns these edits; framework bindings expose the mounted canvas and its change events.

1. Define the editing policy

In your browser application, create this shared file. The data uses the library's NodeSpec and EdgeSpec types. The policy checks its fields against InteractionConfig.

DiagramEngine exposes the commands used by the buttons. copy(), paste() and duplicate() return Promise<void>, not new specs. Read the edited graph through the mounted canvas, or use the binding's change events.

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

export const nodes: NodeSpec[] = [
  { id: 'a', label: 'A', position: { x: 80, y: 160 }, size: { width: 120, height: 60 } },
  { id: 'b', label: 'B', position: { x: 480, y: 80 }, size: { width: 120, height: 60 } },
  { id: 'c', label: 'C', position: { x: 480, y: 300 }, size: { width: 120, height: 60 } },
];

export const edges: EdgeSpec[] = [
  { id: 'ab', source: 'a', target: 'b', sourceHandle: 'right',
    targetHandle: 'left', type: 'direct' },
];

export const interaction = {
  dragThreshold: 8,
  enableLinkReconnection: true,
  showLinkEndpointHandles: true,
  enableWaypointEditing: false,
  showWaypointHandles: true,
  enableHelperLines: true,
  enableKeyboardNudge: true,
} satisfies Partial<InteractionConfig>;

export const actions = ['Reconnect', 'Edit bends', 'Copy', 'Paste', 'Duplicate', 'Lock / unlock'] as const;
export type EditingAction = typeof actions[number];

export async function act(engine: DiagramEngine, action: EditingAction): Promise<void> {
  switch (action) {
    case 'Reconnect':
      engine.setInteractionConfig({ enableWaypointEditing: false });
      break;
    case 'Edit bends':
      engine.setInteractionConfig({ enableWaypointEditing: true, showWaypointHandles: true });
      break;
    case 'Copy':
      if (engine.getDiagram()?.getSelectedNodes().length) {
        await engine.copy();
      }
      break;
    case 'Paste':
      if (engine.hasClipboardData()) {
        await engine.paste({ offset: { x: 30, y: 30 }, selectPasted: true });
      }
      break;
    case 'Duplicate':
      if (engine.getDiagram()?.getSelectedNodes().length) {
        await engine.duplicate({ offset: { x: 30, y: 30 }, selectDuplicated: true });
      }
      break;
    case 'Lock / unlock': {
      const diagram = engine.getDiagram();
      if (diagram) diagram.setReadonly(!diagram.isReadonly());
      break;
    }
  }
}

The initial policy enables alignment helper lines and arrow-key nudging. Reconnection starts enabled; bend editing starts disabled. Copy a selected node, then paste to add and select an offset copy. Copy does nothing in this toolbar when no node is selected; the engine's copy command requires a selected node. Duplicate performs the copy-like operation without requiring an earlier Copy click. The clipboard belongs to the engine.

2. Mount it in your framework

Choose one implementation below. Each uses the same shared file and displays the same six controls. Select a node before Copy or Duplicate. The selection count shows the current selection, not keyboard focus.

JavaScript / TypeScript

Start with the mounted canvas in JavaScript quick start, then use the shared editing policy above.

React

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

This GrafloriaFlow example adds the shared interaction policy, an 8 px dragThreshold and editing actions on the mounted DiagramInstance; see Validate port connections for initialization and selection-callback wiring.

tsx
import { useRef, useState } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, interaction, actions, act, type EditingAction } from './editing';

export default function Editor() {
  const instance = useRef<DiagramInstance | null>(null);
  const [selected, setSelected] = useState(0);
  async function run(action: EditingAction) {
    const current = instance.current;
    if (!current) return;
    await act(current.getEngine(), action);
    current.renderNow();
  }
  return <>
    <div>{actions.map(action => <button key={action} onClick={() => void run(action)}>{action}</button>)}</div>
    <p aria-live="polite">Selected: {selected}</p>
    <div style={{ height: 440 }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
        interaction={interaction} dragThreshold={8}
        onInit={current => { instance.current = current; }}
        onSelectionChange={change => setSelected(change.nodes.length + change.edges.length)} />
    </div>
  </>;
}
React displays A connected to B, C below B, six editing controls and an initial selection count of zero.

Vue

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

Render GrafloriaFlow, retain its instance with @init, and read selection through @selection-change.

Known issue: Vue has no dragThreshold prop, and the shared DOM binder captures its threshold at creation instead of reading the engine's live setting. The intended interaction: { dragThreshold: 8 } does not change Vue's mouse threshold. Until the binding forwards it, keep the default 4 px threshold in Vue; the other settings below still apply.

vue
<script setup lang="ts">
import { shallowRef, ref } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, interaction, actions, act, type EditingAction } from './editing';

const instance = shallowRef<DiagramInstance | null>(null);
const selected = ref(0);
function init(current: DiagramInstance) {
  instance.value = current;
  current.getEngine().setInteractionConfig({ ...interaction, dragThreshold: 4 });
}
async function run(action: EditingAction) {
  const current = instance.value;
  if (!current) return;
  await act(current.getEngine(), action);
  current.renderNow();
}
</script>

<template>
  <div><button v-for="action in actions" :key="action" @click="run(action)">{{ action }}</button></div>
  <p aria-live="polite">Selected: {{ selected }}</p>
  <div style="height: 440px">
    <GrafloriaFlow :default-nodes="nodes" :default-edges="edges"
      :interaction="{ ...interaction, dragThreshold: 4 }" @init="init"
      @selection-change="change => selected = change.nodes.length + change.edges.length" />
  </div>
</template>

Vue renders the same initial diagram and controls pictured in the React section.

Qwik

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

Pass the shared editing policy and an 8 px dragThreshold to GrafloriaFlow; see Validate port connections for the binding's initialization and selection events. Keep the live instance out of serialized state with noSerialize().

tsx
import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, interaction, actions, act } from './editing';

export default component$(() => {
  const instance = useSignal<NoSerialize<DiagramInstance>>();
  const selected = useSignal(0);

  return <>
    <div>{actions.map(action => (
      <button key={action} onClick$={async () => {
        const current = instance.value;
        if (!current) return;
        await act(current.getEngine(), action);
        current.renderNow();
      }}>{action}</button>
    ))}</div>
    <p aria-live="polite">Selected: {selected.value}</p>
    <div style={{ height: '440px' }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
        interaction={interaction} dragThreshold={8}
        onInit$={current => { instance.value = noSerialize(current); }}
        onSelectionChange$={change => {
          selected.value = change.nodes.length + change.edges.length;
        }} />
    </div>
  </>;
});

Qwik renders the same initial diagram and six controls pictured in the React section. Use those controls to switch between reconnection and bend editing, copy, paste, duplicate or toggle the document lock.

Angular

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

Render DiagramCanvasComponent with two-way specs. Read its mounted engine through activeEngine(). Angular exposes snapping and keyboard navigation as inputs; enable them directly. Use the SelectionChange output payload to update the count.

ts
import { AfterViewInit, Component, signal, viewChild } from '@angular/core';
import { DiagramCanvasComponent, type SelectionChange } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { nodes, edges, interaction, actions, act, type EditingAction } from './editing';

@Component({
  selector: 'app-editor',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <div>@for (action of actions; track action) {
      <button (click)="run(action)">{{ action }}</button>
    }</div>
    <p aria-live="polite">Selected: {{ selected() }}</p>
    <grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
      [enableSnapping]="true" [enableKeyboardNavigation]="true"
      [enableProximityConnect]="false" (selectionChange)="selectionChanged($event)"
      style="display: block; height: 440px" />
  `,
})
export class EditorComponent implements AfterViewInit {
  readonly canvas = viewChild.required(DiagramCanvasComponent);
  readonly actions = actions;
  readonly selected = signal(0);
  nodes: NodeSpec[] = nodes;
  edges: EdgeSpec[] = edges;
  ngAfterViewInit(): void {
    this.canvas().activeEngine()?.setInteractionConfig(interaction);
  }
  selectionChanged(change: SelectionChange): void {
    this.selected.set(change.nodes.length + change.edges.length);
  }
  async run(action: EditingAction): Promise<void> {
    const engine = this.canvas().activeEngine();
    if (!engine) return;
    await act(engine, action);
    this.canvas().scheduleRender();
  }
}

Angular renders the same initial diagram and controls pictured in the React section.

3. Try connections, reconnections and bends

  1. Hover a node to reveal its ports. Drag a port to a compatible target port to create a connection.
  2. Click the A → B wire to select it. Drag its endpoint handle at B onto C. The existing link now connects A to C.
  3. Click Edit bends. Click the selected wire's body to insert a waypoint, then drag that waypoint. The route bends through the point while its endpoints stay attached.
  4. Click Reconnect before dragging an endpoint again.

Waypoints are model constraints, not independent painted handles. They persist with the edge and participate in undo; see Route and label edges for routing around those constraints.

Known issue: With waypoint editing enabled, a path hit can insert a waypoint before the endpoint-reconnection branch runs. The intended combined policy is setInteractionConfig({ enableWaypointEditing: true, enableLinkReconnection: true }); until the hit-order conflict is fixed, disable waypoint editing while reconnecting. The two mode buttons above do that without rebuilding the canvas.

Options that matter

Defaults below are engine defaults unless a surface is named explicitly.

OptionTypeDefaultWhat it does
dragThresholdnumber4Separates a pointer click from a drag in screen pixels. JavaScript, React and Qwik also expose a creation-level option/prop.
enableLinkReconnectionbooleantrueAllows moving an existing link endpoint.
showLinkEndpointHandlesbooleantrueDisplays endpoint handles on selected links.
enableWaypointEditingbooleanfalseEnables inserting, moving and removing bends.
showWaypointHandlesbooleantrueDisplays handles on selected links. It does not enable editing by itself.
snapToPortRadiusnumber30Sets the connection's port-snap radius.
enableHelperLinesbooleanfalseSnaps a single top-level node to sibling alignments/equal spacing and displays dashed guides during dragging.
enableKeyboardNudgebooleanfalseEnables selected-node nudging in the shared DOM binding.
enableSnapping (Angular)booleantrueEnables the canvas's snapping layer.
enableKeyboardNavigation (Angular)booleantrueEnables focus navigation, nudging, keyboard connection and announcements.
readonly (JavaScript/React/Vue/Qwik)booleanfalse in the DOM binderPrevents editing gestures while retaining viewing interaction.

For grid snapping, set waypointEditor.snapToGrid and waypointEditor.gridSize in the interaction policy. The shipped snapping layer reads the same grid settings as the waypoint editor. Preserve the other editor fields when changing this nested object: setInteractionConfig() merges only the top level. Use getInteractionConfig().waypointEditor as the starting point. Angular additionally exposes canvasBounds for keeping dragged boxes inside a rectangle.

Keyboard, touch and read-only operation

In the React and Vue examples, click a node, then press an arrow to nudge it by one world unit; Shift increases the step to ten. The policy explicitly enables nudging: it is not enabled by the engine default. Ctrl/⌘+A selects all; Ctrl/⌘+C copies, Ctrl/⌘+V pastes, and Ctrl/⌘+D duplicates selected nodes. Ctrl/⌘+Z undoes, and Ctrl/⌘+Shift+Z or Ctrl/⌘+Y redoes. Text-entry targets keep their keys.

Angular's enabled keyboard navigation also draws a focus ring. Tab and Shift+Tab walk nodes and links; arrows nudge a selection or move focus spatially when nothing is selected. On a focused node, C starts keyboard connection, arrows choose a port, Tab chooses a target and Enter commits. Escape cancels that keyboard connection. Focus is distinct from selection.

Touch uses the shipped gesture handling: one finger taps to select, drags a node to move it, drags from a port to connect, or drags empty canvas to pan. Two fingers pan and pinch to zoom. A 500 ms long press emits a context-menu event; it does not create your menu UI. The binding sets touch-action: none; do not override it with native browser scrolling on the canvas. Touch uses its own 10 px movement tolerance, rather than the mouse's dragThreshold.

Lock / unlock toggles the live document's setReadonly() state. Selection and viewing remain available, but document writes are refused. For a canvas that starts view-only, pass readonly: true to render() or readonly to the React, Vue or Qwik component. Angular has no readonly input on this canvas: set the mounted document lock through its engine, as the shared action does. Keep viewing controls separate with enablePan and the surface's zoom switch (enableZoom, or Angular's enableMouseWheelZoom).

Use Delete or Backspace to remove the selection through the canvas's keyboard handler. Commands and history explains the undo stack those edits join.

Known issue: engine.deleteSelection() checks the store's selection sets, whereas a shared DOM-binding mouse selection lives on the diagram. The intended call can throw “No entities selected” after a visible selection. Until the helper synchronizes those sources, use the canvas's Delete/Backspace handler, which reads the diagram selection and executes removal commands. Angular's keyboard handler synchronizes selection before calling the helper.

Was this page helpful?

Configure editing gestures — Grafloria