Plugins & extras
The editor’s surface stays small; richer behaviours land as plugins or opt-in features.
Auto-layout (@xenolithengine/graph-plugin-autolayout)
One-line arrangement for any graph. Pick an engine (Dagre for layered DAGs, ELK for nested / orthogonal), pass it in, call arrange():
import { autoLayoutPlugin } from '@xenolithengine/graph-plugin-autolayout'import { dagreEngine } from '@xenolithengine/graph-plugin-autolayout/dagre'
const layout = autoLayoutPlugin({ engine: dagreEngine(), defaults: { direction: 'LR', // 'LR' | 'TB' | 'RL' | 'BT' nodeSep: 80, rankSep: 120, animate: { durationMs: 600, easing: easeInOutCubic }, },})editor.use(layout)
// Later:await layout.arrange() // uses defaultsawait layout.arrange({ direction: 'TB' }) // override per-callELK adapter for richer layout (orthogonal edges, nested containers):
import { elkEngine } from '@xenolithengine/graph-plugin-autolayout/elk'
editor.use(autoLayoutPlugin({ engine: elkEngine(), defaults: { algorithm: 'layered' } }))Both adapters animate the move — the plugin tweens positions per frame via an ephemeral path that bypasses the command bus, then commits the final positions in one transaction (one undo entry).
Palette sidebar (editor.setPaletteSidebar)
Persistent left-side panel listing every registered schema. Drag a tile onto the canvas to spawn a node at the drop point:
editor.setPaletteSidebar(true) // mount with defaultseditor.setPaletteSidebar({ side: 'right' }) // dock on the righteditor.setPaletteSidebar({ filter: (s) => s.category !== 'io' }) // hide a categoryeditor.setPaletteSidebar(false) // unmount
// The default drop handler is wired automatically — drag a tile, drop on the canvas, the editor// calls `insertNode(schema.type, dropPosition)`. Listen to the same event to layer on validation,// snapping, or your own analytics:editor.on('node:drop', ({ nodeId, position, text }) => { // `text` holds the dragged schema's `type` (set by the panel into `dataTransfer.text/plain`).})A live demo: Palette sidebar example.
Per-node file drop
Drop files (or DataTransfer items) onto a node and react in your host code:
editor.on('node:drop', ({ nodeId, files, text, items, position }) => { if (!nodeId) return // dropped on empty canvas — handled by palette if (files.length) loadImageInto(nodeId, files[0])})The payload covers all three drop kinds: real File[], plain text, and every other DataTransfer type (text/html, text/uri-list, application/json, app-specific MIMEs — string types collected under items).
Live Mode
Hide editor chrome (palette, breadcrumb, controls) and lock interaction to viewing — perfect for “preview” / demo modes:
editor.setLiveMode(true)
editor.on('livemode:changed', ({ live }) => updateChrome(live))Edges, widgets, pan, zoom still respond; just no editing.
Pluggable edge paths
Per-edge path style — pick at create time or change later:
editor.addEdge( { id, from: { node: a, pin: 'out' }, to: { node: b, pin: 'in' } },)editor.setEdgeOptions(edgeId, { pathStyle: 'smoothstep', // 'bezier' (default) | 'smoothstep' | 'step' | 'linear' animated: true, // marching dashes label: 'fail', // text floating at the midpoint arrowHead: true, // arrowhead at the destination})
editor.setEdgeAnimated(edgeId, true)Type conversions
The type registry colours pins and validates compatibility. Two pins of different types refuse to connect — unless you register a conversion:
editor.types.register({ id: 'number', color: '#ff8a3c', shape: 'circle' })editor.types.register({ id: 'text', color: '#9aff8a', shape: 'circle' })
editor.types.registerConversion('number', 'text', (n) => String(n))// Now number → text wires connect; the cast fn is invoked at runtime when the host evaluates.
editor.types.hasConversion('number', 'text') // trueeditor.types.unregisterConversion('number', 'text')Dynamic node interfaces (NodeSchema.dynamic)
For variadic nodes — Sequence, Math (N-ary), Struct (one pin per declared field) — declare a dynamic callback. The editor invokes it after every setWidgetValue and applies any returned pins / widgets:
editor.registry.register({ type: 'Sequence', title: 'Sequence', pins: [{ kind: 'exec', direction: 'in', type: 'exec' }], widgets: [{ id: 'count', type: 'number', key: 'count', label: 'branches', min: 1, max: 16 }],
dynamic: (node) => { const n = (node.state.count as number) ?? 1 const branches: PinSchema[] = Array.from({ length: n }, (_, i) => ({ kind: 'exec', direction: 'out', type: 'exec', label: `then ${i + 1}`, })) return { pins: [{ kind: 'exec', direction: 'in', type: 'exec' }, ...branches], } },})Returning undefined from a field means “leave it alone”. The fn is pure — never mutate the node directly.
Headless tick loop
Some hosts need a per-frame callback (runtime evaluators, animated previews). Subscribe to onTick and let Xenolith drive it:
const offTick = editor.onTick((dtMs) => host.advance(dtMs))
editor.startLoop({ fps: 30 }) // start internal RAF loop, throttled to ~30 fpseditor.stopLoop()editor.step() // one tick manually (useful for tests)When the editor is idle (no animation, no playback) startLoop can stay off — onTick listeners only fire when a tick actually runs.
Plugin contract
Custom plugins implement install(ctx) → dispose and receive a PluginContext mirroring the editor’s writable surface (commands, types, graph, requestRender, setNodePositionEphemeral, setWidgetValue, …).
const myPlugin: XenolithPlugin = { name: 'my-plugin', install(ctx) { const off = ctx.on('node:added', ({ node }) => log(node)) return () => off() },}editor.use(myPlugin)