Merge pull request #549 from 0xemc/feat/expose-instance-swap
feat(figma-api): expose instance-swap component properties
This commit is contained in:
commit
85ae62cb15
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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