690 lines
22 KiB
TypeScript
690 lines
22 KiB
TypeScript
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>
|
||
)
|
||
}
|