Events → your state
Typed event callbacks wired to app state: a live log, selection inspector, widget values.
import { useState } from 'react'
import { XenolithGraph, XenolithPanel, useEditorEvent, useSelection } from '@xenolithengine/graph-react'
import { DemoStage } from '../Layout.js'
import { loadDemo } from '../demo-data.js'
const SIDE_PANEL = { width: 280, maxHeight: 'calc(100% - 24px)', overflowY: 'auto' as const }
/** Island: typed event callbacks wired to React state — a live log + selection inspector, all in an
* in-editor panel.
*
* Canon: subscriptions live with the state that records them. EventsPanel uses `useEditorEvent`
* (one line per event, auto-rebinds on editor swap); `useSelection()` replaces the manual
* selection-tracking state. No on* callback props on <XenolithGraph>. */
function EventsPanel() {
const selected = useSelection()
const [log, setLog] = useState<string[]>([])
const [widgets, setWidgets] = useState<Record<string, unknown>>({})
const push = (line: string): void => setLog((l) => [line, ...l].slice(0, 40))
useEditorEvent('node:click', (e) => push(`node:click ${e.nodeId}`))
useEditorEvent('selection:changed', (e) => push(`selection:changed (${e.nodeIds.length})`))
useEditorEvent('node:moved', (e) => push(`node:moved ${e.nodeId} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`))
useEditorEvent('edge:connected', (e) => push(`edge:connected ${e.edge.id}`))
useEditorEvent('edge:disconnected', (e) => push(`edge:disconnected ${e.edgeId}`))
useEditorEvent('widget:changed', (e) => {
setWidgets((w) => ({ ...w, [`${e.nodeId}.${e.widgetId}`]: e.value }))
push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`)
})
useEditorEvent('history:changed', (e) => push(`history undo=${e.canUndo} redo=${e.canRedo}`))
return (
<XenolithPanel position="top-right" style={SIDE_PANEL}>
<h3>Selection</h3>
{selected.length === 0 ? <p className="muted">Nothing selected.</p> : selected.map((id) => (
<div className="row" key={String(id)}><span>{String(id)}</span></div>
))}
<h3 style={{ marginTop: 16 }}>Widget values</h3>
{Object.keys(widgets).length === 0
? <p className="muted">Drag a slider, type in a field, toggle a switch…</p>
: Object.entries(widgets).map(([id, v]) => (
<div className="row" key={id}><span className="muted">{id}</span><span>{JSON.stringify(v)}</span></div>
))}
<h3 style={{ marginTop: 16 }}>Event log</h3>
<div className="log">
{log.length === 0 && <p className="muted">Interact with the graph…</p>}
{log.map((line, i) => <div key={i}>{line}</div>)}
</div>
</XenolithPanel>
)
}
export function EventsDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={loadDemo}>
<EventsPanel />
</XenolithGraph>
</DemoStage>
)
} <script setup lang="ts">
// Vue SFC — typed event callbacks wired to component state. `@ready` runs the one-time scene
// setup; `<EventsPanel>` is a child component that uses `useEditorEvent` for live subscriptions.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { loadDemo } from '@xenolithengine/demo/scene'
import EventsPanel from './EventsPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="loadDemo">
<EventsPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
// Subscriptions live with the state that records them. `useEditorEvent` auto-rebinds on editor swap
// and cleans up on unmount. Mounted as a child of <XenolithGraph> so the editor injection resolves.
import { ref } from 'vue'
import { useEditorEvent } from '@xenolithengine/graph-vue'
const log = ref<string[]>([])
const widgets = ref<Record<string, unknown>>({})
const selected = ref<string[]>([])
const push = (line: string): void => { log.value = [line, ...log.value].slice(0, 40) }
useEditorEvent('node:click', (e) => push(`node:click ${String(e.nodeId)}`))
useEditorEvent('selection:changed', (e) => {
selected.value = e.nodeIds.map(String)
push(`selection:changed (${e.nodeIds.length})`)
})
useEditorEvent('node:moved', (e) => push(`node:moved ${String(e.nodeId)} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`))
useEditorEvent('edge:connected', (e) => push(`edge:connected ${String(e.edge.id)}`))
useEditorEvent('edge:disconnected', (e) => push(`edge:disconnected ${String(e.edgeId)}`))
useEditorEvent('widget:changed', (e) => {
widgets.value = { ...widgets.value, [`${String(e.nodeId)}.${e.widgetId}`]: e.value }
push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`)
})
useEditorEvent('history:changed', (e) => push(`history undo=${e.canUndo} redo=${e.canRedo}`))
</script>
<template>
<div data-xeno-panel class="panel">
<h3>Selection</h3>
<p v-if="selected.length === 0" class="muted">Nothing selected.</p>
<div v-for="id in selected" :key="id" class="row"><span>{{ id }}</span></div>
<h3 style="margin-top:16px;">Widget values</h3>
<p v-if="Object.keys(widgets).length === 0" class="muted">Drag a slider, type in a field, toggle a switch…</p>
<div v-for="(v, id) in widgets" :key="id" class="row">
<span class="muted">{{ id }}</span><span>{{ JSON.stringify(v) }}</span>
</div>
<h3 style="margin-top:16px;">Event log</h3>
<div class="log">
<p v-if="log.length === 0" class="muted">Interact with the graph…</p>
<div v-for="(line, i) in log" :key="i">{{ line }}</div>
</div>
</div>
</template>
<style scoped>
.panel {
position: absolute; top: 12px; right: 12px;
width: 280px; max-height: calc(100% - 24px); overflow-y: auto;
background: var(--xeno-panel, #1d1d1d);
border: 1px solid var(--xeno-border, #333);
border-radius: 8px;
padding: 12px;
font: 12px Inter, system-ui, sans-serif;
color: var(--xeno-text, #cfcfcf);
z-index: 5;
}
.panel h3 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--xeno-muted, #9a9a9a); }
.muted { color: var(--xeno-muted, #9a9a9a); margin: 0; }
.row { display: flex; justify-content: space-between; gap: 8px; padding: 2px 0; }
.log { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; }
</style> // Svelte adapter — events. The `use:xenolith` action re-dispatches every editor event off the
// host node as a kebab-named CustomEvent (`node-click`, `selection-changed`, …) — Svelte hosts
// bind these via `on:node-click`. Here we mount via the imperative primitive (to also load the
// shared demo) and attach the same kebab listeners on the node for parity with the action.
import { createXenolithGraph, svelteEventName } from '@xenolithengine/graph-svelte'
import { loadDemo } from '@xenolithengine/demo/scene'
export async function mount(target: HTMLElement): Promise<() => void> {
const slot = document.createElement('div')
slot.style.cssText = 'position:absolute;inset:0;'
target.appendChild(slot)
const binding = await createXenolithGraph(slot, { resizeToWindow: false })
loadDemo(binding.editor)
const panel = renderPanel()
binding.editor.overlayRoot.appendChild(panel.root)
// Subscribe through the binding (canonical) — the action would surface these as DOM events.
const offs = [
binding.on('node:click', (e) => panel.push(`node:click ${String(e.nodeId)}`)),
binding.on('selection:changed', (e) => { panel.selection(e.nodeIds.map(String)); panel.push(`selection:changed (${e.nodeIds.length})`) }),
binding.on('node:moved', (e) => panel.push(`node:moved ${String(e.nodeId)} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`)),
binding.on('edge:connected', (e) => panel.push(`edge:connected ${String(e.edge.id)}`)),
binding.on('edge:disconnected', (e) => panel.push(`edge:disconnected ${String(e.edgeId)}`)),
binding.on('widget:changed', (e) => { panel.widget(`${String(e.nodeId)}.${e.widgetId}`, e.value); panel.push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`) }),
binding.on('history:changed', (e) => panel.push(`history undo=${e.canUndo} redo=${e.canRedo}`)),
]
void svelteEventName // exposed for the kebab event names contract
return () => { offs.forEach((o) => o()); panel.root.remove(); binding.destroy(); slot.remove() }
}
function renderPanel() {
const root = document.createElement('div')
root.setAttribute('data-xeno-panel', '')
root.style.cssText = 'position:absolute;pointer-events:auto;top:12px;right:12px;width:280px;max-height:calc(100% - 24px);overflow-y:auto;padding:12px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;color:var(--xeno-text,#cfcfcf);z-index:5;'
root.innerHTML = '<h3 style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Selection</h3><div data-sel></div><h3 style="margin:16px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Widget values</h3><div data-widgets></div><h3 style="margin:16px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Event log</h3><div data-log style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:1.5;"></div>'
const $sel = root.querySelector<HTMLElement>('[data-sel]')!
const $widgets = root.querySelector<HTMLElement>('[data-widgets]')!
const $log = root.querySelector<HTMLElement>('[data-log]')!
const widgets: Record<string, unknown> = {}
const log: string[] = []
$sel.textContent = 'Nothing selected.'
$widgets.textContent = 'Drag a slider, type, toggle…'
$log.textContent = 'Interact with the graph…'
return {
root,
selection(ids: string[]) {
$sel.innerHTML = ids.length === 0 ? 'Nothing selected.' : ids.map((id) => `<div>${id}</div>`).join('')
},
widget(id: string, v: unknown) {
widgets[id] = v
$widgets.innerHTML = Object.entries(widgets).map(([k, val]) => `<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#9a9a9a;">${k}</span><span>${JSON.stringify(val)}</span></div>`).join('')
},
push(line: string) {
log.unshift(line); log.length = Math.min(log.length, 40)
$log.innerHTML = log.map((l) => `<div>${l}</div>`).join('')
},
}
} // Solid adapter — events. With the directive, Solid hosts bind via `on:node:click={fn}` etc.
// Here we use the imperative primitive to drive the editor and a plain DOM panel; the binding's
// `.on()` is the same channel the directive subscribes to.
import { createXenolithGraph } from '@xenolithengine/graph-solid'
import { loadDemo } from '@xenolithengine/demo/scene'
export async function mount(target: HTMLElement): Promise<() => void> {
const slot = document.createElement('div')
slot.style.cssText = 'position:absolute;inset:0;'
target.appendChild(slot)
const binding = await createXenolithGraph(slot, { resizeToWindow: false })
loadDemo(binding.editor)
const panel = renderPanel()
binding.editor.overlayRoot.appendChild(panel.root)
const offs = [
binding.on('node:click', (e) => panel.push(`node:click ${String(e.nodeId)}`)),
binding.on('selection:changed', (e) => { panel.selection(e.nodeIds.map(String)); panel.push(`selection:changed (${e.nodeIds.length})`) }),
binding.on('node:moved', (e) => panel.push(`node:moved ${String(e.nodeId)} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`)),
binding.on('edge:connected', (e) => panel.push(`edge:connected ${String(e.edge.id)}`)),
binding.on('edge:disconnected', (e) => panel.push(`edge:disconnected ${String(e.edgeId)}`)),
binding.on('widget:changed', (e) => { panel.widget(`${String(e.nodeId)}.${e.widgetId}`, e.value); panel.push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`) }),
binding.on('history:changed', (e) => panel.push(`history undo=${e.canUndo} redo=${e.canRedo}`)),
]
return () => { offs.forEach((o) => o()); panel.root.remove(); binding.destroy(); slot.remove() }
}
function renderPanel() {
const root = document.createElement('div')
root.setAttribute('data-xeno-panel', '')
root.style.cssText = 'position:absolute;pointer-events:auto;top:12px;right:12px;width:280px;max-height:calc(100% - 24px);overflow-y:auto;padding:12px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;color:var(--xeno-text,#cfcfcf);z-index:5;'
root.innerHTML = '<h3 style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Selection</h3><div data-sel></div><h3 style="margin:16px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Widget values</h3><div data-widgets></div><h3 style="margin:16px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9a9a9a;">Event log</h3><div data-log style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:1.5;"></div>'
const $sel = root.querySelector<HTMLElement>('[data-sel]')!
const $widgets = root.querySelector<HTMLElement>('[data-widgets]')!
const $log = root.querySelector<HTMLElement>('[data-log]')!
const widgets: Record<string, unknown> = {}
const log: string[] = []
$sel.textContent = 'Nothing selected.'
$widgets.textContent = 'Drag a slider, type, toggle…'
$log.textContent = 'Interact with the graph…'
return {
root,
selection(ids: string[]) {
$sel.innerHTML = ids.length === 0 ? 'Nothing selected.' : ids.map((id) => `<div>${id}</div>`).join('')
},
widget(id: string, v: unknown) {
widgets[id] = v
$widgets.innerHTML = Object.entries(widgets).map(([k, val]) => `<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#9a9a9a;">${k}</span><span>${JSON.stringify(val)}</span></div>`).join('')
},
push(line: string) {
log.unshift(line); log.length = Math.min(log.length, 40)
$log.innerHTML = log.map((l) => `<div>${l}</div>`).join('')
},
}
} // Angular standalone component — events. Every editor event is exposed as a camelCase Output;
// bind with `(nodeClick)="…"` etc. Selection state + event log live in component fields.
import { Component, signal } from '@angular/core'
import { CommonModule } from '@angular/common'
import { XenolithGraphComponent } from '@xenolithengine/graph-angular'
import type { XenolithEditor, EditorEvents } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
@Component({
selector: 'events-demo',
standalone: true,
imports: [CommonModule, XenolithGraphComponent],
template: `
<div class="app" style="position:absolute;inset:0;">
<xenolith-graph
class="xeno"
[resizeToWindow]="false"
(ready)="onReady($event)"
(nodeClick)="onNodeClick($event)"
(selectionChanged)="onSelection($event)"
(nodeMoved)="onMoved($event)"
(edgeConnected)="onConnected($event)"
(edgeDisconnected)="onDisconnected($event)"
(widgetChanged)="onWidget($event)"
(historyChanged)="onHistory($event)">
</xenolith-graph>
<div data-xeno-panel class="panel">
<h3>Selection</h3>
<p *ngIf="selection().length === 0" class="muted">Nothing selected.</p>
<div *ngFor="let id of selection()" class="row"><span>{{ id }}</span></div>
<h3>Event log</h3>
<div class="log">
<div *ngFor="let line of log()">{{ line }}</div>
</div>
</div>
</div>
`,
styles: [\`
.panel { position:absolute; top:12px; right:12px; width:280px; max-height:calc(100% - 24px);
overflow-y:auto; padding:12px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:8px;
font:12px Inter,system-ui,sans-serif; color:var(--xeno-text,#cfcfcf); z-index:5; }
h3 { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:#9a9a9a; }
.muted { color:#9a9a9a; margin:0; }
.log { font-family:ui-monospace,Menlo,monospace; font-size:11px; line-height:1.5; }
\`],
})
export class EventsDemoComponent {
selection = signal<string[]>([])
log = signal<string[]>([])
onReady(editor: XenolithEditor): void { loadDemo(editor) }
private push(line: string): void {
this.log.update((l) => [line, ...l].slice(0, 40))
}
onNodeClick(e: EditorEvents['node:click']): void { this.push(`node:click ${String(e.nodeId)}`) }
onSelection(e: EditorEvents['selection:changed']): void {
this.selection.set(e.nodeIds.map(String))
this.push(`selection:changed (${e.nodeIds.length})`)
}
onMoved(e: EditorEvents['node:moved']): void {
this.push(`node:moved ${String(e.nodeId)} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`)
}
onConnected(e: EditorEvents['edge:connected']): void { this.push(`edge:connected ${String(e.edge.id)}`) }
onDisconnected(e: EditorEvents['edge:disconnected']): void { this.push(`edge:disconnected ${String(e.edgeId)}`) }
onWidget(e: EditorEvents['widget:changed']): void {
this.push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`)
}
onHistory(e: EditorEvents['history:changed']): void {
this.push(`history undo=${e.canUndo} redo=${e.canRedo}`)
}
}