feat(figma-api): add exposeInstanceSwap for real instance-swap component properties
Adds figma.exposeInstanceSwap(slots, candidates, propertyName), which exposes one or more nested instances as an instance-swap slot — the enclosing component (or component set, walking up past intermediate variant members) gets an INSTANCE_SWAP property offering the given candidates, and each slot instance is tagged to respond to it. Mirrors Figma's "Create component property > Instance swap", letting a designer pick which component fills a slot (e.g. an icon on a button) instead of the slot being baked-in static content. Exposed as the expose_instance_swap MCP/CLI/chat tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d178a7fd70
commit
7dc46bb993
|
|
@ -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<FigmaComponentNode>,
|
||||
parent: FigmaNodeProxy,
|
||||
index?: number
|
||||
): FigmaComponentSetNode
|
||||
combineAsVariants(
|
||||
nodes: ReadonlyArray<ComponentNode>,
|
||||
parent: BaseNode & ChildrenMixin,
|
||||
index?: number
|
||||
): ComponentSetNode
|
||||
combineAsVariants(
|
||||
nodes: ReadonlyArray<ComponentNode | FigmaComponentNode>,
|
||||
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<FigmaNodeProxy>,
|
||||
candidates: ReadonlyArray<FigmaNodeProxy>,
|
||||
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 ---
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
98
tests/engine/figma/api/create/expose-instance-swap.test.ts
Normal file
98
tests/engine/figma/api/create/expose-instance-swap.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue