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.graphNodes()][0]!.id
return { nodeId }
} {
"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": []
} <script setup lang="ts">
// Vue — a WebGL level bar. @widget-changed is the camelCase emit of widget:changed.
// The slider writes the same key back through setWidgetValue.
import { ref } from 'vue'
import { XenolithGraph, XenolithPanel } from '@xenolithengine/graph-vue'
import type { NodeId, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildCanvasWidget } from '@xenolithengine/demo/canvas-widget'
const gain = ref(0.6)
const editor = ref<XenolithEditor | null>(null)
const nodeId = ref<NodeId | null>(null)
function onReady(e: XenolithEditor): void {
editor.value = e
nodeId.value = buildCanvasWidget(e).nodeId
}
function onWidget(payload: { widgetId: string; value: unknown }): void {
if (payload.widgetId === 'gain') gain.value = Number(payload.value)
}
function onRange(ev: Event): void {
const e = editor.value
const id = nodeId.value
if (!e || !id) return
e.setWidgetValue(id, 'gain', (ev.target as HTMLInputElement).valueAsNumber)
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="onReady" @widget-changed="onWidget">
<XenolithPanel position="top-right">
<div style="width:220px;">
<h3 style="margin:0 0 6px;font-size:13px;">Live value (in Vue)</h3>
<p style="margin:0 0 8px;font-size:12px;color:var(--xeno-muted);">
The canvas widget commits through the editor; <code>widget:changed</code> hands the value back.
</p>
<div style="font-size:28px;font-weight: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%;" @input="onRange" />
</div>
</XenolithPanel>
</XenolithGraph>
</div>
</template> // 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.graphNodes()][0]!.id
return { nodeId }
} {
"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": []
} <!-- Svelte — a WebGL level bar. widget:changed is subscribed on the editor from onready.
The range uses oninput, so the file stays on the Svelte 5 event form (no on:ready mix). -->
<script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel } from '@xenolithengine/graph-svelte/components'
import type { NodeId, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildCanvasWidget } from '@xenolithengine/demo/canvas-widget'
const panelEditor = createXenolithEditorContext()
let gain = $state(0.6)
let editor = $state<XenolithEditor | null>(null)
let nodeId = $state<NodeId | null>(null)
function onRange(ev: Event): void {
if (!editor || !nodeId) return
editor.setWidgetValue(nodeId, 'gain', (ev.target as HTMLInputElement).valueAsNumber)
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => {
editor = e.detail
panelEditor.set(e.detail)
nodeId = buildCanvasWidget(e.detail).nodeId
e.detail.on('widget:changed', (payload) => {
if (payload.widgetId === 'gain') gain = Number(payload.value)
})
}}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-right">
<div style="width:220px;">
<h3 style="margin:0 0 6px;font-size:13px;">Live value (in Svelte)</h3>
<p style="margin:0 0 8px;font-size:12px;color:var(--xeno-muted);">
The canvas widget commits through the editor; <code>widget:changed</code> hands the value back.
</p>
<div style="font-size:28px;font-weight: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%;" oninput={onRange} />
</div>
</XenolithPanel> // 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.graphNodes()][0]!.id
return { nodeId }
} {
"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": []
} // Solid — a WebGL level bar. widget:changed is subscribed from on:ready. The directive's
// attribute form is on:widget-changed (one colon); on:widget:changed fails Vite's dep scan.
import { createSignal } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { NodeId, XenolithEditor } from '@xenolithengine/graph-editor'
import { buildCanvasWidget } from '@xenolithengine/demo/canvas-widget'
export function CanvasWidgetDemo() {
const [gain, setGain] = createSignal(0.6)
const [editor, setEditor] = createSignal<XenolithEditor | null>(null)
const [nodeId, setNodeId] = createSignal<NodeId | null>(null)
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
setEditor(e.detail)
setNodeId(buildCanvasWidget(e.detail).nodeId)
e.detail.on('widget:changed', (payload) => {
if (payload.widgetId === 'gain') setGain(Number(payload.value))
})
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;right:12px;z-index:5;width:220px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<h3 style="margin:0 0 6px;font-size:13px;">Live value (in Solid)</h3>
<p style="margin:0 0 8px;font-size:12px;color:var(--xeno-muted);">
The canvas widget commits through the editor; widget:changed hands the value back.
</p>
<div style="font-size:28px;font-weight: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%;"
onInput={(ev) => {
const live = editor()
const id = nodeId()
if (!live || !id) return
live.setWidgetValue(id, 'gain', (ev.currentTarget as HTMLInputElement).valueAsNumber)
}}
/>
</div>
</div>
)
} // 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.graphNodes()][0]!.id
return { nodeId }
} {
"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": []
} // Angular — a WebGL level bar. widget:changed arrives on the service as on$('widget:changed').
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { Subscription } from 'rxjs'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { NodeId } from '@xenolithengine/graph-editor'
import { buildCanvasWidget } from '@xenolithengine/demo/canvas-widget'
@Component({
selector: 'canvas-widget-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<h3>Live value (in Angular)</h3>
<p class="note">The canvas widget commits through the editor; widget:changed hands the value back.</p>
<div class="readout">{{ Math.round(gain() * 100) }}%</div>
<input type="range" min="0" max="1" step="0.01" [value]="gain()" (input)="onRange($event)" />
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; right:12px; z-index:5; width:220px; padding:10px;
background:var(--xeno-panel,#1d1d1d); border:1px solid var(--xeno-border,#333);
border-radius:10px; color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif; }
h3 { margin:0 0 6px; font-size:13px; }
.note { margin:0 0 8px; font-size:12px; color:var(--xeno-muted,#9a9a9a); }
.readout { font-size:28px; font-weight:600; color:var(--xeno-accent,#FCB400); }
input { width:100%; }
`],
})
export class CanvasWidgetDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private sub = new Subscription()
private nodeId: NodeId | null = null
gain = signal(0.6)
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.sub.add(this.graph.on$('widget:changed').subscribe((payload) => {
if (payload.widgetId === 'gain') this.gain.set(Number(payload.value))
}))
this.nodeId = buildCanvasWidget(editor).nodeId
}
onRange(ev: Event): void {
const editor = this.graph.editor
if (!editor || !this.nodeId) return
editor.setWidgetValue(this.nodeId, 'gain', (ev.target as HTMLInputElement).valueAsNumber)
}
ngOnDestroy(): void {
this.sub.unsubscribe()
this.graph.destroy()
}
} // 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.graphNodes()][0]!.id
return { nodeId }
}