Properties sidebar
A "fat" node with 8 widgets opts into the docked properties panel via the per-widget `showInSidebar: true` flag. Edit live — the same widget renders inline AND in the panel; no separate sidebar component to author. Themed via --xeno-*. Open programmatically: `editor.openSidebar(nodeId)`.
// Vanilla mount for G4 — properties sidebar. Same auto-open + toggle button as React.
import { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildPropertiesSidebar } from '@xenolithengine/demo/properties-sidebar'
export async function mount(target: HTMLElement): Promise<() => void> {
const editor = await XenolithEditor.init(target, { resizeToWindow: false, minimap: false })
const scene = buildPropertiesSidebar(editor)
scene.open()
const panel = document.createElement('div')
panel.setAttribute('data-xeno-panel', '')
panel.style.cssText = 'position:absolute;pointer-events:auto;top:12px;left:12px;display:flex;gap:6px;padding:6px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;z-index:5;'
const btn = document.createElement('button')
const paint = (): void => {
const open = scene.isOpen()
btn.textContent = open ? 'Close sidebar' : 'Open sidebar'
btn.style.cssText = `padding:6px 12px;font-size:12px;border-radius:6px;cursor:pointer;border:1px solid ${open ? 'var(--xeno-accent,#FCB400)' : 'var(--xeno-border,#333)'};background:${open ? 'var(--xeno-accent,#FCB400)' : 'transparent'};color:${open ? 'var(--xeno-canvas,#111)' : 'var(--xeno-text,#cfcfcf)'};`
}
btn.addEventListener('click', () => {
if (scene.isOpen()) scene.close()
else scene.open()
paint()
})
paint()
panel.appendChild(btn)
editor.overlayRoot.appendChild(panel)
return () => { panel.remove(); editor.destroy() }
} // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
} import { useEffect, useState } from 'react'
import { XenolithGraph, XenolithPanel, useEditor } from '@xenolithengine/graph-react'
import { setupPropertiesSidebar, PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
import { DemoStage } from '../Layout.js'
// Canon: sidebar open-state lives in the panel — only the panel needs it. Open/close are direct
// editor calls. The panel mounts AFTER `onReady` (Provider gates children on editor presence), so
// the auto-open effect runs once with a guaranteed-ready editor.
function SidebarPanel() {
const editor = useEditor()
const [open, setOpen] = useState(true)
useEffect(() => {
// Auto-open on mount so the demo lands with the panel visible — first impression matters.
editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID)
}, [editor])
const toggle = (): void => {
if (open) { editor.closeSidebar(); setOpen(false) }
else { editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID); setOpen(true) }
}
return (
<XenolithPanel position="top-left" style={{ display: 'flex', gap: 6, padding: 6 }}>
<button onClick={toggle} style={btn(open)}>{open ? 'Close sidebar' : 'Open sidebar'}</button>
</XenolithPanel>
)
}
/** Island: G4 — properties sidebar. */
export function PropertiesSidebarDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupPropertiesSidebar}>
<SidebarPanel />
</XenolithGraph>
</DemoStage>
)
}
const btn = (on: boolean): React.CSSProperties => ({
padding: '6px 12px',
fontSize: 12,
borderRadius: 6,
border: `1px solid ${on ? 'var(--xeno-accent, #FCB400)' : 'var(--xeno-border, #333)'}`,
background: on ? 'var(--xeno-accent, #FCB400)' : 'transparent',
color: on ? 'var(--xeno-canvas, #111)' : 'var(--xeno-text, #cfcfcf)',
cursor: 'pointer',
}) // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
} <script setup lang="ts">
// Vue SFC — properties sidebar. One-time auto-open via `@ready`; toggle panel is a child component
// that uses `useEditor()` for the imperative open/close calls.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { setupPropertiesSidebar, PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
import SidebarTogglePanel from './SidebarTogglePanel.vue'
function onReady(editor: XenolithEditor): void {
setupPropertiesSidebar(editor)
editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID)
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="onReady">
<SidebarTogglePanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { ref } from 'vue'
import { useEditor } from '@xenolithengine/graph-vue'
import { PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
const editor = useEditor()
const open = ref(true)
function toggle(): void {
const e = editor.value
if (!e) return
if (open.value) { e.closeSidebar(); open.value = false }
else { e.openSidebar(PROPERTIES_SIDEBAR_NODE_ID); open.value = true }
}
</script>
<template>
<div data-xeno-panel class="panel">
<button class="btn" :class="{ on: open }" @click="toggle">
{{ open ? 'Close sidebar' : 'Open sidebar' }}
</button>
</div>
</template>
<style scoped>
.panel {
position: absolute; top: 12px; left: 12px;
display: flex; gap: 6px; padding: 6px;
background: var(--xeno-panel, #1d1d1d);
border: 1px solid var(--xeno-border, #333);
border-radius: 8px;
font: 12px Inter, system-ui, sans-serif;
z-index: 5;
}
.btn {
padding: 6px 12px; font: inherit; font-size: 12px; cursor: pointer;
border-radius: 6px;
border: 1px solid var(--xeno-border, #333);
background: transparent;
color: var(--xeno-text, #cfcfcf);
}
.btn.on {
border-color: var(--xeno-accent, #FCB400);
background: var(--xeno-accent, #FCB400);
color: var(--xeno-canvas, #111);
}
</style> // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
} // Svelte adapter — properties sidebar. Imperative primitive + the shared scene builder; the toggle
// button is a plain DOM control mounted into editor.overlayRoot.
import { createXenolithGraph } from '@xenolithengine/graph-svelte'
import { setupPropertiesSidebar, PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
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 })
setupPropertiesSidebar(binding.editor)
binding.editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID)
const panel = document.createElement('div')
panel.setAttribute('data-xeno-panel', '')
panel.style.cssText = 'position:absolute;pointer-events:auto;top:12px;left:12px;display:flex;gap:6px;padding:6px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;z-index:5;'
const btn = document.createElement('button')
let open = true
const paint = (): void => {
btn.textContent = open ? 'Close sidebar' : 'Open sidebar'
btn.style.cssText = `padding:6px 12px;font:inherit;font-size:12px;cursor:pointer;border-radius:6px;border:1px solid ${open ? 'var(--xeno-accent,#FCB400)' : 'var(--xeno-border,#333)'};background:${open ? 'var(--xeno-accent,#FCB400)' : 'transparent'};color:${open ? 'var(--xeno-canvas,#111)' : 'var(--xeno-text,#cfcfcf)'};`
}
btn.addEventListener('click', () => {
if (open) { binding.editor.closeSidebar(); open = false }
else { binding.editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID); open = true }
paint()
})
paint()
panel.appendChild(btn)
binding.editor.overlayRoot.appendChild(panel)
return () => { panel.remove(); binding.destroy(); slot.remove() }
} // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
} // Solid adapter — properties sidebar.
import { createXenolithGraph } from '@xenolithengine/graph-solid'
import { setupPropertiesSidebar, PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
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 })
setupPropertiesSidebar(binding.editor)
binding.editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID)
const panel = document.createElement('div')
panel.setAttribute('data-xeno-panel', '')
panel.style.cssText = 'position:absolute;pointer-events:auto;top:12px;left:12px;display:flex;gap:6px;padding:6px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;z-index:5;'
const btn = document.createElement('button')
let open = true
const paint = (): void => {
btn.textContent = open ? 'Close sidebar' : 'Open sidebar'
btn.style.cssText = `padding:6px 12px;font:inherit;font-size:12px;cursor:pointer;border-radius:6px;border:1px solid ${open ? 'var(--xeno-accent,#FCB400)' : 'var(--xeno-border,#333)'};background:${open ? 'var(--xeno-accent,#FCB400)' : 'transparent'};color:${open ? 'var(--xeno-canvas,#111)' : 'var(--xeno-text,#cfcfcf)'};`
}
btn.addEventListener('click', () => {
if (open) { binding.editor.closeSidebar(); open = false }
else { binding.editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID); open = true }
paint()
})
paint()
panel.appendChild(btn)
binding.editor.overlayRoot.appendChild(panel)
return () => { panel.remove(); binding.destroy(); slot.remove() }
} // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
} // Angular standalone component — properties sidebar. Auto-open via the `(ready)` Output; toggle
// is a direct editor call. Sidebar open-state lives only in the component that drives the button.
import { Component, signal } from '@angular/core'
import { XenolithGraphComponent } from '@xenolithengine/graph-angular'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { setupPropertiesSidebar, PROPERTIES_SIDEBAR_NODE_ID } from '@xenolithengine/demo/properties-sidebar'
@Component({
selector: 'properties-sidebar-demo',
standalone: true,
imports: [XenolithGraphComponent],
template: `
<div class="app" style="position:absolute;inset:0;">
<xenolith-graph
class="xeno"
[resizeToWindow]="false"
(ready)="onReady($event)">
</xenolith-graph>
<div data-xeno-panel class="panel">
<button class="btn" [class.on]="open()" (click)="toggle()">
{{ open() ? 'Close sidebar' : 'Open sidebar' }}
</button>
</div>
</div>
`,
styles: [\`
.panel { position:absolute; top:12px; left:12px; display:flex; gap:6px; padding:6px;
background:var(--xeno-panel,#1d1d1d); border:1px solid var(--xeno-border,#333);
border-radius:8px; font:12px Inter,system-ui,sans-serif; z-index:5; }
.btn { padding:6px 12px; font-size:12px; cursor:pointer; border-radius:6px;
border:1px solid var(--xeno-border,#333); background:transparent; color:var(--xeno-text,#cfcfcf); }
.btn.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400);
color:var(--xeno-canvas,#111); }
\`],
})
export class PropertiesSidebarDemoComponent {
private editor: XenolithEditor | null = null
open = signal(true)
onReady(editor: XenolithEditor): void {
this.editor = editor
setupPropertiesSidebar(editor)
editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID)
}
toggle(): void {
if (!this.editor) return
if (this.open()) { this.editor.closeSidebar(); this.open.set(false) }
else { this.editor.openSidebar(PROPERTIES_SIDEBAR_NODE_ID); this.open.set(true) }
}
} // G4 showcase — properties sidebar. The node declares one IN-pin per widget (canon: a widget
// renders inline only when bound to an IN-pin), and EVERY widget is flagged `showInSidebar`.
// Inline you see the editable fields on each pin row; click "Open sidebar" and the panel docks
// on the right, same widgets, themed, scrollable.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, PinId } from '@xenolithengine/graph-core'
export interface PropertiesSidebarScene {
nodeId: NodeId
open: () => void
close: () => void
isOpen: () => boolean
}
const inPin = (key: string, type: string): { id: PinId; kind: 'data'; direction: 'in'; type: string; multiple: false; label: string } =>
({ id: `mat_${key}` as PinId, kind: 'data', direction: 'in', type, multiple: false, label: key })
/** Material node id loaded by this demo. */
export const PROPERTIES_SIDEBAR_NODE_ID = 'material' as NodeId
/** Idempotent setup: load the fat Material node. */
export function setupPropertiesSidebar(editor: XenolithEditor): void { void buildPropertiesSidebar(editor) }
/** @deprecated Prefer `setupPropertiesSidebar` + `editor.openSidebar/closeSidebar`. */
export function buildPropertiesSidebar(editor: XenolithEditor): PropertiesSidebarScene {
const id = 'material' as NodeId
editor.loadJSON({
version: 'xenolith.v1',
nodes: [
{
id, type: 'Material', position: { x: 80, y: 60 },
state: {
name: 'Untitled material',
intensity: 0.6,
tint: '#9F69FF',
metallic: 0.3,
roughness: 0.55,
mode: 'Multiply',
enabled: true,
notes: 'Edit me in the sidebar →',
},
render: { title: 'Material', category: 'data' },
pins: [
inPin('name', 'string'),
inPin('enabled', 'boolean'),
inPin('mode', 'string'),
inPin('intensity', 'float'),
inPin('metallic', 'float'),
inPin('roughness', 'float'),
inPin('tint', 'color'),
inPin('notes', 'string'),
{ id: 'mat_out' as PinId, kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'out' },
],
// Empty `label` on each widget — they're bound to IN-pins, the pin row already names
// them. The sidebar will fall back to `key` for its label (Name, Enabled, …). This
// avoids the inline widget drawing its own label INSIDE the control and overlapping it.
widgets: [
{ id: 'name', type: 'text', key: 'name', label: '', showInSidebar: true, hint: 'Display name in the asset browser' },
{ id: 'enabled', type: 'toggle', key: 'enabled', label: '', showInSidebar: true },
{ id: 'mode', type: 'combo', key: 'mode', label: '', showInSidebar: true, values: ['Add', 'Multiply', 'Subtract', 'Screen'] },
{ id: 'intensity', type: 'slider', key: 'intensity', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'metallic', type: 'slider', key: 'metallic', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'roughness', type: 'slider', key: 'roughness', label: '', showInSidebar: true, min: 0, max: 1, step: 0.01 },
{ id: 'tint', type: 'color', key: 'tint', label: '', showInSidebar: true },
{ id: 'notes', type: 'text', key: 'notes', label: '', showInSidebar: true, multiline: true, placeholder: 'Authoring notes…' },
],
},
],
edges: [],
})
editor.fitView({ padding: 60, maxZoom: 1 })
return {
nodeId: id,
open: () => editor.openSidebar(id),
close: () => editor.closeSidebar(),
isOpen: () => editor.isSidebarOpen(),
}
}