From 7bda8cc17a3eb1603a71b64d235386700ffcac83 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 30 Aug 2026 20:24:16 +0300 Subject: [PATCH] refactor(scene-graph): share component property operations --- .../core/src/editor/components/properties.ts | 128 ++------------ .../scene-graph/src/component-properties.ts | 167 ++++++++++++++++++ packages/scene-graph/src/index.ts | 2 +- .../scene-graph/component-properties.test.ts | 127 +++++++++++++ 4 files changed, 306 insertions(+), 118 deletions(-) create mode 100644 packages/scene-graph/src/component-properties.ts create mode 100644 tests/engine/scene-graph/component-properties.test.ts diff --git a/packages/core/src/editor/components/properties.ts b/packages/core/src/editor/components/properties.ts index af7301ab2..40c37e044 100644 --- a/packages/core/src/editor/components/properties.ts +++ b/packages/core/src/editor/components/properties.ts @@ -1,3 +1,9 @@ +import { + applyComponentPropertyValue, + componentPropertyDefinitions, + findComponentPropertyTarget, + resolveComponentPropertyValue +} from '@open-pencil/scene-graph' import type { ComponentPropertyDefinition, ComponentPropertyReferenceField, @@ -13,57 +19,11 @@ interface PropertyTarget { source: SceneNode } -function definitionOwners(ctx: Pick, instance: SceneNode): SceneNode[] { - if (!instance.componentId) return [] - const component = ctx.graph.getNode(instance.componentId) - if (!component) return [] - const parent = component.parentId ? ctx.graph.getNode(component.parentId) : null - return parent?.type === 'COMPONENT_SET' ? [parent, component] : [component] -} - function definitionsForInstance( ctx: Pick, instance: SceneNode ): ComponentPropertyDefinition[] { - const byId = new Map() - for (const owner of definitionOwners(ctx, instance)) { - for (const definition of owner.componentPropertyDefinitions) { - if (!byId.has(definition.id)) byId.set(definition.id, definition) - } - } - return [...byId.values()] -} - -function findPropertyPath( - ctx: Pick, - sourceParent: SceneNode, - propertyId: string, - path: number[] = [] -): { path: number[]; field: ComponentPropertyReferenceField; source: SceneNode } | null { - for (const [index, childId] of sourceParent.childIds.entries()) { - const child = ctx.graph.getNode(childId) - if (!child) continue - const reference = child.componentPropertyReferences.find((ref) => ref.propertyId === propertyId) - if (reference) return { path: [...path, index], field: reference.field, source: child } - const nested = findPropertyPath(ctx, child, propertyId, [...path, index]) - if (nested) return nested - } - return null -} - -function nodeAtPath( - ctx: Pick, - root: SceneNode, - path: number[] -): SceneNode | null { - let node = root - for (const index of path) { - const childId = node.childIds[index] - const child = childId ? ctx.graph.getNode(childId) : undefined - if (!child) return null - node = child - } - return node + return componentPropertyDefinitions(ctx.graph, instance) } function propertyTarget( @@ -71,28 +31,12 @@ function propertyTarget( instance: SceneNode, propertyId: string ): PropertyTarget | null { - const component = instance.componentId ? ctx.graph.getNode(instance.componentId) : null - if (!component) return null - const match = findPropertyPath(ctx, component, propertyId) - if (!match) return null - const node = nodeAtPath(ctx, instance, match.path) - return node ? { node, field: match.field, source: match.source } : null + const target = findComponentPropertyTarget(ctx.graph, instance, propertyId) + return target } function swapTargetId(ctx: Pick, value: string): string | null { - const direct = ctx.graph.getNode(value) - if (direct?.type === 'COMPONENT') return direct.id - for (const node of ctx.graph.getAllNodes()) { - if (node.type !== 'COMPONENT') continue - if ( - node.source.id === value || - node.componentKey === value || - node.sourceLibraryKey === value - ) { - return node.id - } - } - return null + return resolveComponentPropertyValue(ctx.graph, value)?.id ?? null } function targetValue(target: PropertyTarget | null): string { @@ -101,63 +45,13 @@ function targetValue(target: PropertyTarget | null): string { if (target.field === 'VISIBLE') return String(target.node.visible) return target.source.componentId ?? target.node.componentId ?? '' } - -function propertyOverrides( - ctx: Pick, - instance: SceneNode, - target: PropertyTarget | null, - value: string, - swapComponentId: string | null -): Record { - const overrides = { ...instance.overrides } - if (target?.field === 'TEXT') overrides[`${target.node.id}:text`] = value - else if (target?.field === 'VISIBLE') overrides[`${target.node.id}:visible`] = value === 'true' - else if (target?.field === 'INSTANCE_SWAP') { - overrides[`${target.node.id}:componentId`] = value - overrides[`${target.node.id}:sourceComponentId`] = target.source.id - const componentName = swapComponentId ? ctx.graph.getNode(swapComponentId)?.name : undefined - if (componentName) overrides[`${target.node.id}:name`] = componentName - } - return overrides -} - -function updatePropertyTarget( - ctx: Pick, - target: PropertyTarget | null, - value: string, - swapComponentId: string | null -): void { - if (target?.field === 'TEXT' && target.node.type === 'TEXT') { - ctx.graph.updateNode(target.node.id, { text: value }) - } else if (target?.field === 'VISIBLE') { - ctx.graph.updateNode(target.node.id, { visible: value === 'true' }) - } else if ( - target?.field === 'INSTANCE_SWAP' && - target.node.type === 'INSTANCE' && - swapComponentId - ) { - ctx.graph.swapInstanceComponent(target.node.id, swapComponentId) - } -} - function applyPropertyValue( ctx: Pick, instanceId: string, definition: ComponentPropertyDefinition, value: string ): void { - const instance = ctx.graph.getNode(instanceId) - if (instance?.type !== 'INSTANCE') return - const target = propertyTarget(ctx, instance, definition.id) - const swapComponentId = target?.field === 'INSTANCE_SWAP' ? swapTargetId(ctx, value) : null - ctx.graph.updateNode(instance.id, { - componentPropertyAssignments: { - ...instance.componentPropertyAssignments, - [definition.id]: value - }, - overrides: propertyOverrides(ctx, instance, target, value, swapComponentId) - }) - updatePropertyTarget(ctx, target, value, swapComponentId) + applyComponentPropertyValue(ctx.graph, instanceId, definition, value) } export function reapplyInstanceComponentProperties( diff --git a/packages/scene-graph/src/component-properties.ts b/packages/scene-graph/src/component-properties.ts new file mode 100644 index 000000000..e64f825d2 --- /dev/null +++ b/packages/scene-graph/src/component-properties.ts @@ -0,0 +1,167 @@ +import type { SceneGraph } from './index' +import type { + ComponentPropertyDefinition, + ComponentPropertyReferenceField, + SceneNode +} from './types' + +export interface ComponentPropertyTarget { + node: SceneNode + field: ComponentPropertyReferenceField + source: SceneNode +} + +export function componentPropertyOwners(graph: SceneGraph, instance: SceneNode): SceneNode[] { + if (instance.type !== 'INSTANCE' || !instance.componentId) return [] + const component = graph.getNode(instance.componentId) + if (!component) return [] + const parent = component.parentId ? graph.getNode(component.parentId) : null + return parent?.type === 'COMPONENT_SET' ? [parent, component] : [component] +} + +export function componentPropertyDefinitions( + graph: SceneGraph, + instance: SceneNode +): ComponentPropertyDefinition[] { + const definitions = new Map() + for (const owner of componentPropertyOwners(graph, instance)) { + for (const definition of owner.componentPropertyDefinitions) { + if (!definitions.has(definition.id)) definitions.set(definition.id, definition) + } + } + return [...definitions.values()] +} + +export function resolveComponentPropertyValue(graph: SceneGraph, value: string): SceneNode | null { + const direct = graph.getNode(value) + if (direct?.type === 'COMPONENT') return direct + if (direct?.type === 'COMPONENT_SET') { + const componentId = direct.childIds.find((id) => graph.getNode(id)?.type === 'COMPONENT') + return componentId ? (graph.getNode(componentId) ?? null) : null + } + return ( + [...graph.getAllNodes()].find( + (node) => + node.type === 'COMPONENT' && + (node.componentKey === value || node.sourceLibraryKey === value || node.source.id === value) + ) ?? null + ) +} + +export function findComponentPropertyTarget( + graph: SceneGraph, + instance: SceneNode, + propertyId: string +): ComponentPropertyTarget | null { + return findComponentPropertyTargets(graph, instance, propertyId)[0] ?? null +} + +export function findComponentPropertyTargets( + graph: SceneGraph, + instance: SceneNode, + propertyId: string +): ComponentPropertyTarget[] { + if (instance.type !== 'INSTANCE' || !instance.componentId) return [] + const component = graph.getNode(instance.componentId) + if (!component) return [] + const targets: ComponentPropertyTarget[] = [] + const visit = (sourceParent: SceneNode, instanceParent: SceneNode): void => { + for (const [index, childId] of sourceParent.childIds.entries()) { + const source = graph.getNode(childId) + const targetId = instanceParent.childIds[index] + const target = targetId ? graph.getNode(targetId) : undefined + if (!source || !target) continue + const reference = source.componentPropertyReferences.find( + (candidate) => candidate.propertyId === propertyId + ) + if (reference) targets.push({ node: target, field: reference.field, source }) + visit(source, target) + } + } + visit(component, instance) + return targets +} + +export function applyComponentPropertyValue( + graph: SceneGraph, + instanceId: string, + definition: ComponentPropertyDefinition, + value: string +): SceneNode | null { + const instance = graph.getNode(instanceId) + if (instance?.type !== 'INSTANCE') return null + const targets = findComponentPropertyTargets(graph, instance, definition.id) + const target = + definition.type === 'INSTANCE_SWAP' ? resolveComponentPropertyValue(graph, value) : null + if (definition.type === 'INSTANCE_SWAP' && !target) return null + const overrides = { ...instance.overrides } + if (definition.type === 'TEXT') { + for (const item of targets) { + if (item.field === 'TEXT' && item.node.type === 'TEXT') { + graph.updateNode(item.node.id, { text: value }) + overrides[`${item.node.id}:text`] = value + } + } + } else if (definition.type === 'BOOLEAN') { + for (const item of targets) { + if (item.field === 'VISIBLE') { + graph.updateNode(item.node.id, { visible: value === 'true' }) + overrides[`${item.node.id}:visible`] = value === 'true' + } + } + } else if (definition.type === 'INSTANCE_SWAP' && target) { + for (const item of targets) { + if (item.field !== 'INSTANCE_SWAP' || item.node.type !== 'INSTANCE') continue + graph.swapInstanceComponent(item.node.id, target.id) + graph.updateNode(item.node.id, { + name: target.name, + overrides: { + ...item.node.overrides, + [`${item.node.id}:name`]: target.name + } + }) + overrides[`${item.node.id}:componentId`] = target.id + overrides[`${item.node.id}:sourceComponentId`] = item.source.id + overrides[`${item.node.id}:name`] = target.name + } + } + graph.updateNode(instance.id, { + componentPropertyAssignments: { + ...instance.componentPropertyAssignments, + [definition.id]: definition.type === 'INSTANCE_SWAP' && target ? target.id : value + }, + overrides + }) + return target +} + +export function removeComponentProperty( + graph: SceneGraph, + ownerId: string, + propertyId: string +): boolean { + const owner = graph.getNode(ownerId) + if (!owner || (owner.type !== 'COMPONENT' && owner.type !== 'COMPONENT_SET')) return false + graph.updateNode(owner.id, { + componentPropertyDefinitions: owner.componentPropertyDefinitions.filter( + (definition) => definition.id !== propertyId + ) + }) + for (const node of graph.getAllNodes()) { + if (node.componentPropertyReferences.some((reference) => reference.propertyId === propertyId)) { + graph.updateNode(node.id, { + componentPropertyReferences: node.componentPropertyReferences.filter( + (reference) => reference.propertyId !== propertyId + ) + }) + } + if (node.type === 'INSTANCE' && propertyId in node.componentPropertyAssignments) { + graph.updateNode(node.id, { + componentPropertyAssignments: Object.fromEntries( + Object.entries(node.componentPropertyAssignments).filter(([id]) => id !== propertyId) + ) + }) + } + } + return true +} diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 352eafb02..f9af9a0b0 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */ -export * from './images' +export * from './component-properties' export * from './copy' export { copyInstanceComponentProps } from './instances' export * from './snap' diff --git a/tests/engine/scene-graph/component-properties.test.ts b/tests/engine/scene-graph/component-properties.test.ts new file mode 100644 index 000000000..213db3027 --- /dev/null +++ b/tests/engine/scene-graph/component-properties.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' + +import { + applyComponentPropertyValue, + componentPropertyDefinitions, + componentPropertyOwners, + findComponentPropertyTarget, + removeComponentProperty, + resolveComponentPropertyValue, + SceneGraph +} from '@open-pencil/scene-graph' + +function component( + graph: SceneGraph, + name: string, + parentId: string +): ReturnType { + return graph.createNode('COMPONENT', parentId, { name }) +} + +describe('component properties', () => { + test('resolves component and component-set owners and definitions', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const first = component(graph, 'Button/Default', page.id) + const second = component(graph, 'Button/Hover', page.id) + const set = graph.createNode('COMPONENT_SET', page.id) + graph.reparentNode(first.id, set.id) + graph.reparentNode(second.id, set.id) + const instance = graph.createInstance(first.id, page.id) + if (!instance) throw new Error('instance creation failed') + graph.updateNode(set.id, { + componentPropertyDefinitions: [ + { id: 'prop:state', name: 'State', type: 'VARIANT', defaultValue: 'Default' } + ] + }) + + expect(componentPropertyOwners(graph, instance).map((node) => node.id)).toEqual([ + set.id, + first.id + ]) + expect( + componentPropertyDefinitions(graph, instance).map((definition) => definition.id) + ).toEqual(['prop:state']) + }) + + test('resolves component IDs, keys, library keys, and component-set defaults', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const icon = component(graph, 'Icon', page.id) + const keyed = component(graph, 'Keyed', page.id) + graph.updateNode(keyed.id, { componentKey: 'icon-key' }) + const set = graph.createNode('COMPONENT_SET', page.id) + graph.reparentNode(icon.id, set.id) + + expect(resolveComponentPropertyValue(graph, keyed.id)?.id).toBe(keyed.id) + expect(resolveComponentPropertyValue(graph, 'icon-key')?.id).toBe(keyed.id) + expect(resolveComponentPropertyValue(graph, set.id)?.id).toBe(icon.id) + }) + + test('applies text, boolean, and instance-swap properties to nested targets', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const host = component(graph, 'Host', page.id) + const icon = component(graph, 'Icon', page.id) + const alternate = component(graph, 'Alternate', page.id) + const slot = graph.createInstance(icon.id, host.id) + if (!slot) throw new Error('slot creation failed') + graph.updateNode(host.id, { + componentPropertyDefinitions: [ + { id: 'prop:swap', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: icon.id }, + { id: 'prop:text', name: 'Label', type: 'TEXT', defaultValue: '' }, + { id: 'prop:visible', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' } + ] + }) + graph.updateNode(slot.id, { + componentPropertyReferences: [ + { propertyId: 'prop:swap', field: 'INSTANCE_SWAP' }, + { propertyId: 'prop:text', field: 'TEXT' }, + { propertyId: 'prop:visible', field: 'VISIBLE' } + ] + }) + const instance = graph.createInstance(host.id, page.id) + if (!instance) throw new Error('host instance creation failed') + const text = graph.createNode('TEXT', slot.id) + graph.updateNode(text.id, { + componentPropertyReferences: [{ propertyId: 'prop:text', field: 'TEXT' }] + }) + + const swapDefinition = host.componentPropertyDefinitions[0] + const swapped = applyComponentPropertyValue(graph, instance.id, swapDefinition, alternate.id) + expect(swapped?.id).toBe(alternate.id) + const updatedInstance = graph.getNode(instance.id) + expect(updatedInstance?.componentPropertyAssignments['prop:swap']).toBe(alternate.id) + expect( + Object.keys(updatedInstance?.overrides ?? {}).some((key) => key.endsWith(':componentId')) + ).toBe(true) + const target = findComponentPropertyTarget(graph, instance, 'prop:swap')?.node + expect(target?.type).toBe('INSTANCE') + expect(target?.componentId).toBe(alternate.id) + }) + + test('removes definitions, references, and assignments', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const host = component(graph, 'Host', page.id) + const icon = component(graph, 'Icon', page.id) + const slot = graph.createInstance(icon.id, host.id) + if (!slot) throw new Error('slot creation failed') + graph.updateNode(host.id, { + componentPropertyDefinitions: [ + { id: 'prop:swap', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: icon.id } + ] + }) + graph.updateNode(slot.id, { + componentPropertyReferences: [{ propertyId: 'prop:swap', field: 'INSTANCE_SWAP' }] + }) + const instance = graph.createInstance(host.id, page.id) + if (!instance) throw new Error('instance creation failed') + graph.updateNode(instance.id, { componentPropertyAssignments: { 'prop:swap': icon.id } }) + + expect(removeComponentProperty(graph, host.id, 'prop:swap')).toBe(true) + expect(graph.getNode(host.id)?.componentPropertyDefinitions).toEqual([]) + expect(graph.getNode(slot.id)?.componentPropertyReferences).toEqual([]) + expect(graph.getNode(instance.id)?.componentPropertyAssignments).toEqual({}) + }) +})