refactor(scene-graph): share component property operations
This commit is contained in:
parent
85ae62cb15
commit
7bda8cc17a
|
|
@ -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(
|
||||
|
|
|
|||
167
packages/scene-graph/src/component-properties.ts
Normal file
167
packages/scene-graph/src/component-properties.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
127
tests/engine/scene-graph/component-properties.test.ts
Normal file
127
tests/engine/scene-graph/component-properties.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue