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:
parent
b01ed830e8
commit
1db92c4603
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
63
packages/core/src/figma-api/components.ts
Normal file
63
packages/core/src/figma-api/components.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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` }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
48
packages/scene-graph/src/variant-properties.ts
Normal file
48
packages/scene-graph/src/variant-properties.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue