Merge pull request #611 from open-pencil/figma-component-property-api
feat(figma-api): expose component property accessors
This commit is contained in:
commit
716e40f0fa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue