Files
glitch_automata_lab/backend/admin/src/PresetNodeTree.tsx
T
2026-07-06 13:11:48 +02:00

690 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react'
import { getCaClassFromParams, supportsCaClass } from './caRuntime.js'
import type { CaEngine, JsonObject, PresetNode } from './types.js'
interface PresetNodeTreeProps {
assignedNodeId?: string
engines?: CaEngine[]
nodes: PresetNode[]
selectedNodeId?: string
usedNodeIds: Set<string>
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}
interface TreeItem {
node: PresetNode
children: TreeItem[]
siblingIndex: number
siblingCount: number
}
interface VisibleTreeItem {
item: TreeItem
inheritedParams: JsonObject
parentId: string | null
}
function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function valuesEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
function getParamPath(value: JsonObject, path: string[]) {
let current: unknown = value
for (const segment of path) {
if (!isPlainObject(current) || !(segment in current)) return undefined
current = current[segment]
}
return current
}
function formatParamValue(path: string[], value: unknown) {
if (path.join('.') === 'simulation.initialCondition' && isPlainObject(value)) {
const cells = Array.isArray(value.cells) ? value.cells.length : 0
return `${cells} live cells`
}
if (Array.isArray(value)) return `${value.length} items`
if (isPlainObject(value)) return 'object'
return String(value)
}
function mergeParamObjects(base: JsonObject, patch: JsonObject): JsonObject {
const merged: JsonObject = { ...base }
for (const [key, value] of Object.entries(patch)) {
const current = merged[key]
merged[key] = isPlainObject(current) && isPlainObject(value) ? mergeParamObjects(current, value) : value
}
return merged
}
function compareNodesByOrder(left: PresetNode, right: PresetNode) {
return left.sort_order - right.sort_order || left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
}
function buildTree(nodes: PresetNode[]): TreeItem[] {
const byParent = new Map<string | null, PresetNode[]>()
for (const node of nodes) {
const siblings = byParent.get(node.parent_id) ?? []
siblings.push(node)
byParent.set(node.parent_id, siblings)
}
function build(parentId: string | null): TreeItem[] {
const siblings = (byParent.get(parentId) ?? []).sort(compareNodesByOrder)
return siblings.map((node, index) => ({
node,
children: build(node.id),
siblingIndex: index,
siblingCount: siblings.length
}))
}
return build(null)
}
function flattenVisibleTree(
items: TreeItem[],
expandedNodeIds: Set<string>,
inheritedParams: JsonObject = {},
parentId: string | null = null
): VisibleTreeItem[] {
const visible: VisibleTreeItem[] = []
for (const item of items) {
visible.push({ item, inheritedParams, parentId })
if (expandedNodeIds.has(item.node.id)) {
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
visible.push(...flattenVisibleTree(item.children, expandedNodeIds, resolvedParams, item.node.id))
}
}
return visible
}
export function PresetNodeTree({
assignedNodeId,
engines = [],
nodes,
selectedNodeId,
usedNodeIds,
onCreateChild,
onCreateGroup,
onDelete,
onMove,
onRename,
onSelect,
onSet,
onUnset
}: PresetNodeTreeProps) {
const tree = React.useMemo(() => buildTree(nodes), [nodes])
const [expandedNodeIds, setExpandedNodeIds] = React.useState<Set<string>>(
() => new Set(nodes.map((node) => node.id))
)
const [editingNodeId, setEditingNodeId] = React.useState('')
const [openMenuNodeId, setOpenMenuNodeId] = React.useState('')
const visibleItems = React.useMemo(
() => flattenVisibleTree(tree, expandedNodeIds),
[expandedNodeIds, tree]
)
React.useEffect(() => {
setExpandedNodeIds((current) => {
const next = new Set([...current].filter((id) => nodes.some((node) => node.id === id)))
for (const node of nodes) {
if (!current.has(node.id)) next.add(node.id)
}
return next
})
}, [nodes])
React.useEffect(() => {
if (!openMenuNodeId) return
function closeMenu(event: PointerEvent) {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest('.tree-node-action-menu') || target.closest('.node-action-trigger')) return
setOpenMenuNodeId('')
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setOpenMenuNodeId('')
}
document.addEventListener('pointerdown', closeMenu)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('pointerdown', closeMenu)
document.removeEventListener('keydown', closeOnEscape)
}
}, [openMenuNodeId])
function toggleNode(nodeId: string, open?: boolean) {
setExpandedNodeIds((current) => {
const next = new Set(current)
const shouldOpen = open ?? !next.has(nodeId)
if (shouldOpen) next.add(nodeId)
else next.delete(nodeId)
return next
})
}
function handleTreeKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(event.key)) return
const selectedIndex = visibleItems.findIndex(({ item }) => item.node.id === selectedNodeId)
const currentIndex = selectedIndex === -1 ? 0 : selectedIndex
const current = visibleItems[currentIndex]
if (!current) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const offset = event.key === 'ArrowDown' ? 1 : -1
const next = visibleItems[Math.min(Math.max(currentIndex + offset, 0), visibleItems.length - 1)]
if (next) onSelect(next.item.node)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
if (current.item.children.length && !expandedNodeIds.has(current.item.node.id)) {
toggleNode(current.item.node.id, true)
} else if (current.item.children[0]) {
onSelect(current.item.children[0].node)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
if (expandedNodeIds.has(current.item.node.id) && current.item.children.length) {
toggleNode(current.item.node.id, false)
} else if (current.parentId) {
const parent = nodes.find((node) => node.id === current.parentId)
if (parent) onSelect(parent)
}
return
}
event.preventDefault()
setEditingNodeId(current.item.node.id)
}
if (tree.length === 0) {
return <p className="empty">No nodes loaded.</p>
}
return (
<div className="preset-node-tree" role="tree" tabIndex={0} onKeyDown={handleTreeKeyDown}>
{tree.map((node) => (
<TreeNodeView
key={node.node.id}
item={node}
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={{}}
selectedNodeId={selectedNodeId}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={setEditingNodeId}
onMenuToggle={(nodeId) => setOpenMenuNodeId((current) => (current === nodeId ? '' : nodeId))}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={toggleNode}
onUnset={onUnset}
/>
))}
</div>
)
}
function TreeNodeView({
item,
assignedNodeId,
engines,
inheritedParams,
selectedNodeId,
usedNodeIds,
expandedNodeIds,
editingNodeId,
openMenuNodeId,
onCreateChild,
onCreateGroup,
onDelete,
onEdit,
onMenuToggle,
onMove,
onRename,
onSelect,
onSet,
onToggle,
onUnset
}: {
item: TreeItem
assignedNodeId?: string
engines: CaEngine[]
inheritedParams: JsonObject
selectedNodeId?: string
usedNodeIds: Set<string>
expandedNodeIds: Set<string>
editingNodeId: string
openMenuNodeId: string
onCreateChild: (node: PresetNode) => void
onCreateGroup: (node: PresetNode) => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onEdit: (nodeId: string) => void
onMenuToggle: (nodeId: string) => void
onMove: (node: PresetNode, direction: 1 | -1) => void
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSelect: (node: PresetNode) => void
onSet: (node: PresetNode, path: string[], value: unknown) => void
onToggle: (nodeId: string, open?: boolean) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const open = expandedNodeIds.has(item.node.id)
const menuOpen = openMenuNodeId === item.node.id
const selected = item.node.id === selectedNodeId
const assigned = item.node.id === assignedNodeId
const used = usedNodeIds.has(item.node.id)
const usedDescendantCount = React.useMemo(() => {
function countUsedDescendants(children: TreeItem[]): number {
return children.reduce(
(total, child) =>
total + (usedNodeIds.has(child.node.id) ? 1 : 0) + countUsedDescendants(child.children),
0
)
}
return countUsedDescendants(item.children)
}, [item.children, usedNodeIds])
const resolvedParams = mergeParamObjects(inheritedParams, item.node.params)
const kindLabel = item.node.kind === 'preset_root' ? 'root' : item.node.kind === 'chapter' ? 'group' : item.node.kind
const usageLabel = assigned
? `${kindLabel} · assigned`
: used
? `${kindLabel} · used by slides`
: usedDescendantCount > 0
? `${kindLabel} · contains ${usedDescendantCount} used preset${usedDescendantCount === 1 ? '' : 's'}`
: `${kindLabel} · unused`
async function deleteFromMenu() {
onMenuToggle(item.node.id)
if (!window.confirm(`Delete preset "${item.node.name}"? This cannot be undone.`)) return
await onDelete(item.node)
}
return (
<div className="tree-item">
<div className={`tree-node${selected ? ' active' : ''}${assigned ? ' assigned' : ''}${used || usedDescendantCount > 0 ? ' used' : ' unused'}`}>
<button
aria-label={item.children.length ? `${open ? 'Collapse' : 'Expand'} ${item.node.name}` : `${item.node.name} has no children`}
className="tree-node-arrow"
disabled={!item.children.length}
type="button"
onClick={() => onToggle(item.node.id)}
>
{item.children.length ? (open ? '▾' : '▸') : '•'}
</button>
<button
aria-current={selected ? 'true' : undefined}
className="tree-node-main"
role="treeitem"
type="button"
onClick={() => onSelect(item.node)}
>
<strong>{item.node.name}</strong>
<small>{usageLabel}</small>
</button>
<div className="tree-node-action-shell">
<button
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={`Actions for ${item.node.name}`}
className="icon-button tree-node-edit node-action-trigger"
title="Preset actions"
type="button"
onClick={(event) => {
event.stopPropagation()
onMenuToggle(item.node.id)
}}
>
</button>
{menuOpen ? (
<div className="tree-node-action-menu" role="menu">
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onEdit(item.node.id)
}}>
Edit
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateChild(item.node)
}}>
Create child preset
</button>
<button role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onCreateGroup(item.node)
}}>
Create child group
</button>
<button disabled={item.siblingIndex === 0} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, -1)
}}>
Move up
</button>
<button disabled={item.siblingIndex >= item.siblingCount - 1} role="menuitem" type="button" onClick={() => {
onMenuToggle(item.node.id)
onMove(item.node, 1)
}}>
Move down
</button>
<button className="danger" role="menuitem" type="button" onClick={() => void deleteFromMenu()}>
Delete
</button>
</div>
) : null}
</div>
</div>
{editingNodeId === item.node.id ? (
<NodePropertiesModal
engines={engines}
inheritedParams={inheritedParams}
node={item.node}
resolvedParams={resolvedParams}
onClose={() => onEdit('')}
onDelete={onDelete}
onRename={onRename}
onSet={onSet}
onUnset={onUnset}
/>
) : null}
{open && item.children.length ? (
<div className="tree-children">
{item.children.map((child) => (
<TreeNodeView
assignedNodeId={assignedNodeId}
engines={engines}
inheritedParams={resolvedParams}
item={child}
key={child.node.id}
usedNodeIds={usedNodeIds}
expandedNodeIds={expandedNodeIds}
editingNodeId={editingNodeId}
openMenuNodeId={openMenuNodeId}
onCreateChild={onCreateChild}
onCreateGroup={onCreateGroup}
onDelete={onDelete}
onEdit={onEdit}
onMenuToggle={onMenuToggle}
onMove={onMove}
onRename={onRename}
onSelect={onSelect}
onSet={onSet}
onToggle={onToggle}
onUnset={onUnset}
selectedNodeId={selectedNodeId}
/>
))}
</div>
) : null}
</div>
)
}
function collectResolvedParams(value: unknown, prefix: string[] = []): Array<{ path: string[]; value: unknown }> {
if (!isPlainObject(value)) return []
const entries: Array<{ path: string[]; value: unknown }> = []
for (const [key, child] of Object.entries(value)) {
const path = [...prefix, key]
if (path.join('.') === 'simulation.initialCondition' || !isPlainObject(child)) {
entries.push({ path, value: child })
} else {
entries.push(...collectResolvedParams(child, path))
}
}
return entries
}
function isGenericEditableProperty(path: string[]) {
const joinedPath = path.join('.')
return (
joinedPath !== 'simulation.initialCondition' &&
joinedPath !== 'simulation.engineId' &&
joinedPath !== 'simulation.neighborhoodId' &&
!joinedPath.startsWith('caClass.')
)
}
function engineParamPath(key: string) {
if (key === 'rendererId') return ['renderer', 'id']
if (key === 'ruleId') return ['simulation', 'ruleId']
if (key.startsWith('grid.')) return ['simulation', ...key.split('.')]
if (key.includes('.')) return key.split('.')
return ['simulation', key]
}
function engineSettingKeys(engine: CaEngine | undefined) {
return engine ? Object.keys(engine.params_schema) : []
}
function isEngineSettingPath(path: string[], engine: CaEngine | undefined) {
const joinedPath = path.join('.')
return engineSettingKeys(engine).some((key) => engineParamPath(key).join('.') === joinedPath)
}
function parsePropertyDraft(value: string) {
const trimmed = value.trim()
if (trimmed === '') return ''
try {
return JSON.parse(trimmed) as unknown
} catch {
return value
}
}
function formatPropertyDraft(value: unknown) {
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}
function NodePropertiesModal({
engines,
inheritedParams,
node,
resolvedParams,
onClose,
onDelete,
onRename,
onSet,
onUnset
}: {
engines: CaEngine[]
inheritedParams: JsonObject
node: PresetNode
resolvedParams: JsonObject
onClose: () => void
onDelete: (node: PresetNode) => boolean | void | Promise<boolean | void>
onRename: (node: PresetNode, name: string) => void | Promise<void>
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [nameDraft, setNameDraft] = React.useState(node.name)
const resolvedEngineId = getParamPath(resolvedParams, ['simulation', 'engineId'])
const caClass = getCaClassFromParams(resolvedParams)
const resolvedEngine =
typeof resolvedEngineId === 'string'
? engines.find((engine) => engine.engine_kind === resolvedEngineId)
: undefined
const resolvedEngineCompatible = resolvedEngine ? supportsCaClass(resolvedEngine, caClass) : false
const activeEngine = resolvedEngineCompatible ? resolvedEngine : undefined
const properties = collectResolvedParams(resolvedParams).filter(
(property) => isGenericEditableProperty(property.path) && !isEngineSettingPath(property.path, activeEngine)
)
React.useEffect(() => {
setNameDraft(node.name)
}, [node.name])
async function saveName() {
const name = nameDraft.trim()
if (!name) {
setNameDraft(node.name)
return
}
if (name !== node.name) await onRename(node, name)
}
async function deleteNode() {
if (!window.confirm(`Delete preset "${node.name}"? This cannot be undone.`)) return
const deleted = await onDelete(node)
if (deleted === false) return
onClose()
}
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<section aria-modal="true" className="property-modal" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<header className="property-modal-header">
<div>
<p className="eyebrow">Preset Editor</p>
<h2>Edit preset</h2>
</div>
<span className="win95-window-controls has-close">
<span className="window-control-decor">_</span>
<span className="window-control-decor"></span>
<button
aria-label="Close"
className="window-control-button window-close-button"
title="Close"
type="button"
onClick={onClose}
>
×
</button>
</span>
</header>
<div className="property-modal-list">
<section className="preset-modal-stage preset-modal-identity">
<div className="preset-modal-stage-heading">
<span>i</span>
<div>
<h3>Preset identity</h3>
<p>Edit CA space, engine, and engine settings in the inspector.</p>
</div>
</div>
<label>
Preset name
<input
autoFocus
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void saveName()
}
}}
/>
</label>
<button disabled={!nameDraft.trim() || nameDraft.trim() === node.name} type="button" onClick={() => void saveName()}>
Save name
</button>
</section>
{properties.map((property) => {
const localValue = getParamPath(node.params, property.path)
const inheritedValue = getParamPath(inheritedParams, property.path)
const isLocal = localValue !== undefined && !valuesEqual(localValue, inheritedValue)
return (
<PropertyEditorRow
inheritedValue={inheritedValue}
isLocal={isLocal}
key={property.path.join('.')}
node={node}
path={property.path}
value={property.value}
onSet={onSet}
onUnset={onUnset}
/>
)
})}
{properties.length === 0 ? <p className="empty">No properties are resolved for this node.</p> : null}
<footer className="preset-modal-danger">
<div>
<strong>Delete preset</strong>
<small>Slides referencing this preset must be reassigned first.</small>
</div>
<button className="danger" type="button" onClick={() => void deleteNode()}>
Delete
</button>
</footer>
</div>
</section>
</div>
)
}
function PropertyEditorRow({
inheritedValue,
isLocal,
node,
path,
value,
onSet,
onUnset
}: {
inheritedValue: unknown
isLocal: boolean
node: PresetNode
path: string[]
value: unknown
onSet: (node: PresetNode, path: string[], value: unknown) => void
onUnset: (node: PresetNode, path: string[]) => void
}) {
const [draft, setDraft] = React.useState(formatPropertyDraft(value))
React.useEffect(() => {
setDraft(formatPropertyDraft(value))
}, [value])
return (
<div className="property-row">
<div className="property-row-meta">
<strong>{path.join('.')}</strong>
<span className={isLocal ? 'local' : 'inherited'}>{isLocal ? 'local override' : 'inherited'}</span>
{!isLocal && inheritedValue !== undefined ? <small>from parent: {formatParamValue(path, inheritedValue)}</small> : null}
</div>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="property-row-actions">
<button type="button" onClick={() => onSet(node, path, parsePropertyDraft(draft))}>
Save
</button>
<button className="danger" disabled={!isLocal} type="button" onClick={() => onUnset(node, path)}>
🗑
</button>
</div>
</div>
)
}