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 5 — typed event callbacks + the store bag: selection via the bag's selection store,
the live log through the action's on:* CustomEvents (payloads in e.detail). -->
<script lang="ts">
import { xenolith, createXenolithStores } from '@xenolithengine/graph-svelte'
import { XenolithPanel } from '@xenolithengine/graph-svelte/components'
import { loadDemo } from '@xenolithengine/demo/scene'
const stores = createXenolithStores()
const selection = stores.selection // destructure for the $-auto-subscription in the template
let log = $state<string[]>([])
const push = (line: string): void => { log = [line, ...log].slice(0, 40) }
</script>
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { stores.editor.set(e.detail); loadDemo(e.detail) }}
on:node:click={(e) => push(`node:click ${String(e.detail.nodeId)}`)}
on:selection:changed={(e) => push(`selection:changed (${e.detail.nodeIds.length})`)}
on:edge:connected={(e) => push(`edge:connected ${String(e.detail.edge.id)}`)}
on:widget:changed={(e) => push(`widget:changed ${e.detail.widgetId} = ${JSON.stringify(e.detail.value)}`)}
on:history:changed={(e) => push(`history undo=${e.detail.canUndo} redo=${e.detail.canRedo}`)}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-right">
<h3>Selection</h3>
{#each $selection as id (id)}<div>{id}</div>{/each}
<h3>Event log</h3>
{#each log as line}<div>{line}</div>{/each}
</XenolithPanel> // Solid — events → your state: single events through the directive's on:* handlers; reactive
// selection through the store bag's signal accessors (stores.selection()).
import { createSignal } from 'solid-js'
import { For } from 'solid-js'
import { xenolith, createXenolithStores } from '@xenolithengine/graph-solid'
import { loadDemo } from '@xenolithengine/demo/scene'
export function EventsDemo() {
const stores = createXenolithStores()
const [log, setLog] = createSignal<string[]>([])
const push = (line: string) => setLog((l) => [line, ...l].slice(0, 40))
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { stores.setEditor(e.detail); loadDemo(e.detail) }}
on:node-click={(e) => push(`node:click ${String(e.detail.nodeId)}`)}
on:selection-changed={(e) => push(`selection:changed (${e.detail.nodeIds.length})`)}
on:edge-connected={(e) => push(`edge:connected ${String(e.detail.edge.id)}`)}
on:widget-changed={(e) => push(`widget:changed ${e.detail.widgetId} = ${JSON.stringify(e.detail.value)}`)}
on:history-changed={(e) => push(`history undo=${e.detail.canUndo} redo=${e.detail.canRedo}`)}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;right:12px;width:280px;max-height:60%;overflow-y:auto;padding:12px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:8px;font:12px Inter,system-ui,sans-serif;color:var(--xeno-text);z-index:5">
<h3 style="margin:0 0 6px;font-size:11px;text-transform:uppercase">Selection</h3>
<For each={stores.selection()}>{(id) => <div>{id}</div>}</For>
<h3 style="margin:8px 0 6px;font-size:11px;text-transform:uppercase">Event log</h3>
<For each={log()}>{(line) => <div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{line}</div>}</For>
</div>
</div>
)
} // Angular standalone host component — events. Single events stream through the service's
// on$('node:click') observables; reactive state (selection) through the store observables
// (selection$, …) wired to signals with toSignal(). The log lives in a component signal.
import {
AfterViewInit, Component, DestroyRef, ElementRef, OnDestroy, inject, signal, viewChild,
} from '@angular/core'
import { CommonModule } from '@angular/common'
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { loadDemo } from '@xenolithengine/demo/scene'
@Component({
selector: 'events-demo',
standalone: true,
imports: [CommonModule],
providers: [XenolithGraphService],
template: `
<div class="app" style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div data-xeno-panel class="panel">
<h3>Selection</h3>
<p class="muted" *ngIf="selection().length === 0">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 implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private destroyRef = inject(DestroyRef)
// Reactive state: the service's store observables → signals (toSignal needs an injection
// context, so it's a field initializer, not inside ngAfterViewInit).
protected selection = toSignal(this.graph.selection$, { initialValue: [] as string[] })
protected log = signal<string[]>([])
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
loadDemo(editor)
const push = (line: string): void => this.log.update((l) => [line, ...l].slice(0, 40))
const until = takeUntilDestroyed(this.destroyRef) // post-await: pass DestroyRef explicitly
this.graph.on$('node:click').pipe(until).subscribe((e) => push(`node:click ${String(e.nodeId)}`))
this.graph.on$('node:moved').pipe(until).subscribe((e) =>
push(`node:moved ${String(e.nodeId)} → ${Math.round(e.position.x)},${Math.round(e.position.y)}`))
this.graph.on$('edge:connected').pipe(until).subscribe((e) => push(`edge:connected ${String(e.edge.id)}`))
this.graph.on$('edge:disconnected').pipe(until).subscribe((e) => push(`edge:disconnected ${String(e.edgeId)}`))
this.graph.on$('widget:changed').pipe(until).subscribe((e) =>
push(`widget:changed ${e.widgetId} = ${JSON.stringify(e.value)}`))
this.graph.on$('history:changed').pipe(until).subscribe((e) =>
push(`history undo=${e.canUndo} redo=${e.canRedo}`))
this.graph.on$('selection:changed').pipe(until).subscribe((e) =>
push(`selection:changed (${e.nodeIds.length})`))
}
ngOnDestroy(): void { this.graph.destroy() }
}