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
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.
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().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:
<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".
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.
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:
.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 isposition: { x, y }(plussize: { width, height }) — there is no top-levelx/yon 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(). innerHTMLwith user data. The template path refuses to do this for you; do not undo that in a renderer.textContentfor 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/heightis rewritten by the engine every frame — style a child (.cardabove), not.grafloria-node-host's geometry.
Where next
- The element vs render() — which front door your custom nodes ride through, and everything each one offers.
- Dashboards in plain JavaScript — custom HTML nodes at scale, with update/repaint handles built in.
- Ports & validation — typed ports and connection rules on any node, custom or stock.