Learn / Custom nodes in Vue

Custom nodes in Vue

A custom node is a named slot. Declaring #node-<type> is the whole opt-in — no flag, no registry call — and what renders inside is real Vue: components, event handlers, your stylesheet. This page is the full mechanics: how the opt-in works, what the slot receives, when it repaints, and how to reach the engine from inside a node.

The mechanic: a slot per node type

Give a node a type, declare a slot named after it, and the node's body becomes your template. It still hit-tests, drags, connects and exports like any other node — the engine keeps the geometry; you paint the interior.

App.vue
<script setup>
import { GrafloriaFlow } from '@grafloria/vue';

const nodes = [
  { id: 'a', type: 'card', position: { x: 80, y: 90 },  size: { width: 230, height: 110 },
    data: { title: 'Build', owner: 'CI', status: 'passing' } },
  { id: 'b', type: 'card', position: { x: 430, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Deploy', owner: 'CD', status: 'ready' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
</script>

<template>
  <div style="height: 100vh">
    <GrafloriaFlow :default-nodes="nodes" :default-edges="edges">
      <template #node-card="{ data }">
        <div class="card">
          <div class="title">{{ data.title }}</div>
          <div class="owner">owner: {{ data.owner }}</div>
          <span class="badge">{{ data.status }}</span>
        </div>
      </template>
    </GrafloriaFlow>
  </div>
</template>

Live: slot-defined custom nodes →

The opt-in rule, quoted from the wrapper source, because the details matter:

libs/vue/src/lib/grafloria-flow.ts
/**
 * Declaring `#node-<type>` IS the opt-in (the same DX as the Angular
 * wrapper): specs whose type has an exact slot are flagged `custom`
 * automatically. Explicit `custom` always wins; the wildcard `#node` slot
 * renders already-custom nodes but does not flag anything itself.
 */

Three consequences worth reading twice:

  • A node whose type has an exact slot renders through it — nothing else to write.
  • #node (no type) is the wildcard: it catches custom nodes that have no exact slot, but it never opts a node in by itself. To route a node through the wildcard, set custom: true on its spec.
  • An explicit custom on the spec wins in both directions — custom: false keeps a node in SVG even when a matching slot exists.

What the slot receives

Slot context is { node, data, engine }:

  • node — the live NodeModel, not a copy. Its setters (setMetadata, setSize, setPosition) write straight into the model.
  • data — the node's data payload ({} when the spec declared none), passed through untouched.
  • engine — the DiagramEngine (the same object instance.getEngine() returns): commands, undo/redo, layout, interaction config.

Reactivity, and how repaints actually work

Slot content is mounted with Vue's low-level render() into the host element the engine provides in its HTML layer. That makes it real Vue — components mount with their own state and lifecycle, and event handlers are real DOM listeners. But the repaint schedule is the engine's, not Vue's, so it pays to know when the wrapper re-invokes your slot:

  • On mount — once per custom node, when the engine creates its host.
  • On every nodes:change — which the instance emits on node add and remove only. In-place changes (node:changed) repaint the SVG canvas but do not re-run slots.
The trap this implies: mutating node.data — or updating a controlled v-model:nodes array in place — does not repaint slot content, because no node was added or removed. A plain interpolation of page-level state inside a slot refreshes only when a repaint happens to run. For content that must stay live, put a component in the slot and let it read from your own reactive source (a store, a reactive map keyed by node.id): a mounted component re-renders itself, on its own schedule, regardless of the canvas.
<template #node-metric="{ node }">
  <!-- MetricCard subscribes to your store by id; it stays live on its own -->
  <MetricCard :metric-id="node.id" style="height: 100%" />
</template>

Styling and sizing

The wrapper renders your slot inside a width:100%; height:100% host, and the host is sized to the node's box — so the one rule is: give your slot root height: 100% (and box-sizing: border-box if it has padding), or the card will not fill the node it lives in. The node's footprint itself comes from the spec's size, not from your CSS.

Custom nodes render in light DOM, so your app stylesheet — including <style scoped> in the SFC that declares the slot — cascades in directly. No stylesheet import, no shadow-DOM piercing.

The canvas itself fills its container, and 100% of zero is a blank page — the component's own root is width:100%; height:100%, so the element you mount <GrafloriaFlow> into must have a real height.

Ports on custom nodes

Ports belong to the spec, not to the painter — how the body renders and where edges attach are orthogonal. A custom node you declare no ports for keeps the default four (one per side, deterministic ids <nodeId>__<side>); declare ports to replace them:

const nodes = [
  { id: 'src', type: 'card', position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Extract' },
    ports: [
      { id: 'out',  side: 'right', type: 'output', dataType: 'number' },
      { id: 'ctl',  side: 'top',   type: 'input' },
    ] },
];
<GrafloriaFlow :default-nodes="nodes" :interaction="{ portVisibility: 'always' }">
  <template #node-card="{ data }"> ... </template>
</GrafloriaFlow>

Typed ports, connection validators and port gating all work unchanged on custom nodes — see Ports & validation. One caveat carries over: connection validators are process-global, so dispose them in onBeforeUnmount.

Live: typed ports →

Reaching the model and engine from inside a node

Because the slot gets the live NodeModel and the engine, a node can carry its own controls. Deletion is one call — it runs through the command layer, so it is a single undoable step, and the removal fires nodes:change, which repaints the remaining slots and (in controlled mode) emits the updated specs:

<template #node-card="{ node, data, engine }">
  <div class="card">
    <strong>{{ data.title }}</strong>
    <button class="close" @click="engine.removeNode(node.id)">✕</button>
  </div>
</template>
Undo and redo live on the engine, not the instance: instance.getEngine().undo(). There is no instance.undo() — reaching for one fails silently in an ?. chain, which makes it a long-lived bug rather than a loud one.

Live: undo/redo from your own buttons →

Common mistakes

  • No height on the wrapper. The canvas fills its parent; an unsized parent renders nothing.
  • No height: 100% on the slot root. The card floats at the top of an invisible node box.
  • Expecting #node to opt nodes in. The wildcard only renders nodes that are already custom — add custom: true to the spec.
  • Expecting a data mutation to repaint the slot. Slots repaint on add/remove; live content belongs in a component with its own reactive source.
  • instance.undo(). It does not exist; go through getEngine().
  • Doing shape work in a slot that the spec already does. For a styled rectangle, terminal or document shape, shape: { type, fill, stroke } on the spec is cheaper than a custom node — see the shape-based demo for the contrast.

Where next