feat(figma-api): complete component property compatibility

This commit is contained in:
Danila Poyarkov 2026-08-31 09:47:46 +03:00
parent 82191b75f7
commit 2e29787c5e
2 changed files with 93 additions and 8 deletions

View file

@ -1,4 +1,9 @@
import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyType,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
import {
applyComponentPropertyValue,
componentPropertyDefinitions as sharedComponentPropertyDefinitions,
@ -112,7 +117,17 @@ function preferredValues(
})
}
function definitions(target: ProxyThis, internals: NodeProxyInternals): Record<string, unknown> {
function definitions(
target: ProxyThis,
internals: NodeProxyInternals
): Record<
string,
{
type: ComponentPropertyType
defaultValue: string | boolean
preferredValues?: Array<{ type: 'COMPONENT' | 'COMPONENT_SET'; key: string }>
}
> {
const node = raw(target, internals)
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return {}
return Object.fromEntries(
@ -137,7 +152,13 @@ function definitions(target: ProxyThis, internals: NodeProxyInternals): Record<s
function componentProperties(
target: ProxyThis,
internals: NodeProxyInternals
): Record<string, unknown> {
): Record<
string,
{
type: ComponentPropertyType
value: string | boolean
}
> {
const node = raw(target, internals)
if (node.type !== 'INSTANCE') return {}
return Object.fromEntries(
@ -200,6 +221,15 @@ function editPropertyDefinitions(
})
return propertyName(updated)
}
function propertyReferenceField(field: string): 'TEXT' | 'VISIBLE' | 'INSTANCE_SWAP' {
if (field === 'mainComponent') return 'INSTANCE_SWAP'
return field === 'characters' ? 'TEXT' : 'VISIBLE'
}
function propertyReferenceName(field: 'TEXT' | 'VISIBLE' | 'INSTANCE_SWAP'): string {
if (field === 'INSTANCE_SWAP') return 'mainComponent'
return field === 'TEXT' ? 'characters' : 'visible'
}
function applyProperty(
target: ProxyThis,
internals: NodeProxyInternals,
@ -207,7 +237,9 @@ function applyProperty(
definition: ComponentPropertyDefinition,
value: string | boolean
): void {
if (definition.type === 'VARIANT' || node.type !== 'INSTANCE') return
if (definition.type === 'VARIANT') {
throw new Error('setProperties() cannot set VARIANT properties through the adapter')
}
applyComponentPropertyValue(graph(target, internals), node.id, definition, String(value))
}
export function installComponentPropertyAccessors(
@ -223,13 +255,31 @@ export function installComponentPropertyAccessors(
componentPropertyReferences: {
get(this: ProxyThis) {
const node = raw(this, internals)
if (node.type !== 'INSTANCE' && node.type !== 'COMPONENT') return null
if (
node.type !== 'INSTANCE' &&
node.type !== 'COMPONENT' &&
node.type !== 'FRAME' &&
node.type !== 'TEXT'
)
return null
return Object.fromEntries(
node.componentPropertyReferences.map((reference) => [
reference.field === 'INSTANCE_SWAP' ? 'mainComponent' : reference.field.toLowerCase(),
propertyReferenceName(reference.field),
reference.propertyId
])
)
},
set(this: ProxyThis, value: Record<string, string> | null) {
if (value === null) {
updateNode(this, internals, { componentPropertyReferences: [] })
return
}
updateNode(this, internals, {
componentPropertyReferences: Object.entries(value).map(([field, propertyId]) => ({
propertyId,
field: propertyReferenceField(field)
}))
})
}
},
componentProperties: {
@ -287,7 +337,8 @@ export function installComponentPropertyAccessors(
throw new Error('setProperties() can only be called on instances')
for (const [name, value] of Object.entries(properties)) {
const definition = findDefinition(this, internals, name)
if (definition) applyProperty(this, internals, node, definition, value)
if (!definition) throw new Error(`Unknown component property: ${name}`)
applyProperty(this, internals, node, definition, value)
}
}
},
@ -304,7 +355,7 @@ export function installComponentPropertyAccessors(
const definition: ComponentPropertyDefinition = {
id: `prop:${randomHex(8)}`,
name: name.trim(),
type: type as SceneNode['componentPropertyDefinitions'][number]['type'],
type,
defaultValue:
type === 'BOOLEAN'
? String(defaultValue === true || defaultValue === 'true')

View file

@ -18,6 +18,40 @@ describe('components', () => {
instance.setProperties({ [propertyName]: 'Updated' })
expect(instance.componentProperties[propertyName]?.value).toBe('Updated')
})
test('supports boolean properties, references, exposure, and CRUD', () => {
const api = createAPI()
const component = api.createComponent()
const label = api.createText()
label.name = 'Label'
component.appendChild(label)
const badge = api.createFrame()
badge.name = 'Badge'
component.appendChild(badge)
const textName = component.addComponentProperty('Label', 'TEXT', 'Default')
const visibleName = component.addComponentProperty('Visible', 'BOOLEAN', true)
label.componentPropertyReferences = { characters: textName }
badge.componentPropertyReferences = { visible: visibleName }
const instance = component.createInstance()
expect(component.componentPropertyReferences).toEqual({})
expect(instance.componentProperties[visibleName]?.value).toBe(true)
expect(badge.componentPropertyReferences).toEqual({ visible: visibleName })
instance.setProperties({ [visibleName]: false })
expect(instance.componentProperties[visibleName]?.value).toBe(false)
const nested = api.createComponent()
const slot = nested.createInstance()
slot.componentPropertyReferences = { mainComponent: textName }
expect(slot.isExposedInstance).toBe(true)
slot.isExposedInstance = false
expect(slot.isExposedInstance).toBe(false)
const editedName = component.editComponentProperty(visibleName, { name: 'Shown' })
expect(editedName).toContain('Shown#')
component.deleteComponentProperty(editedName)
expect(component.componentPropertyDefinitions[editedName]).toBeUndefined()
})
test('createInstance from component', () => {
const api = createAPI()
const comp = api.createComponent()