Merge pull request #549 from 0xemc/feat/expose-instance-swap

feat(figma-api): expose instance-swap component properties
This commit is contained in:
Danila Poyarkov 2026-08-29 18:40:32 +03:00 committed by GitHub
commit 85ae62cb15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 221 additions and 7 deletions

View file

@ -1,11 +1,81 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { ComponentPropertyDefinition, SceneGraph, SceneNode } 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 { FigmaNodeProxy } from './proxy'
const COMPONENT_SET_PADDING = 40
export function exposeInstanceSwap(
graph: SceneGraph,
slots: ReadonlyArray<FigmaNodeProxy>,
candidates: ReadonlyArray<FigmaNodeProxy>,
propertyName = 'Instance'
): SceneNode {
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 name = propertyName.trim()
if (!name) throw new Error('Property name must not be empty')
const slotNodes = slots.map((slot) => graph.getNode(slot.id))
if (new Set(slotNodes.map((node) => node?.id)).size !== slotNodes.length)
throw new Error('exposeInstanceSwap requires distinct INSTANCE nodes')
if (!slotNodes.every((node): node is SceneNode => node?.type === 'INSTANCE'))
throw new Error('exposeInstanceSwap requires INSTANCE nodes')
if (
slotNodes.some((node) =>
node.componentPropertyReferences.some((ref) => ref.field === 'INSTANCE_SWAP')
)
)
throw new Error('Instance already has an INSTANCE_SWAP property')
const candidateNodes = candidates.map((candidate) => graph.getNode(candidate.id))
if (
!candidateNodes.every(
(node): node is SceneNode => node?.type === 'COMPONENT' || node?.type === 'COMPONENT_SET'
)
)
throw new Error('Candidates must be COMPONENT or COMPONENT_SET nodes')
const candidateIds = [...new Set(candidateNodes.map((node) => node.id))]
if (candidateIds.length !== candidateNodes.length) throw new Error('Candidates must be distinct')
const host = findPropertyHost(graph, slotNodes[0].parentId)
if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET')
if (host.componentPropertyDefinitions.some((definition) => definition.name === name))
throw new Error(`A component property named "${name}" already exists`)
if (!slotNodes.every((node) => findPropertyHost(graph, node.parentId)?.id === host.id))
throw new Error('All instances must belong to the same component or component set')
const definition: ComponentPropertyDefinition = {
id: `prop:${randomHex(8)}`,
name,
type: 'INSTANCE_SWAP',
defaultValue: slotNodes[0].componentId ?? candidateIds[0],
preferredValues: candidateIds
}
graph.updateNode(host.id, {
componentPropertyDefinitions: [...host.componentPropertyDefinitions, definition]
})
for (const node of slotNodes) {
graph.updateNode(node.id, {
componentPropertyReferences: [
...node.componentPropertyReferences,
{ propertyId: definition.id, field: 'INSTANCE_SWAP' }
]
})
}
return host
}
function findPropertyHost(graph: SceneGraph, nodeId: string | null): SceneNode | null {
let current = nodeId ? graph.getNode(nodeId) : null
let fallback: SceneNode | null = null
while (current) {
if (current.type === 'COMPONENT_SET') return current
if (current.type === 'COMPONENT' && !fallback) fallback = current
current = current.parentId ? graph.getNode(current.parentId) : null
}
return fallback
}
function requireDistinctComponents(graph: SceneGraph, nodeIds: ReadonlyArray<string>): SceneNode[] {
if (nodeIds.length === 0) throw new Error('Need at least 1 component to combine as variants')
if (new Set(nodeIds).size !== nodeIds.length) {

View file

@ -20,7 +20,7 @@ import { IS_BROWSER } from '#core/constants'
import type { RasterExportFormat } from '#core/io/formats/raster'
import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status'
import { combineComponentsAsVariants } from './components'
import { combineComponentsAsVariants, exposeInstanceSwap } from './components'
import type {
FigmaBooleanOperationNode,
FigmaComponentNode,
@ -294,7 +294,14 @@ export class FigmaAPI implements NodeProxyHost {
return this.wrapNode(componentSet.id) as FigmaComponentSetNode
}
// --- Variables ---
exposeInstanceSwap(
slots: ReadonlyArray<FigmaNodeProxy>,
candidates: ReadonlyArray<FigmaNodeProxy>,
propertyName = 'Instance'
): FigmaNodeProxy {
const host = exposeInstanceSwap(this.graph, slots, candidates, propertyName)
return this.wrapNode(host.id)
}
getVariableById(id: string): Variable | null {
return this.graph.variables.get(id) ?? null

View file

@ -1,5 +1,10 @@
export { createPage, createShape, createSlice } from './create/basic'
export { combineAsVariants, createComponent, createInstance } from './create/components'
export {
combineAsVariants,
createComponent,
createInstance,
exposeInstanceSwap
} from './create/components'
export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons'
export { render } from './create/render'
export { importSVG } from './create/svg'

View file

@ -1,4 +1,4 @@
import type { FigmaComponentNode } from '#core/figma-api'
import type { FigmaComponentNode, FigmaNodeProxy } from '#core/figma-api'
import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema'
export const createComponent = defineTool({
@ -63,3 +63,35 @@ export const combineAsVariants = defineTool({
}
}
})
export const exposeInstanceSwap = defineTool({
name: 'expose_instance_swap',
mutates: true,
description: 'Expose nested instances as an instance-swap slot on their component.',
params: {
instance_ids: { type: 'string[]', description: 'Instance node IDs', required: true },
candidate_ids: {
type: 'string[]',
description: 'Candidate component node IDs',
required: true
},
property_name: { type: 'string', description: 'Property name' }
},
execute: (figma, { instance_ids, candidate_ids, property_name }) => {
const slots = instance_ids
.map((id) => figma.getNodeById(id))
.filter((node): node is FigmaNodeProxy => node !== 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((node): node is FigmaNodeProxy => node !== null)
if (candidates.length !== candidate_ids.length)
return { error: 'One or more candidate IDs were not found' }
try {
return nodeSummary(figma.exposeInstanceSwap(slots, candidates, property_name))
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
}
})

View file

@ -9,13 +9,14 @@ import {
} from './analyze'
import { designToComponentMap, designToTokens } from './codegen'
import {
combineAsVariants,
createComponent,
createInstance,
createPage,
createShape,
createSlice,
createVector,
exposeInstanceSwap,
combineAsVariants,
fetchIconsTool,
importSVG,
insertIcon,
@ -125,9 +126,10 @@ export const EXTENDED_TOOLS: ToolDef[] = [
searchIconsTool,
insertIcon,
fetchIconsTool,
combineAsVariants,
createComponent,
createInstance,
combineAsVariants,
exposeInstanceSwap,
createPage,
createVector,
createSlice,

View 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()
})
})