diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index e7d4c7531..c4e665584 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,4 +1,5 @@ import type { + ComponentPropertyDefinition, SceneGraph, SceneNode as CoreSceneNode, NodeType, @@ -18,13 +19,12 @@ import { canMakeBooleanSourceNode } from '#core/canvas/boolean' import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { IS_BROWSER } from '#core/constants' import type { RasterExportFormat } from '#core/io/formats/raster' +import { randomHex } from '#core/random' import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status' -import { combineComponentsAsVariants } from './components' import type { FigmaBooleanOperationNode, FigmaComponentNode, - FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -51,7 +51,6 @@ export { FigmaNodeProxy } from './proxy' export type { FigmaBooleanOperationNode, FigmaComponentNode, - FigmaComponentSetNode, FigmaEllipseNode, FigmaFrameNode, FigmaGroupNode, @@ -253,8 +252,6 @@ export class FigmaAPI implements NodeProxyHost { layoutMode: raw.layoutMode, primaryAxisAlign: raw.primaryAxisAlign, counterAxisAlign: raw.counterAxisAlign, - primaryAxisSizing: raw.primaryAxisSizing, - counterAxisSizing: raw.counterAxisSizing, itemSpacing: raw.itemSpacing, paddingTop: raw.paddingTop, paddingRight: raw.paddingRight, @@ -270,28 +267,70 @@ export class FigmaAPI implements NodeProxyHost { return this.wrapNode(comp.id) } - combineAsVariants( - nodes: ReadonlyArray, - parent: FigmaNodeProxy, - index?: number - ): FigmaComponentSetNode - combineAsVariants( - nodes: ReadonlyArray, - parent: BaseNode & ChildrenMixin, - index?: number - ): ComponentSetNode - combineAsVariants( - nodes: ReadonlyArray, - parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, - index?: number - ): FigmaComponentSetNode { - const componentSet = combineComponentsAsVariants( - this.graph, - nodes.map((node) => this._nodeId(node)), - this._nodeId(parent), - index - ) - return this.wrapNode(componentSet.id) as FigmaComponentSetNode + private _findPropertyHost(nodeId: string | null): CoreSceneNode | null { + let current = nodeId ? this.graph.getNode(nodeId) : null + let fallbackComponent: CoreSceneNode | null = null + while (current) { + if (current.type === 'COMPONENT_SET') return current + if (current.type === 'COMPONENT' && !fallbackComponent) fallbackComponent = current + current = current.parentId ? this.graph.getNode(current.parentId) : null + } + return fallbackComponent + } + + /** + * Exposes one or more nested instances as an instance-swap slot: the + * enclosing component (or component set) gets an INSTANCE_SWAP property + * offering `candidates`, and each slot instance is tagged to respond to it + * — mirrors Figma's "Create component property > Instance swap". + */ + exposeInstanceSwap( + slots: ReadonlyArray, + candidates: ReadonlyArray, + propertyName = 'Instance' + ): FigmaNodeProxy { + if (slots.length === 0) throw new Error('Provide at least one instance to expose') + if (candidates.length === 0) throw new Error('Provide at least one candidate component') + + const slotNodes = slots.map((s) => this.graph.getNode(s[INTERNAL_ID])) + if (!slotNodes.every((n): n is CoreSceneNode => n?.type === 'INSTANCE')) { + throw new Error('exposeInstanceSwap requires INSTANCE nodes') + } + + const candidateNodes = candidates.map((c) => this.graph.getNode(c[INTERNAL_ID])) + if (!candidateNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) { + throw new Error('Candidates must be COMPONENT nodes') + } + const candidateIds = candidateNodes.map((n) => n.id) + + const host = this._findPropertyHost(slotNodes[0].parentId) + if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET') + const sameHost = slotNodes.every((n) => this._findPropertyHost(n.parentId)?.id === host.id) + if (!sameHost) throw new Error('All instances must belong to the same component or component set') + + const propId = `prop:${randomHex(8)}` + const propDef: ComponentPropertyDefinition = { + id: propId, + name: propertyName, + type: 'INSTANCE_SWAP', + defaultValue: slotNodes[0].componentId ?? candidateIds[0], + preferredValues: candidateIds + } + + this.graph.updateNode(host.id, { + componentPropertyDefinitions: [...host.componentPropertyDefinitions, propDef] + }) + + for (const node of slotNodes) { + this.graph.updateNode(node.id, { + componentPropertyReferences: [ + ...node.componentPropertyReferences.filter((r) => r.field !== 'INSTANCE_SWAP'), + { propertyId: propId, field: 'INSTANCE_SWAP' } + ] + }) + } + + return this.wrapNode(host.id) } // --- Variables --- diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index 548ff939e..a45c44c19 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -1,5 +1,5 @@ export { createPage, createShape, createSlice } from './create/basic' -export { combineAsVariants, createComponent, createInstance } from './create/components' +export { createComponent, createInstance, exposeInstanceSwap } from './create/components' export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons' export { render } from './create/render' export { importSVG } from './create/svg' diff --git a/packages/core/src/tools/create/components.ts b/packages/core/src/tools/create/components.ts index c91bfe898..e72738b7f 100644 --- a/packages/core/src/tools/create/components.ts +++ b/packages/core/src/tools/create/components.ts @@ -1,5 +1,5 @@ -import type { FigmaComponentNode } from '#core/figma-api' -import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema' +import type { FigmaNodeProxy } from '#core/figma-api' +import { defineTool, nodeSummary } from '#core/tools/schema' export const createComponent = defineTool({ name: 'create_component', @@ -35,29 +35,36 @@ export const createInstance = defineTool({ } }) -export const combineAsVariants = defineTool({ - name: 'combine_as_variants', +export const exposeInstanceSwap = defineTool({ + name: 'expose_instance_swap', mutates: true, description: - 'Combine components sharing a parent into a component set (variant set). Components named ' + - '"Category/Value" (e.g. "Button/Primary") derive variant properties from the name segments.', + 'Expose one or more nested instances as an instance-swap slot, so instances of the enclosing ' + + 'component can pick which component fills it (e.g. an icon slot on a button). All slot instances ' + + 'must live inside the same component or component set.', params: { - ids: { type: 'string[]', description: 'Component node IDs to combine', required: true } + instance_ids: { + type: 'string[]', + description: 'Instance node IDs to expose as the swap slot (one per variant that has this slot)', + required: true + }, + candidate_ids: { + type: 'string[]', + description: 'Component node IDs the designer can swap the slot to', + required: true + }, + property_name: { type: 'string', description: 'Name for the property (default: "Instance")' } }, - execute: (figma, { ids }) => { - const nodes = requireNodes(figma, ids) - if (!nodes) return { error: 'One or more node IDs were not found' } - if (nodes.length < 2) return { error: 'Need at least 2 components to combine as variants' } - if (!nodes.every((node): node is FigmaComponentNode => node.type === 'COMPONENT')) { - return { error: 'combineAsVariants requires COMPONENT nodes' } - } - const parent = nodes[0].parent ?? figma.currentPage - if (!nodes.every((node) => node.parent?.id === parent.id)) { - return { error: 'combineAsVariants requires components to share a parent' } - } + execute: (figma, { instance_ids, candidate_ids, property_name }) => { + const slots = instance_ids.map((id) => figma.getNodeById(id)).filter((n): n is FigmaNodeProxy => n !== null) + if (slots.length !== instance_ids.length) return { error: 'One or more instance IDs were not found' } + const candidates = candidate_ids + .map((id) => figma.getNodeById(id)) + .filter((n): n is FigmaNodeProxy => n !== null) + if (candidates.length !== candidate_ids.length) return { error: 'One or more candidate IDs were not found' } try { - const componentSet = figma.combineAsVariants(nodes, parent) - return nodeSummary(componentSet) + const host = figma.exposeInstanceSwap(slots, candidates, property_name) + return nodeSummary(host) } catch (error) { return { error: error instanceof Error ? error.message : String(error) } } diff --git a/packages/core/src/tools/registry-extended.ts b/packages/core/src/tools/registry-extended.ts index 9d9cb38f1..6dc98b02a 100644 --- a/packages/core/src/tools/registry-extended.ts +++ b/packages/core/src/tools/registry-extended.ts @@ -9,13 +9,13 @@ import { } from './analyze' import { designToComponentMap, designToTokens } from './codegen' import { - combineAsVariants, createComponent, createInstance, createPage, createShape, createSlice, createVector, + exposeInstanceSwap, fetchIconsTool, importSVG, insertIcon, @@ -127,7 +127,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [ fetchIconsTool, createComponent, createInstance, - combineAsVariants, + exposeInstanceSwap, createPage, createVector, createSlice, diff --git a/tests/engine/figma/api/create/expose-instance-swap.test.ts b/tests/engine/figma/api/create/expose-instance-swap.test.ts new file mode 100644 index 000000000..89905647e --- /dev/null +++ b/tests/engine/figma/api/create/expose-instance-swap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from '../helpers' + +describe('exposeInstanceSwap', () => { + test('adds an INSTANCE_SWAP property to the host component and tags the slot', () => { + const api = createAPI() + const host = api.createComponent() + host.name = 'Button' + host.resize(120, 40) + + const iconA = api.createComponent() + iconA.name = 'Icon/A' + iconA.resize(16, 16) + const iconB = api.createComponent() + iconB.name = 'Icon/B' + iconB.resize(16, 16) + + const slot = iconA.createInstance() + host.appendChild(slot) + + const result = api.exposeInstanceSwap([slot], [iconA, iconB], 'Icon') + const raw = api.graph.getNode(result.id) + + expect(result.id).toBe(host.id) + expect(raw?.componentPropertyDefinitions?.length).toBe(1) + expect(raw?.componentPropertyDefinitions?.[0].type).toBe('INSTANCE_SWAP') + expect(raw?.componentPropertyDefinitions?.[0].preferredValues?.slice().sort()).toEqual( + [iconA.id, iconB.id].sort() + ) + + const slotRaw = api.graph.getNode(slot.id) + expect(slotRaw?.componentPropertyReferences?.length).toBe(1) + expect(slotRaw?.componentPropertyReferences?.[0].field).toBe('INSTANCE_SWAP') + }) + + test('shares one property across slots in different variants of the same set', () => { + const api = createAPI() + const a = api.createComponent() + a.name = 'Primary' + a.resize(120, 40) + const b = api.createComponent() + b.name = 'Secondary' + b.resize(120, 40) + + const icon = api.createComponent() + icon.name = 'Icon/A' + icon.resize(16, 16) + + const slotA = icon.createInstance() + a.appendChild(slotA) + const slotB = icon.createInstance() + b.appendChild(slotB) + + const set = api.graph.createNode('COMPONENT_SET', api.currentPageId, { name: 'Button' }) + api.graph.reparentNode(a.id, set.id) + api.graph.reparentNode(b.id, set.id) + const result = api.exposeInstanceSwap([slotA, slotB], [icon], 'Icon') + + expect(result.id).toBe(set.id) + const raw = api.graph.getNode(set.id) + expect(raw?.componentPropertyDefinitions?.some((d) => d.type === 'INSTANCE_SWAP')).toBe(true) + }) + + test('rejects non-instance slots', () => { + const api = createAPI() + const host = api.createComponent() + const icon = api.createComponent() + const frame = api.createFrame() + host.appendChild(frame) + expect(() => api.exposeInstanceSwap([frame], [icon])).toThrow() + }) + + test('rejects non-component candidates', () => { + const api = createAPI() + const host = api.createComponent() + const icon = api.createComponent() + const slot = icon.createInstance() + host.appendChild(slot) + const notAComponent = api.createFrame() + expect(() => api.exposeInstanceSwap([slot], [notAComponent])).toThrow() + }) + + test('rejects slots from unrelated hosts', () => { + const api = createAPI() + const hostA = api.createComponent() + hostA.resize(100, 40) + const hostB = api.createComponent() + hostB.resize(100, 40) + const icon = api.createComponent() + icon.resize(16, 16) + const slotA = icon.createInstance() + hostA.appendChild(slotA) + const slotB = icon.createInstance() + hostB.appendChild(slotB) + expect(() => api.exposeInstanceSwap([slotA, slotB], [icon])).toThrow() + }) +})