Merge pull request #611 from open-pencil/figma-component-property-api

feat(figma-api): expose component property accessors
This commit is contained in:
Danila Poyarkov 2026-08-31 15:05:51 +03:00 committed by GitHub
commit 716e40f0fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 441 additions and 1 deletions

View file

@ -1,6 +1,8 @@
/// <reference types="@figma/plugin-typings" />
import type { FigmaAPI } from './index'
import type { FigmaComponentNode, FigmaComponentSetNode } from './node-types'
import type { FigmaNodeProxy } from './proxy'
type Expect<T extends true> = T
@ -35,3 +37,49 @@ export type SupportedPluginAPI = Pick<
export type FigmaAPIIncompatibleKeys = IncompatibleKeys<FigmaAPI, SupportedPluginAPI>
export type FigmaAPICompatibility = Expect<FigmaAPIIncompatibleKeys extends never ? true : false>
type Extends<Actual, Expected> = Actual extends Expected ? true : false
type Equal<Actual, Expected> = [Actual] extends [Expected]
? [Expected] extends [Actual]
? true
: false
: false
type ComponentPropertyDefinitionsMatch = Expect<
Equal<FigmaComponentNode['componentPropertyDefinitions'], ComponentPropertyDefinitions>
>
const _componentPropertyDefinitionsMatch: ComponentPropertyDefinitionsMatch = true
type ComponentPropertyMethodsMatch = Expect<
Extends<FigmaComponentNode['addComponentProperty'], ComponentNode['addComponentProperty']>
>
const _componentPropertyMethodsMatch: ComponentPropertyMethodsMatch = true
type ComponentSetPropertyMethodsMatch = Expect<
Extends<FigmaComponentSetNode['editComponentProperty'], ComponentSetNode['editComponentProperty']>
>
const _componentSetPropertyMethodsMatch: ComponentSetPropertyMethodsMatch = true
type InstancePropertySurfaceMatch = Expect<
Extends<
Pick<
FigmaNodeProxy & InstanceNode,
| 'componentProperties'
| 'componentPropertyReferences'
| 'setProperties'
| 'isExposedInstance'
| 'exposedInstances'
>,
Pick<
InstanceNode,
| 'componentProperties'
| 'componentPropertyReferences'
| 'setProperties'
| 'isExposedInstance'
| 'exposedInstances'
>
>
>
const _instancePropertySurfaceMatch: InstancePropertySurfaceMatch = true

View file

@ -1,13 +1,45 @@
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,
removeComponentProperty
} from '@open-pencil/scene-graph'
import { computeAbsoluteBounds } from '@open-pencil/scene-graph/geometry'
import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties'
import { randomHex } from '#core/random'
import type { NodeProxyInternals, ProxyThis } from './accessor-utils'
import { graph, raw, updateNode } from './accessor-utils'
import type { FigmaNodeProxy } from './proxy'
type InstanceSwapPreferredValue = { type: 'COMPONENT' | 'COMPONENT_SET'; key: string }
const COMPONENT_SET_PADDING = 40
interface FigmaComponentPropertyDefinition {
type: ComponentPropertyType
defaultValue: string | boolean
preferredValues?: InstanceSwapPreferredValue[]
variantOptions?: string[]
}
interface FigmaComponentProperty {
type: ComponentPropertyType
value: string | boolean
preferredValues?: InstanceSwapPreferredValue[]
variantOptions?: string[]
}
interface FigmaComponentProperties {
[propertyName: string]: FigmaComponentProperty
}
export function exposeInstanceSwap(
graph: SceneGraph,
slots: ReadonlyArray<FigmaNodeProxy>,
@ -89,6 +121,317 @@ function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray<str
return nodes
}
function propertyName(definition: ComponentPropertyDefinition): string {
return definition.type === 'VARIANT' ? definition.name : `${definition.name}#${definition.id}`
}
function preferredValues(graph: SceneGraph, ids: string[]): InstanceSwapPreferredValue[] {
return ids.flatMap((id) => {
const node = graph.getNode(id)
return node && (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET')
? [{ type: node.type, key: node.componentKey ?? node.sourceLibraryKey ?? node.id }]
: []
})
}
function propertyMetadata(
target: ProxyThis,
internals: NodeProxyInternals,
definition: ComponentPropertyDefinition,
includeVariantOptions: boolean
): Pick<FigmaComponentPropertyDefinition, 'preferredValues' | 'variantOptions'> {
return {
...(definition.preferredValues
? { preferredValues: preferredValues(graph(target, internals), definition.preferredValues) }
: {}),
...(includeVariantOptions && definition.variantOptions
? { variantOptions: [...definition.variantOptions] }
: {})
}
}
function definitions(
target: ProxyThis,
internals: NodeProxyInternals
): Record<string, FigmaComponentPropertyDefinition> {
const node = raw(target, internals)
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return {}
return Object.fromEntries(
node.componentPropertyDefinitions.map((definition) => [
propertyName(definition),
{
type: definition.type,
defaultValue:
definition.type === 'BOOLEAN'
? definition.defaultValue === 'true'
: definition.defaultValue,
...propertyMetadata(target, internals, definition, true)
}
])
)
}
function componentProperties(
target: ProxyThis,
internals: NodeProxyInternals
): FigmaComponentProperties {
const node = raw(target, internals)
if (node.type !== 'INSTANCE') return {}
return Object.fromEntries(
sharedComponentPropertyDefinitions(graph(target, internals), node).map((definition) => {
const value = node.componentPropertyAssignments[definition.id] ?? definition.defaultValue
return [
propertyName(definition),
{
type: definition.type,
value: definition.type === 'BOOLEAN' ? value === 'true' : value,
...propertyMetadata(target, internals, definition, false)
}
]
})
)
}
function findDefinition(
target: ProxyThis,
internals: NodeProxyInternals,
name: string
): ComponentPropertyDefinition | null {
const node = raw(target, internals)
const defs =
node.type === 'INSTANCE'
? sharedComponentPropertyDefinitions(graph(target, internals), node)
: node.componentPropertyDefinitions
return (
defs.find(
(definition) =>
propertyName(definition) === name ||
(definition.type === 'VARIANT' && definition.name === name)
) ?? null
)
}
function editPropertyDefinitions(
target: ProxyThis,
internals: NodeProxyInternals,
propertyNameValue: string,
changes: {
name?: string
defaultValue?: string | boolean
preferredValues?: InstanceSwapPreferredValue[]
}
): string {
const node = raw(target, internals)
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET')
throw new Error('editComponentProperty() can only be called on components')
const definition = findDefinition(target, internals, propertyNameValue)
if (!definition) throw new Error(`Unknown component property: ${propertyNameValue}`)
if (
changes.defaultValue !== undefined &&
!['BOOLEAN', 'TEXT', 'INSTANCE_SWAP'].includes(definition.type)
) {
throw new Error(`defaultValue is not supported for ${definition.type} properties`)
}
const updatedName = changes.name?.trim()
if (updatedName === '') throw new Error('Property name must not be empty')
const updated = {
...definition,
...(updatedName ? { name: updatedName } : {}),
...(changes.defaultValue !== undefined
? {
defaultValue:
definition.type === 'BOOLEAN'
? String(changes.defaultValue === true || changes.defaultValue === 'true')
: String(changes.defaultValue)
}
: {}),
...(changes.preferredValues
? { preferredValues: changes.preferredValues.map((value) => value.key) }
: {})
}
updateNode(target, internals, {
componentPropertyDefinitions: node.componentPropertyDefinitions.map((item) =>
item.id === definition.id ? updated : item
)
})
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,
node: SceneNode,
definition: ComponentPropertyDefinition,
value: string | boolean
): void {
if (definition.type === 'VARIANT') {
throw new Error('setProperties() cannot set VARIANT properties through the adapter')
}
const result = applyComponentPropertyValue(
graph(target, internals),
node.id,
definition,
String(value)
)
if (!result) throw new Error(`Unable to apply component property: ${propertyName(definition)}`)
}
export function installComponentPropertyAccessors(
prototype: object,
internals: NodeProxyInternals
): void {
Object.defineProperties(prototype, {
componentPropertyDefinitions: {
get(this: ProxyThis) {
return definitions(this, internals)
}
},
componentPropertyReferences: {
get(this: ProxyThis) {
const node = raw(this, internals)
if (
node.type !== 'INSTANCE' &&
node.type !== 'COMPONENT' &&
node.type !== 'FRAME' &&
node.type !== 'TEXT'
)
return null
return Object.fromEntries(
node.componentPropertyReferences.map((reference) => [
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: {
get(this: ProxyThis) {
return componentProperties(this, internals)
}
},
isExposedInstance: {
get(this: ProxyThis) {
const node = raw(this, internals)
return (
node.type === 'INSTANCE' &&
node.componentPropertyReferences.some((reference) => reference.field === 'INSTANCE_SWAP')
)
},
set(this: ProxyThis, value: boolean) {
const node = raw(this, internals)
if (node.type !== 'INSTANCE')
throw new Error('isExposedInstance is only supported on instances')
if (!value)
updateNode(this, internals, {
componentPropertyReferences: node.componentPropertyReferences.filter(
(reference) => reference.field !== 'INSTANCE_SWAP'
)
})
}
},
exposedInstances: {
get(this: ProxyThis) {
const node = raw(this, internals)
if (node.type !== 'INSTANCE') return []
const result: FigmaNodeProxy[] = []
const visit = (id: string): void => {
const child = graph(this, internals).getNode(id)
if (!child) return
if (
child.type === 'INSTANCE' &&
child.componentPropertyReferences.some(
(reference) => reference.field === 'INSTANCE_SWAP'
)
)
result.push(
(this[internals.api] as { wrapNode(id: string): FigmaNodeProxy }).wrapNode(child.id)
)
child.childIds.forEach(visit)
}
node.childIds.forEach(visit)
return result
}
},
setProperties: {
value(this: ProxyThis, properties: Record<string, string | boolean>) {
const node = raw(this, internals)
if (node.type !== 'INSTANCE')
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) throw new Error(`Unknown component property: ${name}`)
applyProperty(this, internals, node, definition, value)
}
}
},
addComponentProperty: {
value(
this: ProxyThis,
name: string,
type: ComponentPropertyType,
defaultValue: string | boolean,
options?: { preferredValues?: InstanceSwapPreferredValue[] }
) {
const node = raw(this, internals)
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET')
throw new Error('addComponentProperty() can only be called on components')
const definition: ComponentPropertyDefinition = {
id: `prop:${randomHex(8)}`,
name: name.trim(),
type,
defaultValue:
type === 'BOOLEAN'
? String(defaultValue === true || defaultValue === 'true')
: String(defaultValue),
...(options?.preferredValues
? { preferredValues: options.preferredValues.map((value) => value.key) }
: {})
}
updateNode(this, internals, {
componentPropertyDefinitions: [...node.componentPropertyDefinitions, definition]
})
return propertyName(definition)
}
},
editComponentProperty: {
value(
this: ProxyThis,
name: string,
changes: { name?: string; defaultValue?: string | boolean }
) {
return editPropertyDefinitions(this, internals, name, changes)
}
},
deleteComponentProperty: {
value(this: ProxyThis, name: string) {
const node = raw(this, internals)
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET')
throw new Error('deleteComponentProperty() can only be called on components')
const definition = findDefinition(this, internals, name)
if (!definition) throw new Error(`Unknown component property: ${name}`)
removeComponentProperty(graph(this, internals), node.id, definition.id)
}
}
})
}
export function combineComponentsAsVariants(
graph: SceneGraph,
nodeIds: ReadonlyArray<string>,

View file

@ -27,6 +27,7 @@ import {
type FigmaVectorPath
} from './accessors/vector'
import { installVisualNodeProxyAccessors } from './accessors/visual'
import { installComponentPropertyAccessors } from './components'
import type { FigmaFontName } from './fonts'
import { getPageBackgrounds, setPageBackgrounds } from './page-backgrounds'
import * as PluginData from './plugin-data'
@ -596,3 +597,4 @@ const proxyInternals = {
installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
installComponentPropertyAccessors(FigmaNodeProxy.prototype, proxyInternals)

View file

@ -5,6 +5,53 @@ import { expectDefined } from '#tests/helpers/assert'
import { createAPI } from './helpers'
describe('components', () => {
test('exposes component property accessors and applies instance properties', () => {
const api = createAPI()
const component = api.createComponent()
component.name = 'Card'
component.appendChild(Object.assign(api.createText(), { name: 'Label', characters: 'Default' }))
const propertyName = component.addComponentProperty('Label', 'TEXT', 'Default')
const instance = component.createInstance()
expect(component.componentPropertyDefinitions[propertyName]?.defaultValue).toBe('Default')
expect(instance.componentProperties[propertyName]?.value).toBe('Default')
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()