fix(figma-api): align variant combination behavior

- Match the Figma parent and insertion-index API contract\n- Reject duplicate component references before graph mutation\n- Share slash-name variant derivation with editor actions\n- Add structural regression coverage and changelog entry
This commit is contained in:
Danila Poyarkov 2026-08-19 16:11:51 +03:00
parent b01ed830e8
commit 1db92c4603
12 changed files with 216 additions and 160 deletions

View file

@ -11,6 +11,8 @@
- Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu.
- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings.
- Open multiple selected design files in separate tabs.
- Let Figma API scripts and automation combine components into variant sets.
- Add deterministic two-browser collaboration coverage for bidirectional edits, awareness, departure cleanup, partitioned-peer convergence, and reconnect synchronization without public network dependencies. (#530)
- Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout.
- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491)
- Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130)

View file

@ -1,4 +1,5 @@
import type { ComponentPropertyDefinition, SceneNode } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { deriveSlashVariantProperties } from '@open-pencil/scene-graph/variant-properties'
import { randomHex } from '#core/random'
@ -61,43 +62,13 @@ export function createComponentActions(ctx: EditorContext) {
const containerId = wrapSelectionInContainer('COMPONENT_SET', selectedNodes)
if (!containerId) return
const slashCounts = selectedNodes.map((n) => (n.name.match(/\//g) ?? []).length)
const hasConsistentSlashes =
slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0
const derived = deriveSlashVariantProperties(selectedNodes, () => `prop:${randomHex(8)}`)
if (!derived) return
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 selectedNodes) {
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)
}
ctx.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]
}
ctx.graph.updateNode(containerId, { componentPropertyDefinitions: propDefs })
for (const [nodeId, changes] of derived.variants) {
ctx.graph.updateNode(nodeId, changes)
}
ctx.graph.updateNode(containerId, { componentPropertyDefinitions: derived.definitions })
}
const focusActions = createComponentFocusActions(ctx)

View file

@ -0,0 +1,63 @@
import type { 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'
const COMPONENT_SET_PADDING = 40
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) {
throw new Error('combineAsVariants requires distinct COMPONENT nodes')
}
const nodes = nodeIds.map((id) => graph.getNode(id))
if (!nodes.every((node): node is SceneNode => node?.type === 'COMPONENT')) {
throw new Error('combineAsVariants requires COMPONENT nodes')
}
return nodes
}
export function combineComponentsAsVariants(
graph: SceneGraph,
nodeIds: ReadonlyArray<string>,
parentId: string,
index?: number
): SceneNode {
const components = requireDistinctComponents(graph, nodeIds)
const parent = graph.getNode(parentId)
if (!parent) throw new Error('Parent node not found')
const bounds = computeAbsoluteBounds(components, (id) => graph.getAbsolutePosition(id))
const parentPosition =
parentId === graph.rootId || parent.type === 'CANVAS'
? { x: 0, y: 0 }
: graph.getAbsolutePosition(parentId)
const componentSet = graph.createNode('COMPONENT_SET', parentId, {
name: components[0].name.split('/')[0]?.trim() || 'Component Set',
x: bounds.x - parentPosition.x - COMPONENT_SET_PADDING,
y: bounds.y - parentPosition.y - COMPONENT_SET_PADDING,
width: bounds.width + COMPONENT_SET_PADDING * 2,
height: bounds.height + COMPONENT_SET_PADDING * 2,
fills: [
{
type: 'SOLID',
color: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
opacity: 1,
visible: true
}
]
})
for (const component of components) graph.reparentNode(component.id, componentSet.id)
if (index !== undefined) graph.reorderChild(componentSet.id, parentId, index)
const derived = deriveSlashVariantProperties(components, () => `prop:${randomHex(8)}`)
if (derived) {
for (const [nodeId, changes] of derived.variants) graph.updateNode(nodeId, changes)
graph.updateNode(componentSet.id, { componentPropertyDefinitions: derived.definitions })
}
return componentSet
}

View file

@ -1,5 +1,4 @@
import type {
ComponentPropertyDefinition,
SceneGraph,
SceneNode as CoreSceneNode,
NodeType,
@ -9,7 +8,7 @@ import type {
VariableValue
} from '@open-pencil/scene-graph'
import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy'
import { computeAbsoluteBounds, computeBounds } from '@open-pencil/scene-graph/geometry'
import { 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'
@ -19,12 +18,13 @@ 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,6 +51,7 @@ export { FigmaNodeProxy } from './proxy'
export type {
FigmaBooleanOperationNode,
FigmaComponentNode,
FigmaComponentSetNode,
FigmaEllipseNode,
FigmaFrameNode,
FigmaGroupNode,
@ -267,105 +268,28 @@ 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
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
}
// --- Variables ---

View file

@ -11,6 +11,7 @@ export type FigmaVectorNode = FigmaNodeProxy & VectorNode
export type FigmaPolygonNode = FigmaNodeProxy & PolygonNode
export type FigmaStarNode = FigmaNodeProxy & StarNode
export type FigmaComponentNode = FigmaNodeProxy & ComponentNode
export type FigmaComponentSetNode = FigmaNodeProxy & ComponentSetNode
export type FigmaSectionNode = FigmaNodeProxy & SectionNode
export type FigmaGroupNode = FigmaNodeProxy & GroupNode
export type FigmaBooleanOperationNode = FigmaNodeProxy & BooleanOperationNode

View file

@ -1,5 +1,5 @@
import type { FigmaNodeProxy } from '#core/figma-api'
import { defineTool, nodeSummary } from '#core/tools/schema'
import type { FigmaComponentNode } from '#core/figma-api'
import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema'
export const createComponent = defineTool({
name: 'create_component',
@ -45,16 +45,15 @@ export const combineAsVariants = defineTool({
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' }
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.type === 'COMPONENT')) {
if (!nodes.every((node): node is FigmaComponentNode => node.type === 'COMPONENT')) {
return { error: 'combineAsVariants requires COMPONENT nodes' }
}
const parent = nodes[0].parent ?? figma.currentPage
try {
const componentSet = figma.combineAsVariants(nodes)
const componentSet = figma.combineAsVariants(nodes, parent)
return nodeSummary(componentSet)
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }

View file

@ -71,6 +71,16 @@ export function requireNode(figma: FigmaAPI, id: string): ReturnType<FigmaAPI['g
return node
}
export function requireNodes(figma: FigmaAPI, ids: ReadonlyArray<string>): FigmaNodeProxy[] | null {
const nodes: FigmaNodeProxy[] = []
for (const id of ids) {
const node = figma.getNodeById(id)
if (!node) return null
nodes.push(node)
}
return nodes
}
export function nodeNotFound(id: string): { error: string } {
return { error: `Node "${id}" not found` }
}

View file

@ -1,5 +1,4 @@
import type { FigmaNodeProxy } from '#core/figma-api'
import { defineTool, nodeSummary } from '#core/tools/schema'
import { defineTool, nodeSummary, requireNodes } from '#core/tools/schema'
export const reparentNode = defineTool({
name: 'reparent_node',
@ -27,10 +26,8 @@ export const groupNodes = defineTool({
ids: { type: 'string[]', description: 'Node IDs to group', required: true }
},
execute: (figma, { ids }) => {
const nodes = ids
.map((id) => figma.getNodeById(id))
.filter((node): node is FigmaNodeProxy => node !== null)
if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' }
const nodes = requireNodes(figma, ids)
if (!nodes || nodes.length < 2) return { error: 'Need at least 2 nodes to group' }
const parent = nodes[0].parent ?? figma.currentPage
const group = figma.group(nodes, parent)
return nodeSummary(group)

View file

@ -64,6 +64,12 @@
"import": "./dist/variant-name.js",
"default": "./dist/variant-name.js"
},
"./variant-properties": {
"types": "./dist/variant-properties.d.ts",
"bun": "./src/variant-properties.ts",
"import": "./dist/variant-properties.js",
"default": "./dist/variant-properties.js"
},
"./vector-network": {
"types": "./dist/vector-network.d.ts",
"bun": "./src/vector-network.ts",

View file

@ -0,0 +1,48 @@
import type { ComponentPropertyDefinition, SceneNode } from './types'
export interface DerivedVariantProperties {
definitions: ComponentPropertyDefinition[]
variants: Map<string, Pick<SceneNode, 'componentPropertyValues' | 'name'>>
}
export function deriveSlashVariantProperties(
components: ReadonlyArray<Pick<SceneNode, 'id' | 'name'>>,
createPropertyId: () => string
): DerivedVariantProperties | null {
const slashCounts = components.map((component) => (component.name.match(/\//g) ?? []).length)
const slashCount = slashCounts[0] ?? 0
if (slashCount === 0 || !slashCounts.every((count) => count === slashCount)) return null
const definitions: ComponentPropertyDefinition[] = Array.from(
{ length: slashCount },
(_, index) => ({
id: createPropertyId(),
name: index === 0 ? 'Variant' : `Property ${index + 1}`,
type: 'VARIANT',
defaultValue: ''
})
)
const options = new Map(definitions.map((definition) => [definition.name, new Set<string>()]))
const variants = new Map<string, Pick<SceneNode, 'componentPropertyValues' | 'name'>>()
for (const component of components) {
const parts = component.name.split('/').slice(1)
const componentPropertyValues: Record<string, string> = {}
for (const [index, definition] of definitions.entries()) {
const value = parts[index]?.trim() ?? ''
componentPropertyValues[definition.name] = value
options.get(definition.name)?.add(value)
}
variants.set(component.id, {
componentPropertyValues,
name: Object.values(componentPropertyValues).join(', ')
})
}
for (const definition of definitions) {
definition.variantOptions = [...(options.get(definition.name) ?? [])]
definition.defaultValue = definition.variantOptions[0] ?? ''
}
return { definitions, variants }
}

View file

@ -15,6 +15,7 @@ export default defineConfig({
undo: './src/undo.ts',
variables: './src/variables.ts',
'variant-name': './src/variant-name.ts',
'variant-properties': './src/variant-properties.ts',
'vector-network': './src/vector-network.ts',
types: './src/types.ts',
primitives: './src/primitives.ts',

View file

@ -12,12 +12,12 @@ describe('combineAsVariants', () => {
b.name = 'Button/Secondary'
b.resize(100, 40)
const set = api.combineAsVariants([a, b])
const set = api.combineAsVariants([a, b], api.currentPage)
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'])
expect(set.children.map((child) => child.name).sort()).toEqual(['Primary', 'Secondary'])
})
test('derives variant property definitions from name segments', () => {
@ -29,7 +29,7 @@ describe('combineAsVariants', () => {
b.name = 'State/Hover'
b.resize(100, 40)
const set = api.combineAsVariants([a, b])
const set = api.combineAsVariants([a, b], api.currentPage)
const raw = api.graph.getNode(set.id)
expect(raw?.componentPropertyDefinitions?.length).toBe(1)
@ -40,16 +40,50 @@ describe('combineAsVariants', () => {
])
})
test('rejects fewer than 2 nodes', () => {
test('accepts one component and an explicit destination', () => {
const api = createAPI()
const destination = api.createFrame()
const component = api.createComponent()
const set = api.combineAsVariants([component], destination)
expect(set.parent?.id).toBe(destination.id)
expect(set.children.map((child) => child.id)).toEqual([component.id])
})
test('uses the requested insertion index and preserves absolute positions', () => {
const api = createAPI()
const before = api.createFrame()
const a = api.createComponent()
expect(() => api.combineAsVariants([a])).toThrow()
a.x = 120
a.y = 80
const b = api.createComponent()
b.x = 260
b.y = 160
const originalPositions = [a.absoluteTransform, b.absoluteTransform]
const set = api.combineAsVariants([a, b], api.currentPage, 0)
expect(api.currentPage.children[0].id).toBe(set.id)
expect(before.parent?.id).toBe(api.currentPage.id)
expect([a.absoluteTransform, b.absoluteTransform]).toEqual(originalPositions)
})
test('rejects an empty list', () => {
const api = createAPI()
expect(() => api.combineAsVariants([], api.currentPage)).toThrow()
})
test('rejects duplicate component references', () => {
const api = createAPI()
const component = api.createComponent()
expect(() => api.combineAsVariants([component, component], api.currentPage)).toThrow('distinct')
})
test('rejects non-component nodes', () => {
const api = createAPI()
const a = api.createComponent()
const b = api.createFrame()
expect(() => api.combineAsVariants([a, b])).toThrow()
const component = api.createComponent()
const frame = api.createFrame()
expect(() => api.combineAsVariants([component, frame], api.currentPage)).toThrow()
})
})