Skip to content

Editor API & interactions

Everything below is a method on the editor returned by XenolithEditor.init(...).

Building a graph

// Add a node you constructed yourself.
editor.addNode(node, { category: 'logic', title: 'Source', collapsed: false })
// Connect an output pin to an input pin (indices into each node's `pins`).
editor.connect(source, 0, sink, 1, { sourceType: 'float' })

Most apps don’t hand-build nodes, though — they load a document.

Load & save (the xenolith.v1 format)

The editor speaks one canonical JSON format. loadJSON replaces the whole graph, selection, and viewport; toJSON round-trips it back out.

editor.loadJSON(graphDoc) // replace the scene from a xenolith.v1 document
const doc = editor.toJSON() // serialise nodes + edges + render opts + viewport

A document is { version: 'xenolith.v1', nodes: [...], edges: [...], viewport? }. Pins, sizes, collapsed state, category/title render options, and per-edge sourceType all serialise. This is the recommended way to seed a graph — author it as data, register the matching schemas, then load it:

for (const schema of schemas) editor.registry.register(schema) // so Tab can spawn them
editor.loadJSON(graphDoc)
editor.fitView()

Framing the view

fitView computes the bounding box of every node and frames it in the canvas — the reliable way to reveal a freshly loaded graph, however large.

editor.fitView() // 64px padding, never zooms past 1×
editor.fitView({ padding: 80, maxZoom: 1 }) // more margin
editor.resetView() // back to identity
OptionDefaultMeaning
padding64Screen-space margin kept clear on each side
maxZoom1Never zoom in past this (keeps small graphs from filling the screen)
minZoomthe editor’s zoom floorNever zoom out past this

Inserting nodes & reroutes

// Spawn a registered schema at a world position (undoable, selects it).
editor.insertNode('Transform', { x: 320, y: 200 })
editor.insertNode('Transform', midpoint, { center: true }) // centre on the point
// Split an edge with an inline reroute knot at a world point.
editor.insertRerouteOnEdge(edgeId, worldPos)
// Disconnect an edge — preventable through the `edge:disconnecting` event (no cleanup of dangling
// inline reroutes; use `deleteEdge` for that destructive variant).
editor.disconnectEdge(edgeId)
// Hard-delete one edge; dangling inline reroutes are removed too.
editor.deleteEdge(edgeId)

Two kinds of reroute

  • Inline knot ($reroute) — created by splitting an edge. A non-pullable dot that carries the wire through and takes the wire’s type colour. It can’t exist on its own: deleting its last connection removes it.
  • Reroute node (Reroute) — a movable rectangular relay you can pull fresh wires from. It’s a built-in schema, so it always appears in the insert palette.

Interactions out of the box

GestureAction
Tab / double-click empty canvasOpen the insert palette (fuzzy search; built-in nodes rank first)
Right-click an edge’s midpoint dotContext menu: Add Node (filtered to compatible types) · Add Reroute · Delete
Drag from a pinLive ghost edge with type-compatibility validation
Alt+drag a connected pinTear the edge off and re-wire it
Drag node / marqueeMove (8px snap, Alt to disable) / box-select
Delete · ⌘Z / ⌘⇧Z · ⌘C/⌘V · ⌘D · ⌘ADelete · undo/redo · copy/paste · duplicate · select-all

Busy overlay

Heavy first renders (large graphs) can be wrapped in a themeable blur-and-spinner overlay. It paints first, runs your work behind the blur, then fades out — so big graphs reveal smoothly instead of freezing then popping in. It’s styled by the active theme’s paletteStyle.

await editor.withOverlay('Rendering…', () => {
editor.loadJSON(bigGraph)
editor.fitView()
})
// Or drive it manually:
editor.showOverlay('Loading…')
editor.hideOverlay()

Widgets

Nodes can carry in-node controls. A widget is declarative data on the node (or its schema); its value lives in node.state[key], so it serialises with the graph and every change is undoable.

const node = {
id: 'n1', type: 'Sampler', position: { x: 0, y: 0 }, state: {}, pins: [],
widgets: [
{ id: 'steps', type: 'number', label: 'Steps', key: 'steps', min: 1, max: 150, step: 1 },
{ id: 'cfg', type: 'slider', label: 'CFG', key: 'cfg', min: 0, max: 20, step: 0.1 },
{ id: 'sampler',type: 'combo', label: 'Sampler', key: 'sampler', values: ['euler', 'dpm++', 'ddim'] },
{ id: 'prompt', type: 'text', label: 'Prompt', key: 'prompt', multiline: true },
{ id: 'hires', type: 'toggle', label: 'Hi-res', key: 'hires' },
{ id: 'tint', type: 'color', label: 'Tint', key: 'tint' },
{ id: 'run', type: 'button', label: 'Run', action: 'run' },
],
}

Built-in types: number (drag-scrub + click-to-type), slider, combo (dropdown), text (multiline), toggle, color (picker popover), button (fires an action).

editor.getWidgetValue(nodeId, 'steps')
editor.setWidgetValue(nodeId, 'steps', 30) // clamped, undoable
editor.on('widget:action', ({ nodeId, action }) => { /* button pressed */ })

Every widget is themeable globally via color.widget / geometry.widget tokens, and per-instance via a style override:

{ id: 'gain', type: 'slider', label: 'Gain', key: 'gain', min: 0, max: 1,
style: { fill: '#FF3366', radius: 10, borderFocused: '#FF3366' } }

Custom widgets

Register a controller, then reference it from a custom widget’s renderer:

{ id: 'curve', type: 'custom', renderer: 'curve', key: 'curve', height: 120 }

Two controller kinds:

// Canvas-draw (fast — painted to a WebGL texture, no DOM):
editor.registerWidget('curve', {
draw(ctx, { value, width, height }) { /* 2D-canvas drawing */ },
onPointer(phase, x, y, { value, width, height }) { return newValue },
})
// DOM-mounted (arbitrary HTML — the contract React / Vue / Svelte adapters wrap):
editor.registerWidget('chart', {
mount(el, { value, setValue }) { /* render a component into el */; return () => { /* cleanup */ } },
update({ value }) { /* re-render on external change */ },
})

Canvas-draw is the perf-friendly default; the editor keeps DOM-mounted widgets glued to their node’s on-screen rect through pan/zoom/drag.

ComfyUI import

importComfyWorkflow maps each node’s positional widgets_values to typed widgets by value (number→number, boolean→toggle, string→text). Names/combos/ranges require the server’s object_info and are a later upgrade.

Plugins

editor.use(plugin) installs a plugin. A plugin is { name, install(ctx) }; install runs once and may return a disposer that fires on editor.destroy(). Plugins are how the editor stays extensible without baking features in — node packs, runtimes, type systems, custom widgets, and validators all enter through this surface.

editor.use({
name: 'my-plugin',
install(ctx) {
ctx.registry.register({ type: 'Custom', title: 'Custom', pins: [/* ... */] })
ctx.types.register({ id: 'vec3', color: '#a0f', shape: 'diamond', compatibleWith: ['vec2'] })
ctx.icons.register('star', '<svg>...</svg>')
ctx.setIsValidConnection((from, to) => from.node.type !== 'Frozen')
ctx.registerWidget('myKnob', { draw(/* ... */) {}, onPointer(/* ... */) {} })
const off = ctx.on('node:added', e => console.log('added', e.id))
return () => off()
},
})

PluginContext exposes:

FieldPurpose
registryNode schema registry (ctx.registry.register(schema))
typesTypeRegistry for custom pin types (id/color/shape/compatibleWith)
iconsGlyph registry — built-in Feather set + register(name, svg)
commandBusBatch mutations as one transaction (use sparingly)
graphRead-only view of the graph
appThe PIXI Application — escape hatch for custom rendering only; touching the scene graph here is unsupported
registerWidgetRegister canvas-draw or DOM-mount custom widget controllers
setIsValidConnectionInstall a global connection validator (runs after built-in type-compat)
onFull event-bus access (see Events)

Runtime delegation (for execution plugins — none ship in the editor itself):

MethodPurpose
onTick(cb)Subscribe to per-frame ticks
startLoop() / stopLoop() / step()Drive the tick loop or step one frame
setWidgetValue(nodeId, key, value, { ephemeral: true })Non-undoable widget write for sims
setNodePins(nodeId, pins)Variadic pins (Sequence / MakeArray)
setEdgeAnimated(edgeId, on)Toggle the marching-ants pulse
expandTemplateInstance(nodeId)Read-only flatten of a template instance
graphSnapshot({ expandMacros, flattenReroutes })Snapshot for execution traversal

Custom types

TypeRegistry defines pin types — colour, shape, and which other types they auto-cast from. Type drives wire colour, pin fill, and connection validity.

editor.types.register({ id: 'float', color: '#36f', shape: 'circle', compatibleWith: ['int'] })
editor.types.register({ id: 'exec', color: '#fff', shape: 'arrow' })
editor.types.register({ id: 'struct:Vec3', color: '#a0f', shape: 'diamond' })

Pin shapes: circle (data), arrow (exec), diamond (struct/object). compatibleWith enables auto-cast between related types (e.g. intfloat).

Custom validation

setIsValidConnection installs a predicate that runs after built-in type-compat. Return false to refuse the connection.

editor.setIsValidConnection((from, to) => {
return !(from.node.type === 'A' && to.node.type === 'B')
})

Header glyphs

A glyph is a small SVG icon rendered on the node header beside the title. The built-in Feather set is preloaded; register more by name.

editor.icons.register('zap', '<svg viewBox="0 0 24 24"><path d="..."/></svg>')
editor.registry.register({
type: 'Trigger',
title: 'Trigger',
glyph: { icon: 'zap', side: 'left' },
pins: [{ kind: 'exec', direction: 'out' }],
})
// Per-node override (without mutating the schema):
editor.setNodeGlyph(nodeId, { icon: 'star', side: 'right' })
editor.setNodeGlyph(nodeId, null) // clear override

A per-node glyph override round-trips through xenolith.v1.

Events

editor.on(event, handler) returns an Unsubscribe. Events are bridged off the command bus, so they fire on undo/redo too — a UI bound to them stays in sync without extra wiring around mutation calls.

const off = editor.on('node:added', ({ node }) => {})
editor.on('node:removed', ({ nodeId }) => {})
editor.on('node:moved', ({ nodeId, position }) => {})
editor.on('edge:connected', ({ edge }) => {})
editor.on('edge:disconnected', ({ edgeId }) => {})
editor.on('selection:changed', ({ nodeIds }) => {})
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {})
editor.on('viewport:changed', ({ x, y, zoom }) => {})
editor.on('dive:changed', ({ depth, definitionId }) => {}) // template dive in/out
off()

Pre-mutation events (node:removing, edge:connecting, edge:disconnecting, node:clicking) accept payload.cancel() to veto.

Animated edges

Toggles a flowing marching-ants pulse along an edge. Round-trips through xenolith.v1.

editor.setEdgeAnimated(edgeId, true)
editor.setEdgeAnimated(edgeId, false)

Use sparingly — animated edges break render-on-demand for as long as they’re on-screen.

Variadic pins

setNodePins replaces a node’s pin list. Use this for nodes whose pin count depends on state — Sequence (exec out 1..N), MakeArray (data in 1..N), and similar. Plugins typically call this from a widget:changed handler.

editor.setNodePins(nodeId, [
{ kind: 'exec', direction: 'in', label: 'in' },
{ kind: 'exec', direction: 'out', label: 'Then 1' },
{ kind: 'exec', direction: 'out', label: 'Then 2' },
])

Edges connected to removed pins are pruned automatically.

Comments

A Comment is a labelled rectangle behind nodes — a spatial group, not data containment. Move it, resize it from the corner, double-click the header to rename.

const id = editor.addComment({
position: { x: 100, y: 100 },
size: { x: 400, y: 300 },
text: 'IO',
color: '#36c',
})
editor.setCommentText(id, 'Inputs')
editor.setCommentColor(id, '#f93')
editor.removeComment(id)

Inline rename: double-click the comment header.

Shortcuts: TabComment, or double-click an empty area and pick Comment from the palette.

Macros (Groups)

A Macro packs N selected nodes into one collapsible wrapper with proxy pins on its border. Inline — there’s no shared definition, no propagation.

const macroId = editor.createMacroFromSelection()
const macroId = editor.createMacroFromSelection([id1, id2, id3], 'Backup')
editor.collapseMacro(macroId)
editor.expandMacro(macroId)
editor.ungroupMacro(macroId) // dissolve; members go back to the root graph

Shortcut: Cmd+G groups the current selection.

Live templates

A Template is a sub-graph with a stored definition. You can spawn many instances of one definition; editing the definition (via dive-in) propagates to every instance. Boundary nodes $templateInput / $templateOutput inside the definition form the pin interface — each holds one pin, rename it to set the pin label.

const instanceId = editor.createTemplateFromSelection()
const instanceId = editor.createTemplateFromSelection([id1, id2], 'My Template')
editor.diveInto(instanceId) // enter the definition (breadcrumb tracks the path)
editor.diveOut() // back to the parent graph
editor.renameTemplate(defId, 'New name')
// Drop the shared link, get an editable Macro:
editor.unpackTemplateInstance(instanceId)
// Conversion both ways:
editor.convertTemplateInstanceToMacro(instanceId)
editor.convertMacroToTemplate(macroId) // nested macros carry into the def

The palette lists every definition as an insertable node. A definition can’t insert itself or any ancestor on the dive stack (recursion guard).

Shortcuts: Cmd+Shift+G makes a template from the selection; double-click an instance dives in.

Virtualization & LOD

The editor virtualizes nodes outside the viewport and downgrades distant nodes to lower-fidelity representations automatically. Defaults are tuned for ~30k+ nodes — the threshold is read from the active theme (virtualizeThreshold, default 300) so a custom theme can override it without touching editor code.

// In your theme module:
export const myTheme: XenolithTheme = {
...xenTheme,
virtualizeThreshold: 100, // virtualize earlier on a render-heavy theme
}

LOD switches as zoom decreases: full → sprite-baked → flat-batch. No public toggle — the editor manages it per-frame against the active theme’s threshold.

Headless runtime

Execution is delegated to plugins — the editor itself ships no execution. A runtime (e.g. @xenolithengine/graph-plugin-runtime, a Blueprint VM, in progress) installs as a plugin and uses the runtime delegation API above.

import { runtime } from '@xenolithengine/graph-plugin-runtime' // future package
editor.use(runtime({ /* options */ }))
editor.startLoop() // begins ticking — the runtime decides what executes
editor.step() // single frame