diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index ccc83c468..e1cfb3545 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -1,6 +1,8 @@ /// import type { FigmaAPI } from './index' +import type { FigmaComponentNode, FigmaComponentSetNode } from './node-types' +import type { FigmaNodeProxy } from './proxy' type Expect = T @@ -35,3 +37,49 @@ export type SupportedPluginAPI = Pick< export type FigmaAPIIncompatibleKeys = IncompatibleKeys export type FigmaAPICompatibility = Expect + +type Extends = Actual extends Expected ? true : false + +type Equal = [Actual] extends [Expected] + ? [Expected] extends [Actual] + ? true + : false + : false + +type ComponentPropertyDefinitionsMatch = Expect< + Equal +> +const _componentPropertyDefinitionsMatch: ComponentPropertyDefinitionsMatch = true + +type ComponentPropertyMethodsMatch = Expect< + Extends +> +const _componentPropertyMethodsMatch: ComponentPropertyMethodsMatch = true + +type ComponentSetPropertyMethodsMatch = Expect< + Extends +> +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 diff --git a/packages/core/src/figma-api/components.ts b/packages/core/src/figma-api/components.ts index 6582b674a..844a1ae4e 100644 --- a/packages/core/src/figma-api/components.ts +++ b/packages/core/src/figma-api/components.ts @@ -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, @@ -89,6 +121,317 @@ function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray { + 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 { + return { + ...(definition.preferredValues + ? { preferredValues: preferredValues(graph(target, internals), definition.preferredValues) } + : {}), + ...(includeVariantOptions && definition.variantOptions + ? { variantOptions: [...definition.variantOptions] } + : {}) + } +} +function definitions( + target: ProxyThis, + internals: NodeProxyInternals +): Record { + 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 | 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) { + 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, diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 0584a2c28..81b53f5da 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -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) diff --git a/tests/engine/figma/api/components.test.ts b/tests/engine/figma/api/components.test.ts index e8b4434f6..038a8a03f 100644 --- a/tests/engine/figma/api/components.test.ts +++ b/tests/engine/figma/api/components.test.ts @@ -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()