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 documentconst doc = editor.toJSON() // serialise nodes + edges + render opts + viewportA 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 themeditor.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 margineditor.resetView() // back to identity| Option | Default | Meaning |
|---|---|---|
padding | 64 | Screen-space margin kept clear on each side |
maxZoom | 1 | Never zoom in past this (keeps small graphs from filling the screen) |
minZoom | the editor’s zoom floor | Never 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
| Gesture | Action |
|---|---|
Tab / double-click empty canvas | Open the insert palette (fuzzy search; built-in nodes rank first) |
| Right-click an edge’s midpoint dot | Context menu: Add Node (filtered to compatible types) · Add Reroute · Delete |
| Drag from a pin | Live ghost edge with type-compatibility validation |
Alt+drag a connected pin | Tear the edge off and re-wire it |
| Drag node / marquee | Move (8px snap, Alt to disable) / box-select |
Delete · ⌘Z / ⌘⇧Z · ⌘C/⌘V · ⌘D · ⌘A | Delete · 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, undoableeditor.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:
| Field | Purpose |
|---|---|
registry | Node schema registry (ctx.registry.register(schema)) |
types | TypeRegistry for custom pin types (id/color/shape/compatibleWith) |
icons | Glyph registry — built-in Feather set + register(name, svg) |
commandBus | Batch mutations as one transaction (use sparingly) |
graph | Read-only view of the graph |
app | The PIXI Application — escape hatch for custom rendering only; touching the scene graph here is unsupported |
registerWidget | Register canvas-draw or DOM-mount custom widget controllers |
setIsValidConnection | Install a global connection validator (runs after built-in type-compat) |
on | Full event-bus access (see Events) |
Runtime delegation (for execution plugins — none ship in the editor itself):
| Method | Purpose |
|---|---|
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. int → float).
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 overrideA 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/outoff()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: Tab → Comment, 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 graphShortcut: 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 grapheditor.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 defThe 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 packageeditor.use(runtime({ /* options */ }))editor.startLoop() // begins ticking — the runtime decides what executeseditor.step() // single frame