Adding the lab project
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
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>
|
||||
<button type="button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user