Two-way data binding
Both binding levels in one: selection edits the selected node’s widgets; the serialized graph binds the whole document back to the editor.
import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor, useEditorEvent, useSelection, useGraphJSON } from '@xenolithengine/graph-react'
import type { WidgetSpec } from '@xenolithengine/graph-editor'
import { DemoStage } from '../Layout.js'
import { loadDemo } from '../demo-data.js'
const note: React.CSSProperties = { margin: 0, fontSize: 11.5, color: 'var(--xeno-muted)' }
/** Widget-level binding via `useSelection`: edit the selected node's widgets; canvas edits flow back
* (widget:changed re-renders). */
function Inspector() {
const editor = useEditor()
const selection = useSelection()
const [, bump] = useState(0)
useEditorEvent('widget:changed', () => bump((n) => n + 1))
const nodeId = selection[0] ?? null
const node = nodeId ? editor.getNode(nodeId) : undefined
const widgets = (node?.widgets ?? []).filter((w) => w.key !== undefined)
const set = (w: WidgetSpec, value: unknown): void => { editor.setWidgetValue(nodeId!, w.id, value); bump((n) => n + 1) }
return (
<XenolithPanel position="top-right" style={{ width: 232, maxHeight: 'calc(100% - 24px)', overflowY: 'auto' }}>
<h3>Inspector</h3>
<p style={note}>useSelection() → widgets</p>
{!node && <p className="muted">Select a node.</p>}
{node && widgets.length === 0 && <p className="muted">No editable widgets.</p>}
{node && widgets.map((w) => {
const v = editor.getWidgetValue(nodeId!, w.id)
return (
<label key={w.id} className="field">
<span>{w.label}</span>
{(w.type === 'slider' || w.type === 'number') && (
<input type="range" min={'min' in w ? w.min : 0} max={'max' in w ? w.max : 1}
step={w.type === 'slider' ? (w.step ?? 0.01) : (w.step ?? 1)}
value={Number(v) || 0} onChange={(e) => set(w, e.target.valueAsNumber)} />
)}
{w.type === 'text' && <input type="text" value={String(v ?? '')} onChange={(e) => set(w, e.target.value)} />}
{w.type === 'toggle' && <input type="checkbox" checked={Boolean(v)} onChange={(e) => set(w, e.target.checked)} />}
{w.type === 'color' && <input type="color" value={String(v ?? '#000000')} onChange={(e) => set(w, e.target.value)} />}
{w.type === 'combo' && (
<select value={String(v)} onChange={(e) => set(w, e.target.value)}>
{w.values.map((o) => {
const val = typeof o === 'string' ? o : o.value
const lab = typeof o === 'string' ? o : o.label
return <option key={String(val)} value={String(val)}>{lab}</option>
})}
</select>
)}
{(w.type === 'slider' || w.type === 'number') && <em>{String(v)}</em>}
</label>
)
})}
</XenolithPanel>
)
}
/** Graph-level binding via `useGraphJSON`: the whole graph ⇄ xenolith.v1 JSON; Apply rebuilds it. */
function JsonPanel() {
const editor = useEditor()
const json = useGraphJSON()
const [text, setText] = useState('')
const [err, setErr] = useState(false)
const focused = useRef(false)
useEffect(() => { if (json && !focused.current) { setText(JSON.stringify(json, null, 2)); setErr(false) } }, [json])
const apply = (): void => {
try { editor.loadJSON(JSON.parse(text)); editor.view.fitView({ padding: 48, maxZoom: 1 }); focused.current = false; setErr(false) }
catch { setErr(true) }
}
return (
<XenolithPanel position="top-left" style={{ width: 340, height: 'calc(100% - 24px)', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<XenolithButton active onClick={apply}>Apply JSON →</XenolithButton>
{err && <span style={{ color: '#e06c5b', fontSize: 12 }}>Invalid JSON</span>}
</div>
<p style={note}>useGraphJSON() → the whole graph as state</p>
<textarea className={`graph-json${err ? ' err' : ''}`} style={{ flex: 1 }} spellCheck={false}
value={text} onFocus={() => { focused.current = true }} onChange={(e) => setText(e.target.value)} onBlur={() => { focused.current = false }} />
</XenolithPanel>
)
}
/** Island: BOTH binding levels via hooks — widgets (useSelection) and the whole graph (useGraphJSON).
* No hand-wired event plumbing; the editor stays the single source of truth. */
export function TwoWayBindingDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={loadDemo}>
<JsonPanel />
<Inspector />
</XenolithGraph>
</DemoStage>
)
} <script setup lang="ts">
// Vue — both binding levels: useSelection() for the selected node's widgets,
// useGraphJSON() for the whole document. The editor stays the source of truth.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { loadDemo } from '@xenolithengine/demo/scene'
import TwoWayPanels from './TwoWayPanels.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="loadDemo">
<TwoWayPanels />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor, useEditorEvent, useSelection, useGraphJSON } from '@xenolithengine/graph-vue'
import type { WidgetSpec } from '@xenolithengine/graph-editor'
const editor = useEditor()
const selection = useSelection()
const json = useGraphJSON()
const bump = ref(0)
useEditorEvent('widget:changed', () => { bump.value++ })
const nodeId = computed(() => selection.value[0] ?? null)
const node = computed(() => {
void bump.value
const id = nodeId.value
return id && editor.value ? editor.value.getNode(id) : undefined
})
const widgets = computed(() => (node.value?.widgets ?? []).filter((w) => w.key !== undefined))
function valueOf(id: string): unknown {
void bump.value
const nid = nodeId.value
return nid && editor.value ? editor.value.getWidgetValue(nid, id) : undefined
}
function setValue(w: WidgetSpec, value: unknown): void {
const nid = nodeId.value
if (!nid || !editor.value) return
editor.value.setWidgetValue(nid, w.id, value)
bump.value++
}
function numBound(w: WidgetSpec, key: 'min' | 'max' | 'step', fallback: number): number {
return key in w && typeof (w as Record<string, unknown>)[key] === 'number'
? (w as Record<string, number>)[key]!
: fallback
}
function comboOptions(w: WidgetSpec): { value: string; label: string }[] {
if (w.type !== 'combo') return []
return w.values.map((o) => typeof o === 'string'
? { value: o, label: o }
: { value: String(o.value), label: o.label })
}
const text = ref('')
const err = ref(false)
const focused = ref(false)
watch(json, (doc) => {
if (doc && !focused.value) { text.value = JSON.stringify(doc, null, 2); err.value = false }
})
function apply(): void {
const e = editor.value
if (!e) return
try {
e.loadJSON(JSON.parse(text.value))
e.view.fitView({ padding: 48, maxZoom: 1 })
focused.value = false
err.value = false
} catch { err.value = true }
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="width:340px;height:calc(100vh - 280px);min-height:220px;display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<XenolithButton :active="true" @click="apply">Apply JSON →</XenolithButton>
<span v-if="err" style="color:#e06c5b;font-size:12px;">Invalid JSON</span>
</div>
<p style="margin:0;font-size:11.5px;color:var(--xeno-muted);">useGraphJSON() → the whole graph as state</p>
<textarea
:value="text"
spellcheck="false"
style="flex:1;resize:none;font:12px ui-monospace,monospace;background:var(--xeno-canvas);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:8px;"
@focus="focused = true"
@blur="focused = false"
@input="text = ($event.target as HTMLTextAreaElement).value"
/>
</div>
</XenolithPanel>
<XenolithPanel position="top-right">
<div style="width:232px;max-height:calc(100% - 24px);overflow-y:auto;">
<h3 style="margin:0 0 6px;font-size:13px;">Inspector</h3>
<p style="margin:0 0 8px;font-size:11.5px;color:var(--xeno-muted);">useSelection() → widgets</p>
<p v-if="!node" style="color:var(--xeno-muted);">Select a node.</p>
<p v-else-if="widgets.length === 0" style="color:var(--xeno-muted);">No editable widgets.</p>
<label v-for="w in widgets" :key="String(w.id)" style="display:flex;flex-direction:column;gap:4px;margin:0 0 8px;font-size:12px;">
<span>{{ w.label }}</span>
<input v-if="w.type === 'slider' || w.type === 'number'" type="range"
:min="numBound(w, 'min', 0)" :max="numBound(w, 'max', 1)"
:step="w.type === 'slider' ? numBound(w, 'step', 0.01) : numBound(w, 'step', 1)"
:value="Number(valueOf(String(w.id))) || 0"
@input="setValue(w, ($event.target as HTMLInputElement).valueAsNumber)" />
<input v-else-if="w.type === 'text'" type="text" :value="String(valueOf(String(w.id)) ?? '')"
@input="setValue(w, ($event.target as HTMLInputElement).value)" />
<input v-else-if="w.type === 'toggle'" type="checkbox" :checked="Boolean(valueOf(String(w.id)))"
@change="setValue(w, ($event.target as HTMLInputElement).checked)" />
<input v-else-if="w.type === 'color'" type="color" :value="String(valueOf(String(w.id)) ?? '#000000')"
@input="setValue(w, ($event.target as HTMLInputElement).value)" />
<select v-else-if="w.type === 'combo'" :value="String(valueOf(String(w.id)))"
@change="setValue(w, ($event.target as HTMLSelectElement).value)">
<option v-for="o in comboOptions(w)" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<em v-if="w.type === 'slider' || w.type === 'number'">{{ String(valueOf(String(w.id))) }}</em>
</label>
</div>
</XenolithPanel>
</template> <!-- Svelte — useSelection via the store bag, useGraphJSON via graphJSON. Panels need
createXenolithEditorContext() during init; feed it from onready. -->
<script lang="ts">
import { xenolith, createXenolithStores, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import type { WidgetSpec, XenolithEditor } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
const stores = createXenolithStores()
const panelEditor = createXenolithEditorContext()
const selection = stores.selection
const graphJSON = stores.graphJSON
let editor = $state<XenolithEditor | null>(null)
let tick = $state(0)
let text = $state('')
let err = $state(false)
let focused = $state(false)
$effect(() => {
const doc = $graphJSON
if (doc && !focused) { text = JSON.stringify(doc, null, 2); err = false }
})
const nodeId = $derived($selection[0] ?? null)
const node = $derived.by(() => {
void tick
return nodeId && editor ? editor.getNode(nodeId) : undefined
})
const widgets = $derived((node?.widgets ?? []).filter((w) => w.key !== undefined))
function valueOf(id: string): unknown {
void tick
return nodeId && editor ? editor.getWidgetValue(nodeId, id) : undefined
}
function setValue(w: WidgetSpec, value: unknown): void {
if (!nodeId || !editor) return
editor.setWidgetValue(nodeId, w.id, value)
tick += 1
}
function numBound(w: WidgetSpec, key: 'min' | 'max' | 'step', fallback: number): number {
const rec = w as unknown as Record<string, unknown>
return typeof rec[key] === 'number' ? rec[key] : fallback
}
function comboOptions(w: WidgetSpec): { value: string; label: string }[] {
if (w.type !== 'combo') return []
return w.values.map((o) => typeof o === 'string'
? { value: o, label: o }
: { value: String(o.value), label: o.label })
}
function apply(): void {
if (!editor) return
try {
editor.loadJSON(JSON.parse(text))
editor.view.fitView({ padding: 48, maxZoom: 1 })
focused = false
err = false
} catch { err = true }
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => {
editor = e.detail
panelEditor.set(e.detail)
stores.editor.set(e.detail)
loadDemo(e.detail)
e.detail.on('widget:changed', () => { tick += 1 })
}}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="width:340px;height:min(420px,calc(100vh - 280px));display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<XenolithButton active onclick={apply}>Apply JSON →</XenolithButton>
{#if err}<span style="color:#e06c5b;font-size:12px;">Invalid JSON</span>{/if}
</div>
<p style="margin:0;font-size:11.5px;color:var(--xeno-muted);">graphJSON store → the whole graph as state</p>
<textarea
spellcheck="false"
style="flex:1;resize:none;font:12px ui-monospace,monospace;background:var(--xeno-canvas);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:8px;"
value={text}
onfocus={() => { focused = true }}
onblur={() => { focused = false }}
oninput={(e) => { text = e.currentTarget.value }}
></textarea>
</div>
</XenolithPanel>
<XenolithPanel position="top-right">
<div style="width:232px;max-height:70vh;overflow-y:auto;">
<h3 style="margin:0 0 6px;font-size:13px;">Inspector</h3>
<p style="margin:0 0 8px;font-size:11.5px;color:var(--xeno-muted);">selection store → widgets</p>
{#if !node}<p style="color:var(--xeno-muted);">Select a node.</p>
{:else if widgets.length === 0}<p style="color:var(--xeno-muted);">No editable widgets.</p>
{/if}
{#each widgets as w (w.id)}
<label style="display:flex;flex-direction:column;gap:4px;margin:0 0 8px;font-size:12px;">
<span>{w.label}</span>
{#if w.type === 'slider' || w.type === 'number'}
<input type="range" min={numBound(w, 'min', 0)} max={numBound(w, 'max', 1)}
step={w.type === 'slider' ? numBound(w, 'step', 0.01) : numBound(w, 'step', 1)}
value={Number(valueOf(String(w.id))) || 0}
oninput={(e) => setValue(w, e.currentTarget.valueAsNumber)} />
<em>{String(valueOf(String(w.id)))}</em>
{:else if w.type === 'text'}
<input type="text" value={String(valueOf(String(w.id)) ?? '')}
oninput={(e) => setValue(w, e.currentTarget.value)} />
{:else if w.type === 'toggle'}
<input type="checkbox" checked={Boolean(valueOf(String(w.id)))}
onchange={(e) => setValue(w, e.currentTarget.checked)} />
{:else if w.type === 'color'}
<input type="color" value={String(valueOf(String(w.id)) ?? '#000000')}
oninput={(e) => setValue(w, e.currentTarget.value)} />
{:else if w.type === 'combo'}
<select value={String(valueOf(String(w.id)))} onchange={(e) => setValue(w, e.currentTarget.value)}>
{#each comboOptions(w) as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
{/if}
</label>
{/each}
</div>
</XenolithPanel> // Solid — selection() and graphJSON() from the signal bag. No panel package: the
// inspector is a DOM card. The canvas on the site stays the React island; this is the source.
import { createEffect, createSignal, For, Show } from 'solid-js'
import { xenolith, createXenolithStores } from '@xenolithengine/graph-solid'
import type { WidgetSpec, XenolithEditor } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
function numBound(w: WidgetSpec, key: 'min' | 'max' | 'step', fallback: number): number {
const rec = w as unknown as Record<string, unknown>
return typeof rec[key] === 'number' ? rec[key] : fallback
}
function comboOptions(w: WidgetSpec): { value: string; label: string }[] {
if (w.type !== 'combo') return []
return w.values.map((o) => typeof o === 'string'
? { value: o, label: o }
: { value: String(o.value), label: o.label })
}
const card = 'position:absolute;z-index:5;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px Inter,system-ui,sans-serif;'
export function TwoWayBindingDemo() {
const stores = createXenolithStores()
const [editor, setEditor] = createSignal<XenolithEditor | null>(null)
const [tick, setTick] = createSignal(0)
const [text, setText] = createSignal('')
const [err, setErr] = createSignal(false)
const [focused, setFocused] = createSignal(false)
createEffect(() => {
const doc = stores.graphJSON()
if (doc && !focused()) { setText(JSON.stringify(doc, null, 2)); setErr(false) }
})
const nodeId = () => stores.selection()[0] ?? null
const node = () => {
void tick()
const id = nodeId()
const e = editor()
return id && e ? e.getNode(id) : undefined
}
const widgets = () => (node()?.widgets ?? []).filter((w) => w.key !== undefined)
const valueOf = (id: string): unknown => {
void tick()
const nid = nodeId()
const e = editor()
return nid && e ? e.getWidgetValue(nid, id) : undefined
}
const setValue = (w: WidgetSpec, value: unknown): void => {
const nid = nodeId()
const e = editor()
if (!nid || !e) return
e.setWidgetValue(nid, w.id, value)
setTick((n) => n + 1)
}
const apply = (): void => {
const e = editor()
if (!e) return
try {
e.loadJSON(JSON.parse(text()))
e.view.fitView({ padding: 48, maxZoom: 1 })
setFocused(false)
setErr(false)
} catch { setErr(true) }
}
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
setEditor(e.detail)
stores.setEditor(e.detail)
loadDemo(e.detail)
e.detail.on('widget:changed', () => setTick((n) => n + 1))
}}
style="position:absolute;inset:0"
>
<div style={`${card}top:12px;left:12px;width:340px;height:min(420px,calc(100% - 24px));display:flex;flex-direction:column;gap:8px;`}>
<div style="display:flex;align-items:center;gap:8px;">
<button type="button" onClick={apply} style="padding:7px 12px;border-radius:8px;border:1px solid var(--xeno-accent);background:var(--xeno-accent);color:var(--xeno-canvas);cursor:pointer;">Apply JSON →</button>
<Show when={err()}><span style="color:#e06c5b;font-size:12px;">Invalid JSON</span></Show>
</div>
<p style="margin:0;font-size:11.5px;color:var(--xeno-muted);">graphJSON() → the whole graph as state</p>
<textarea
spellcheck={false}
style="flex:1;resize:none;font:12px ui-monospace,monospace;background:var(--xeno-canvas);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:8px;"
value={text()}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onInput={(e) => setText(e.currentTarget.value)}
/>
</div>
<div style={`${card}top:12px;right:12px;width:232px;max-height:calc(100% - 24px);overflow-y:auto;`}>
<h3 style="margin:0 0 6px;font-size:13px;">Inspector</h3>
<p style="margin:0 0 8px;font-size:11.5px;color:var(--xeno-muted);">selection() → widgets</p>
<Show when={!node()}><p style="color:var(--xeno-muted);">Select a node.</p></Show>
<Show when={node() && widgets().length === 0}><p style="color:var(--xeno-muted);">No editable widgets.</p></Show>
<For each={widgets()}>{(w) => (
<label style="display:flex;flex-direction:column;gap:4px;margin:0 0 8px;font-size:12px;">
<span>{w.label}</span>
<Show when={w.type === 'slider' || w.type === 'number'}>
<input type="range" min={numBound(w, 'min', 0)} max={numBound(w, 'max', 1)}
step={w.type === 'slider' ? numBound(w, 'step', 0.01) : numBound(w, 'step', 1)}
value={Number(valueOf(String(w.id))) || 0}
onInput={(e) => setValue(w, e.currentTarget.valueAsNumber)} />
<em>{String(valueOf(String(w.id)))}</em>
</Show>
<Show when={w.type === 'text'}>
<input type="text" value={String(valueOf(String(w.id)) ?? '')} onInput={(e) => setValue(w, e.currentTarget.value)} />
</Show>
<Show when={w.type === 'toggle'}>
<input type="checkbox" checked={Boolean(valueOf(String(w.id)))} onChange={(e) => setValue(w, e.currentTarget.checked)} />
</Show>
<Show when={w.type === 'color'}>
<input type="color" value={String(valueOf(String(w.id)) ?? '#000000')} onInput={(e) => setValue(w, e.currentTarget.value)} />
</Show>
<Show when={w.type === 'combo'}>
<select value={String(valueOf(String(w.id)))} onChange={(e) => setValue(w, e.currentTarget.value)}>
<For each={comboOptions(w)}>{(o) => <option value={o.value}>{o.label}</option>}</For>
</select>
</Show>
</label>
)}</For>
</div>
</div>
)
} // Angular — selection$ and graphJSON$ on XenolithGraphService. The inspector is host DOM;
// this adapter ships no panel component.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, computed, viewChild } from '@angular/core'
import { Subscription } from 'rxjs'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { NodeId, WidgetSpec } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
function numBound(w: WidgetSpec, key: 'min' | 'max' | 'step', fallback: number): number {
const rec = w as unknown as Record<string, unknown>
return typeof rec[key] === 'number' ? rec[key] as number : fallback
}
function comboOptions(w: WidgetSpec): { value: string; label: string }[] {
if (w.type !== 'combo') return []
return w.values.map((o) => typeof o === 'string'
? { value: o, label: o }
: { value: String(o.value), label: o.label })
}
@Component({
selector: 'two-way-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" style="top:12px;left:12px;width:340px;height:min(420px,calc(100% - 24px));display:flex;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<button type="button" class="btn on" (click)="apply()">Apply JSON →</button>
@if (err()) { <span style="color:#e06c5b;font-size:12px;">Invalid JSON</span> }
</div>
<p class="note">graphJSON$ → the whole graph as state</p>
<textarea [value]="text()" (focus)="focused.set(true)" (blur)="focused.set(false)"
(input)="text.set($any($event.target).value)"></textarea>
</div>
<div class="card" style="top:12px;right:12px;width:232px;max-height:calc(100% - 24px);overflow-y:auto;">
<h3>Inspector</h3>
<p class="note">selection$ → widgets</p>
@if (!node()) { <p class="note">Select a node.</p> }
@else if (widgets().length === 0) { <p class="note">No editable widgets.</p> }
@for (w of widgets(); track w.id) {
<label>
<span>{{ w.label }}</span>
@if (w.type === 'slider' || w.type === 'number') {
<input type="range" [min]="numBound(w, 'min', 0)" [max]="numBound(w, 'max', 1)"
[step]="w.type === 'slider' ? numBound(w, 'step', 0.01) : numBound(w, 'step', 1)"
[value]="num(w)" (input)="setValue(w, $any($event.target).valueAsNumber)" />
<em>{{ valueOf(w) }}</em>
} @else if (w.type === 'text') {
<input type="text" [value]="str(w)" (input)="setValue(w, $any($event.target).value)" />
} @else if (w.type === 'toggle') {
<input type="checkbox" [checked]="bool(w)" (change)="setValue(w, $any($event.target).checked)" />
} @else if (w.type === 'color') {
<input type="color" [value]="str(w) || '#000000'" (input)="setValue(w, $any($event.target).value)" />
} @else if (w.type === 'combo') {
<select [value]="str(w)" (change)="setValue(w, $any($event.target).value)">
@for (o of comboOptions(w); track o.value) { <option [value]="o.value">{{ o.label }}</option> }
</select>
}
</label>
}
</div>
</div>
`,
styles: [`
.card { position:absolute; z-index:5; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px; color:var(--xeno-text,#cfcfcf);
font:13px Inter,system-ui,sans-serif; }
.note { margin:0 0 8px; font-size:11.5px; color:var(--xeno-muted,#9a9a9a); }
h3 { margin:0 0 6px; font-size:13px; }
label { display:flex; flex-direction:column; gap:4px; margin:0 0 8px; font-size:12px; }
textarea { flex:1; resize:none; font:12px ui-monospace,monospace; background:var(--xeno-canvas,#111);
color:var(--xeno-text,#cfcfcf); border:1px solid var(--xeno-border,#333); border-radius:6px; padding:8px; }
.btn { padding:7px 12px; border-radius:8px; cursor:pointer; font:inherit; }
.btn.on { border:1px solid var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); }
`],
})
export class TwoWayBindingDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private subs = new Subscription()
readonly numBound = numBound
readonly comboOptions = comboOptions
selection = signal<readonly NodeId[]>([])
tick = signal(0)
text = signal('')
err = signal(false)
focused = signal(false)
node = computed(() => {
void this.tick()
const id = this.selection()[0]
const e = this.graph.editor
return id && e ? e.getNode(id) : undefined
})
widgets = computed(() => (this.node()?.widgets ?? []).filter((w) => w.key !== undefined))
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
loadDemo(editor)
this.subs.add(this.graph.selection$.subscribe((ids) => this.selection.set(ids)))
this.subs.add(this.graph.graphJSON$.subscribe((doc) => {
if (doc && !this.focused()) { this.text.set(JSON.stringify(doc, null, 2)); this.err.set(false) }
}))
this.subs.add(this.graph.on$('widget:changed').subscribe(() => this.tick.update((n) => n + 1)))
}
valueOf(w: WidgetSpec): unknown {
void this.tick()
const id = this.selection()[0]
return id ? this.graph.editor?.getWidgetValue(id, w.id) : undefined
}
num(w: WidgetSpec): number { return Number(this.valueOf(w)) || 0 }
str(w: WidgetSpec): string { return String(this.valueOf(w) ?? '') }
bool(w: WidgetSpec): boolean { return Boolean(this.valueOf(w)) }
setValue(w: WidgetSpec, value: unknown): void {
const id = this.selection()[0]
if (!id) return
this.graph.editor?.setWidgetValue(id, w.id, value)
this.tick.update((n) => n + 1)
}
apply(): void {
const editor = this.graph.editor
if (!editor) return
try {
editor.loadJSON(JSON.parse(this.text()))
editor.view.fitView({ padding: 48, maxZoom: 1 })
this.focused.set(false)
this.err.set(false)
} catch { this.err.set(true) }
}
ngOnDestroy(): void { this.subs.unsubscribe(); this.graph.destroy() }
}