/** * Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab * edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct. */ import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph'; import { resolveDef } from '../../core/graph/nested'; import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry'; import type { Asset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import type { ObjectRef } from '../../core/model/instance'; import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds'; import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems'; import { type PropertyDef, schemaForAsset, schemaForObject, structOf, } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import type { FieldHandlers } from '../inspector/fieldFactory'; import { currentProps, currentRef } from '../inspector/objectQueries'; import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers'; import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs'; import type { EditorState, ListSlot } from '../state'; export type ListDef = Extract; /** What the tab needs of a slot: what is in it, and the two ways of writing it back. */ export interface ListAccess { readonly def: ListDef; readonly value: ListValue; /** * The kinds this list may hold. A list in a struct field takes primitives only – `struct → list → * struct` is past the depth the stored value goes, so offering it would add an item that the next * write silently drops (`flatListOf`). */ readonly kinds: readonly ListItemKind[]; /** What the tab header says the list belongs to. */ readonly title: string; /** One item, previewed and committed exactly as the inspector does it. */ itemHandlers(index: number): FieldHandlers; /** A whole new list in one command – adding, deleting, and the bulk text field. */ replace(next: ListValue): void; } /** The property a slot's `key` names, and how the section that owns it reads and writes it. */ interface Owner { def: PropertyDef; /** Its effective value, re-read on every call – a preview writes into the model. */ current: () => PropertyValue | undefined; title: string; spec: Omit, 'toModel'>; } export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null { const inSlot = (owner: Owner | null) => owner && listIn(state, owner, slot.field); if (slot.kind === 'asset') return inSlot(assetOwner(state, slot)); if (slot.kind === 'object') return inSlot(objectOwner(state, slot)); return inSlot(nodeOwner(state, slot)); } const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []); const withItem = (list: ListValue, index: number, item: ListItem): ListValue => list.map((value, i) => (i === index ? item : value)); /** The owner's own list, or – with a `field` – the list one of its struct fields holds. */ function listIn(state: EditorState, owner: Owner, field?: string): ListAccess | null { if (field === undefined) return owner.def.kind === 'list' ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec) : null; if (owner.def.kind !== 'struct') return null; const declared = owner.def.fields.find((f) => f.key === field); if (declared?.kind !== 'list') return null; const fields = owner.def.fields; const read = (target: Target) => { const bundle = owner.spec.read(target); return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]); }; // Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the // list alone would drop its neighbours, and a snapshot would undo an edit made in between. const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({ ...structOf(fields, target === undefined ? undefined : owner.spec.read(target)), [field]: asList(list), }); return access( state, { key: field, label: field, kind: 'list', default: [] }, `${owner.title} · ${field}`, () => asList(structOf(fields, owner.current())[field]), { target: owner.spec.target, read, write: (target, list) => owner.spec.write(target, bundleWith(target, list)), remove: owner.spec.remove, commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)), }, FIELD_KINDS, ); } /** * `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so * the next keystroke has to build on what is in there now, and the commit on what the restore * put back. Everything else is the spec the matching inspector section already uses. */ function access( state: EditorState, def: ListDef, title: string, current: () => ListValue, spec: Omit, 'toModel'>, kinds: readonly ListItemKind[] = LIST_ITEM_KINDS, ): ListAccess { return { def, kinds, title, value: current(), itemHandlers: (index) => ({ ...previewingHandlers( { state }, { ...spec, // The row keeps the kind it has: a color item written from its field stays a color. toModel: (input) => withItem(current(), index, itemLike(current()[index], input)), }, ), // A struct item is edited field by field, and each write rebuilds the whole item from what // is in the list at that moment -- the same rule as a row, one level deeper. structFieldHandlers: (field: { key: string }) => previewingHandlers( { state }, { ...spec, toModel: (input) => { const item = structItemOf(current()[index]); return withItem(current(), index, { ...item, [field.key]: fieldLike(item[field.key], input), }); }, }, ), }), // No coercion here: every command behind `commit` runs the value through `coerceValue`. // A preview does not, which is why `toModel` above has to. replace: (next) => state.execute(spec.commit(next)), }; } function assetOwner(state: EditorState, slot: Extract): Owner | null { const asset = findAsset(state.project, slot.assetId); const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key); if (!asset || !def) return null; return { def, title: `${asset.name} · ${def.label}`, current: () => { const now = findAsset(state.project, slot.assetId); return now && resolveAssetProps(state.project, now)[slot.key]; }, spec: assetValue(state, slot.assetId, slot.key), }; } function objectOwner( state: EditorState, slot: Extract, ): Owner | null { const ref = currentRef(state, slot.holderId, slot.objectId); const owner = ref && findAsset(state.project, ref.object.assetId); const def = owner && schemaForObject(state.project, owner, ref.object).find((d) => d.key === slot.key); if (!ref || !def) return null; return { def, title: `${ref.object.name} · ${def.label}`, current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key], spec: overrideValue(state, slot.holderId, slot.objectId, slot.key), }; } function nodeOwner(state: EditorState, slot: Extract): Owner | null { const found = currentNode(state.project, slot.assetId, slot.nodeId); if (!found) return null; const nodeDef = resolveDef(found.node, found.graph, graphLookup(state.project)); const def = nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key)); if (!nodeDef || !def) return null; return { def, title: `${nodeDef.label} · ${def.label}`, current: () => currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default, spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key), }; } /** * A `list` input, which carries its inline value in the very same params bag a setting does. Once * an edge feeds the port that value is dead, so the slot stops resolving rather than editing * something the node no longer reads. */ function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null { const port = inlineInputs(def).find((p) => p.key === key); if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null; const inline = inlineInputDef(port); return inline.kind === 'list' ? inline : null; }