Custom canvas widget
The simplest custom widget: a click/drag level bar, drawn in WebGL — no DOM. The value flows back to your app via the standard widget callback.
{
"version": "xenolith.v1",
"nodes": [
{
"id": "mixer",
"type": "Mixer",
"position": {
"x": 0,
"y": 0
},
"render": {
"title": "Mixer"
},
"state": {
"gain": 0.6
},
"pins": [
{
"id": "mixer:out",
"kind": "data",
"direction": "out",
"type": "float",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "gain",
"type": "custom",
"renderer": "level",
"key": "gain",
"label": "Gain",
"height": 30,
"freeFloating": true
}
]
}
],
"edges": []
} import { useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel } from '@xenolithengine/graph-react'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import { buildCanvasWidget } from '@xenolithengine/demo/canvas-widget'
import { DemoStage } from '../Layout.js'
// The simplest custom widget — a click/drag level bar — lives in the framework-agnostic core
// (@xenolithengine/demo/canvas-widget): two functions, no DOM. This React file registers it via the core,
// then catches the value through onWidgetChange and shows it (and a slider that writes it back).
export function CanvasWidgetDemo() {
const [gain, setGain] = useState(0.6)
const editorRef = useRef<XenolithEditor | null>(null)
const nodeIdRef = useRef<NodeId | null>(null)
return (
<DemoStage>
<XenolithGraph
className="xeno"
resizeToWindow={false}
onWidgetChange={(e) => { if (e.widgetId === 'gain') setGain(Number(e.value)) }}
onReady={(editor) => {
editorRef.current = editor
nodeIdRef.current = buildCanvasWidget(editor).nodeId
}}
>
<XenolithPanel position="top-right" style={{ width: 220 }}>
<h3>Live value (in React)</h3>
<p className="muted">The canvas widget commits through the editor; <code>onWidgetChange</code> hands the value back to React.</p>
<div style={{ fontSize: 28, fontWeight: 600, color: 'var(--xeno-accent)' }}>{Math.round(gain * 100)}%</div>
<input type="range" min={0} max={1} step={0.01} value={gain} style={{ width: '100%' }}
onChange={(e) => editorRef.current?.setWidgetValue(nodeIdRef.current!, 'gain', e.target.valueAsNumber)} />
</XenolithPanel>
</XenolithGraph>
</DemoStage>
)
} // The simplest possible custom widget: a click/drag level bar. A CanvasWidgetController is just two
// functions — `draw` paints into a 2D canvas, `onPointer` returns the new value during a drag. No
// framework, no DOM. The node it lives on is DATA (canvas-widget.json).
import type { CanvasWidgetController, XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './canvas-widget.json'
export const levelWidget: CanvasWidgetController = {
draw(ctx, { value, width, height, accent, muted }) {
const v = typeof value === 'number' ? value : 0
ctx.fillStyle = muted // readout (top-left, inside bounds)
ctx.font = '11px Inter'
ctx.textBaseline = 'top'
ctx.fillText(`${Math.round(v * 100)}%`, 0, 0)
const barY = height - 10 // bar along the bottom
ctx.fillStyle = 'rgba(255,255,255,0.10)' // track
ctx.fillRect(0, barY, width, 8)
ctx.fillStyle = accent // fill up to the value
ctx.fillRect(0, barY, width * v, 8)
},
onPointer(phase, x, _y, { width }) {
if (phase === 'up') return undefined
return Math.max(0, Math.min(1, x / width)) // click/drag → new value
},
}
/** Register the bar widget, load the one-node graph, frame it. Returns the node id so the host can
* read/write its value (e.g. mirror it into app state). */
export function buildCanvasWidget(editor: XenolithEditor): { nodeId: NodeId } {
editor.registerWidget('level', levelWidget)
editor.loadJSON(graph)
editor.fitView({ padding: 90, maxZoom: 1 })
const nodeId = [...editor.graph.nodes()][0]!.id
return { nodeId }
}