refactor(scene-graph): share component property operations

This commit is contained in:
Danila Poyarkov 2026-08-30 20:24:16 +03:00
parent 85ae62cb15
commit 7bda8cc17a
4 changed files with 306 additions and 118 deletions

View file

@ -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<EditorContext, 'graph'>, 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<EditorContext, 'graph'>,
instance: SceneNode
): ComponentPropertyDefinition[] {
const byId = new Map<string, ComponentPropertyDefinition>()
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<EditorContext, 'graph'>,
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<EditorContext, 'graph'>,
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<EditorContext, 'graph'>, 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<EditorContext, 'graph'>,
instance: SceneNode,
target: PropertyTarget | null,
value: string,
swapComponentId: string | null
): Record<string, unknown> {
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<EditorContext, 'graph'>,
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<EditorContext, 'graph'>,
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(

View file

@ -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<string, ComponentPropertyDefinition>()
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
}

View file

@ -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'

View file

@ -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<SceneGraph['createNode']> {
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({})
})
})