feat(figma-api): add combineAsVariants for real component variant sets
figma.combineAsVariants() was unimplemented in the scripting/automation layer (figma-api/proxy.ts's compatibility surface), even though the editor UI already has a full equivalent — createComponentSetFromComponents() (editor/components.ts) plus wrapSelectionInContainer() (editor/structure/container-wrap.ts) — wired to the app's own "Create component set" menu action. This ports that logic down to FigmaAPI (the class MCP tools, the CLI, and AI chat scripting all run against): wraps selected COMPONENT nodes sharing a parent into a COMPONENT_SET, and derives variant property definitions from "Category/Value" node-name segments, matching the editor behavior exactly. Drops the undo-stack push and selection-state writes the editor path has, since neither concept exists at the scripting layer — everything else is the same primitives (SceneGraph.createNode/insertChildAt/reparentNode), which FigmaAPI already had direct access to. Also adds a matching combine_as_variants tool (packages/core/src/tools/ create/components.ts) registered in EXTENDED_TOOLS, so it's exposed through MCP, the CLI, and AI chat alongside the existing create_component/ create_instance tools.
This commit is contained in:
parent
4b8bf021f6
commit
b01ed830e8
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
ComponentPropertyDefinition,
|
||||
SceneGraph,
|
||||
SceneNode as CoreSceneNode,
|
||||
NodeType,
|
||||
|
|
@ -8,7 +9,7 @@ import type {
|
|||
VariableValue
|
||||
} from '@open-pencil/scene-graph'
|
||||
import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy'
|
||||
import { computeBounds } from '@open-pencil/scene-graph/geometry'
|
||||
import { computeAbsoluteBounds, computeBounds } from '@open-pencil/scene-graph/geometry'
|
||||
import { computeImageHash } from '@open-pencil/scene-graph/images'
|
||||
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ 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 type {
|
||||
|
|
@ -265,6 +267,107 @@ export class FigmaAPI implements NodeProxyHost {
|
|||
return this.wrapNode(comp.id)
|
||||
}
|
||||
|
||||
private _isTopLevel(parentId: string | null): boolean {
|
||||
return !parentId || parentId === this.graph.rootId || parentId === this._currentPageId
|
||||
}
|
||||
|
||||
private _wrapNodesInComponentSet(rawNodes: CoreSceneNode[]): FigmaNodeProxy | null {
|
||||
const parentId = rawNodes[0].parentId ?? this._currentPageId
|
||||
const sameParent = rawNodes.every((n) => (n.parentId ?? this._currentPageId) === parentId)
|
||||
if (!sameParent) return null
|
||||
|
||||
const parent = this.graph.getNode(parentId)
|
||||
if (!parent) return null
|
||||
|
||||
const nodeIds = rawNodes.map((n) => n.id)
|
||||
const {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: bw,
|
||||
height: bh
|
||||
} = computeAbsoluteBounds(rawNodes, (id) => this.graph.getAbsolutePosition(id))
|
||||
const maxX = minX + bw
|
||||
const maxY = minY + bh
|
||||
|
||||
const parentAbs = this._isTopLevel(parentId)
|
||||
? { x: 0, y: 0 }
|
||||
: this.graph.getAbsolutePosition(parentId)
|
||||
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
|
||||
const padding = 40
|
||||
|
||||
const containerNode = this.graph.createNode('COMPONENT_SET', parentId, {
|
||||
name: rawNodes[0].name.split('/')[0]?.trim() || 'Component Set',
|
||||
x: minX - parentAbs.x - padding,
|
||||
y: minY - parentAbs.y - padding,
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY - minY + padding * 2,
|
||||
fills: [{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
|
||||
this.graph.insertChildAt(containerNode.id, parentId, firstIndex)
|
||||
for (const id of nodeIds) {
|
||||
this.graph.reparentNode(id, containerNode.id)
|
||||
}
|
||||
|
||||
return this.wrapNode(containerNode.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps components sharing a common parent into a COMPONENT_SET, deriving
|
||||
* variant properties from `Category/Value` name segments (mirrors the
|
||||
* editor's createComponentSetFromComponents, minus undo/selection state).
|
||||
*/
|
||||
combineAsVariants(nodes: ReadonlyArray<FigmaNodeProxy>): FigmaNodeProxy {
|
||||
if (nodes.length < 2) throw new Error('Need at least 2 components to combine as variants')
|
||||
|
||||
const rawNodes = nodes.map((n) => this.graph.getNode(n[INTERNAL_ID]))
|
||||
if (!rawNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) {
|
||||
throw new Error('combineAsVariants requires COMPONENT nodes')
|
||||
}
|
||||
|
||||
const container = this._wrapNodesInComponentSet(rawNodes)
|
||||
if (!container) throw new Error('Components must share the same parent')
|
||||
|
||||
const slashCounts = rawNodes.map((n) => (n.name.match(/\//g) ?? []).length)
|
||||
const hasConsistentSlashes = slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0
|
||||
|
||||
if (hasConsistentSlashes) {
|
||||
const propCount = slashCounts[0]
|
||||
const propDefs: ComponentPropertyDefinition[] = []
|
||||
const propValues = new Map<string, Set<string>>()
|
||||
|
||||
for (let i = 0; i < propCount; i++) {
|
||||
const propId = `prop:${randomHex(8)}`
|
||||
const propName = i === 0 ? 'Variant' : `Property ${i + 1}`
|
||||
propDefs.push({ id: propId, name: propName, type: 'VARIANT', defaultValue: '' })
|
||||
propValues.set(propName, new Set())
|
||||
}
|
||||
|
||||
for (const node of rawNodes) {
|
||||
const parts = node.name.split('/').slice(1)
|
||||
const values: Record<string, string> = {}
|
||||
for (let i = 0; i < propDefs.length; i++) {
|
||||
const value = parts[i]?.trim() ?? ''
|
||||
values[propDefs[i].name] = value
|
||||
propValues.get(propDefs[i].name)?.add(value)
|
||||
}
|
||||
this.graph.updateNode(node.id, {
|
||||
componentPropertyValues: values,
|
||||
name: Object.values(values).join(', ')
|
||||
})
|
||||
}
|
||||
|
||||
for (const def of propDefs) {
|
||||
def.variantOptions = [...(propValues.get(def.name) ?? [])]
|
||||
if (!def.defaultValue && def.variantOptions[0]) def.defaultValue = def.variantOptions[0]
|
||||
}
|
||||
|
||||
this.graph.updateNode(container[INTERNAL_ID], { componentPropertyDefinitions: propDefs })
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
// --- Variables ---
|
||||
|
||||
getVariableById(id: string): Variable | null {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { createPage, createShape, createSlice } from './create/basic'
|
||||
export { createComponent, createInstance } from './create/components'
|
||||
export { combineAsVariants, createComponent, createInstance } from './create/components'
|
||||
export { fetchIconsTool, insertIcon, searchIconsTool } from './create/icons'
|
||||
export { render } from './create/render'
|
||||
export { importSVG } from './create/svg'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { FigmaNodeProxy } from '#core/figma-api'
|
||||
import { defineTool, nodeSummary } from '#core/tools/schema'
|
||||
|
||||
export const createComponent = defineTool({
|
||||
|
|
@ -33,3 +34,30 @@ export const createInstance = defineTool({
|
|||
return nodeSummary(instance)
|
||||
}
|
||||
})
|
||||
|
||||
export const combineAsVariants = defineTool({
|
||||
name: 'combine_as_variants',
|
||||
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.',
|
||||
params: {
|
||||
ids: { type: 'string[]', description: 'Component node IDs to combine', required: true }
|
||||
},
|
||||
execute: (figma, { ids }) => {
|
||||
const nodes = ids
|
||||
.map((id) => figma.getNodeById(id))
|
||||
.filter((node): node is FigmaNodeProxy => node !== null)
|
||||
if (nodes.length !== ids.length) 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.type === 'COMPONENT')) {
|
||||
return { error: 'combineAsVariants requires COMPONENT nodes' }
|
||||
}
|
||||
try {
|
||||
const componentSet = figma.combineAsVariants(nodes)
|
||||
return nodeSummary(componentSet)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from './analyze'
|
||||
import { designToComponentMap, designToTokens } from './codegen'
|
||||
import {
|
||||
combineAsVariants,
|
||||
createComponent,
|
||||
createInstance,
|
||||
createPage,
|
||||
|
|
@ -126,6 +127,7 @@ export const EXTENDED_TOOLS: ToolDef[] = [
|
|||
fetchIconsTool,
|
||||
createComponent,
|
||||
createInstance,
|
||||
combineAsVariants,
|
||||
createPage,
|
||||
createVector,
|
||||
createSlice,
|
||||
|
|
|
|||
55
tests/engine/figma/api/create/combine-as-variants.test.ts
Normal file
55
tests/engine/figma/api/create/combine-as-variants.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createAPI } from '../helpers'
|
||||
|
||||
describe('combineAsVariants', () => {
|
||||
test('wraps components into a COMPONENT_SET', () => {
|
||||
const api = createAPI()
|
||||
const a = api.createComponent()
|
||||
a.name = 'Button/Primary'
|
||||
a.resize(100, 40)
|
||||
const b = api.createComponent()
|
||||
b.name = 'Button/Secondary'
|
||||
b.resize(100, 40)
|
||||
|
||||
const set = api.combineAsVariants([a, b])
|
||||
|
||||
expect(set.type).toBe('COMPONENT_SET')
|
||||
expect(set.name).toBe('Button')
|
||||
expect(set.children.length).toBe(2)
|
||||
expect(set.children.map((c) => c.name).sort()).toEqual(['Primary', 'Secondary'])
|
||||
})
|
||||
|
||||
test('derives variant property definitions from name segments', () => {
|
||||
const api = createAPI()
|
||||
const a = api.createComponent()
|
||||
a.name = 'State/Default'
|
||||
a.resize(100, 40)
|
||||
const b = api.createComponent()
|
||||
b.name = 'State/Hover'
|
||||
b.resize(100, 40)
|
||||
|
||||
const set = api.combineAsVariants([a, b])
|
||||
const raw = api.graph.getNode(set.id)
|
||||
|
||||
expect(raw?.componentPropertyDefinitions?.length).toBe(1)
|
||||
expect(raw?.componentPropertyDefinitions?.[0].name).toBe('Variant')
|
||||
expect(raw?.componentPropertyDefinitions?.[0].variantOptions?.sort()).toEqual([
|
||||
'Default',
|
||||
'Hover'
|
||||
])
|
||||
})
|
||||
|
||||
test('rejects fewer than 2 nodes', () => {
|
||||
const api = createAPI()
|
||||
const a = api.createComponent()
|
||||
expect(() => api.combineAsVariants([a])).toThrow()
|
||||
})
|
||||
|
||||
test('rejects non-component nodes', () => {
|
||||
const api = createAPI()
|
||||
const a = api.createComponent()
|
||||
const b = api.createFrame()
|
||||
expect(() => api.combineAsVariants([a, b])).toThrow()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue