Learn / Custom nodes in plain JavaScript

Custom nodes in plain JavaScript

A custom node is a box the engine drives and you fill. Two ways to fill it — a registered renderer function, or a <template> that needs no JavaScript at all — and one stylesheet: yours, because the canvas renders in light DOM.

The mental model

A node that opts in with custom: true does not render as SVG. The instance creates an absolutely-positioned host element in the HTML layer — <div class="grafloria-node-host" data-node-id="…"> — keeps its left/top/width/height glued to the model, and hands it to your renderer exactly once. Everything around the box stays the engine's job: selection, dragging, hit-testing, ports and wires are painted by the SVG layer as for any stock node.

The renderer function

main.js
import { Grafloria, render } from '@grafloria/element';

// (node: NodeModel, element: HTMLElement) => void — register (or replace) globally
Grafloria.registerNodeType('card', (node, el) => {
  el.innerHTML = `
    <div class="card">
      <h4>${node.getData('title')}</h4>
      <small>${node.id}</small>
    </div>`;
});

render({
  nodes: [{ id: 'a', type: 'card', custom: true,
    position: { x: 80, y: 90 }, size: { width: 230, height: 110 },
    data: { title: 'Build' } }],
}, '#canvas');

The renderer receives the live NodeModel: read the payload with node.getData('title') (spec data lands there key by key), plus node.id and node.getMetadata(…). The same registry serves both front doors — render() and <grafloria-flow> resolve types from it identically.

When does it run? Once, at mount. The source states the contract plainly: "renderCustomNode fires only when the element is created" — it is a mount hook, not a data binding. Dragging, panning, zooming and selection never re-run it (the engine moves the host, your DOM rides along), and neither does a later change to node.data. If content must update, you own that: keep a reference to the elements you created, or repaint from your own event handler. The dashboard kit wraps this pattern for you as widget.update() / widget.repaint().
Register before you render. A node whose type has no renderer and no template mounts an empty host — deliberately: "leave the host empty rather than throwing inside a render. The node still exists, is selectable and is draggable." Registering afterwards does not back-fill hosts that already mounted.

custom: true is the opt-in

Plain JavaScript is the surface where the flag is always explicit. It maps to the model's useHTMLLayer metadata — the renderer's signal to build an HTML host instead of an SVG body. Omit it and the node paints as a stock SVG node: your renderer is simply never consulted, with no warning, because a non-custom node of type 'card' is perfectly legal.

The zero-JavaScript path: <template data-node-type>

Slotted inside the element, a template makes a custom node with no build step and no script — the CMS / notebook / static-page path:

index.html
<grafloria-flow nodes='[{"id":"n1","type":"card","custom":true,
    "position":{"x":80,"y":90},"size":{"width":230,"height":110},
    "data":{"title":"Build"}}]'>
  <template data-node-type="card">
    <div class="card">
      <h4 data-field="title"></h4>
      <small data-field="id"></small>
    </div>
  </template>
</grafloria-flow>

The template is cloned per node, and every [data-field="key"] descendant is filled from node.data[key]. One special case: data-field="id" fills from node.id rather than the data bag. A missing or null value becomes an empty string, never the text "undefined".

Values are written with textContent, never innerHTML — in the source's words: "a diagram's data is frequently user-supplied, and a template engine that injected raw HTML here would be an XSS vector in every host that embeds us." The test suite pins this down: a data.title of <img src=x onerror=…> arrives on screen as literal text, not as an element. The same stance applies to your renderer functions — the engine hands you a raw host precisely so <svg> and <canvas> work, which means innerHTML with user-supplied data is your XSS to avoid. Interpolate untrusted values via textContent.

Precedence: a registered renderer wins over a template. The element checks the registry first and only then looks for a matching template — so a page can ship a template fallback and an app can override it later without touching the markup.

Live: custom node demos →

Styling: your stylesheet already applies

The element renders in light DOM on purpose. From the source: "the diagram's stylesheet is injected into <head> and its CSS variables cascade — a shadow root would cut both off, and hosts routinely want to style nodes from their own stylesheet." So a custom node is styled like any other markup on the page:

app.css
.card { height: 100%; border: 1px solid #d8dce6; border-radius: 9px;
  background: #fff; padding: 10px 12px; font-family: system-ui; }
.card h4 { margin: 0; font-size: 14px; }
.grafloria-node-host[data-node-id="n1"] .card { border-color: #3b52d9; }

See Theming for the theme object and the token bridge — theme = the room, your node CSS = the furniture.

Ports on custom nodes

Port machinery is untouched by custom: true. A spec-built node gets the four default bi-directional side ports with deterministic ids (<nodeId>__top, __right, __bottom, __left), or exactly the ports: [{ id, side, type, dataType }] you declare — port glyphs and wires paint in the SVG layer, above and around your HTML. A node that should not be wired (a dashboard tile, a legend) opts out on the model, which is exactly what the dashboard kit does for every widget:

const n = api.getModel().getNode('a');
n.setBehavior({ connectable: false });                  // refuses link ends, no hover glyphs
for (const p of [...n.getPorts().values()]) n.removePort(p.id);   // and says so in the model

Validation and typed ports work as everywhere else — see Ports & validation.

Common mistakes

  • Top-level x/y. Node geometry is position: { x, y } (plus size: { width, height }) — there is no top-level x/y on a node spec.
  • Forgetting custom: true. The renderer is never called and the node paints as a stock SVG node — silently.
  • Registering after mounting. Hosts that mounted while the type was unknown stay empty. Register first, then render().
  • innerHTML with user data. The template path refuses to do this for you; do not undo that in a renderer. textContent for anything a user typed.
  • Expecting a re-render on data change. Mount-once is the contract. Update the DOM you own, or use the dashboard kit's widget handles.
  • Styling the host itself. The host's inline left/top/width/height is rewritten by the engine every frame — style a child (.card above), not .grafloria-node-host's geometry.

Where next