From bf57a429d94db7fd9951335a75e940cdbfec84e6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 17 May 2026 17:11:15 +0300 Subject: [PATCH] refactor(figma-api): align grouping API signatures --- packages/core/src/figma-api/compatibility.ts | 14 +- packages/core/src/figma-api/index.ts | 148 ++++++++++++++---- packages/core/src/figma-api/node-types.ts | 2 + .../core/src/scene-graph/node-defaults.ts | 1 + packages/core/src/scene-graph/types.ts | 2 + .../engine/figma/api/grouping-compat.test.ts | 62 ++++++++ 6 files changed, 196 insertions(+), 33 deletions(-) create mode 100644 tests/engine/figma/api/grouping-compat.test.ts diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index 9b41b559c..ccc83c468 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -4,7 +4,9 @@ import type { FigmaAPI } from './index' type Expect = T -type IsAssignable = Actual extends Expected ? true : false +type IncompatibleKeys = { + [K in keyof Expected]: K extends keyof Actual ? (Actual[K] extends Expected[K] ? never : K) : K +}[keyof Expected] export type SupportedPluginAPI = Pick< PluginAPI, @@ -22,6 +24,14 @@ export type SupportedPluginAPI = Pick< | 'createStar' | 'createText' | 'createVector' + | 'exclude' + | 'flatten' + | 'group' + | 'intersect' + | 'subtract' + | 'ungroup' + | 'union' > -export type FigmaAPICompatibility = Expect> +export type FigmaAPIIncompatibleKeys = IncompatibleKeys +export type FigmaAPICompatibility = Expect diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 2a84c43ad..5235545f5 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -13,9 +13,11 @@ import { copyFills, copyStrokes, copyEffects } from '#core/scene-graph/copy' import type { Rect, Vector } from '#core/types' import type { + FigmaBooleanOperationNode, FigmaComponentNode, FigmaEllipseNode, FigmaFrameNode, + FigmaGroupNode, FigmaLineNode, FigmaPolygonNode, FigmaRectangleNode, @@ -37,9 +39,11 @@ const noop = () => undefined export { FigmaNodeProxy } from './proxy' export type { + FigmaBooleanOperationNode, FigmaComponentNode, FigmaEllipseNode, FigmaFrameNode, + FigmaGroupNode, FigmaLineNode, FigmaPolygonNode, FigmaRectangleNode, @@ -202,22 +206,39 @@ export class FigmaAPI implements NodeProxyHost { // --- Grouping --- - group(nodes: FigmaNodeProxy[], parent: FigmaNodeProxy): FigmaNodeProxy { - const groupNode = this.graph.createNode('GROUP', parent[INTERNAL_ID]) - for (const n of nodes) { - this.graph.reparentNode(n[INTERNAL_ID], groupNode.id) - } - return this.wrapNode(groupNode.id) + private _nodeId(node: BaseNode | FigmaNodeProxy): string { + return (node as BaseNode & { [INTERNAL_ID]: string })[INTERNAL_ID] } - ungroup(node: FigmaNodeProxy): void { - const raw = this.graph.getNode(node[INTERNAL_ID]) - if (raw?.type !== 'GROUP') return + group(nodes: ReadonlyArray, parent: FigmaNodeProxy, index?: number): FigmaGroupNode + group(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): GroupNode + group( + nodes: ReadonlyArray, + parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, + index?: number + ): FigmaGroupNode { + const parentId = this._nodeId(parent) + const groupNode = this.graph.createNode('GROUP', parentId) + for (const n of nodes) { + this.graph.reparentNode(this._nodeId(n), groupNode.id) + } + if (index != null) this.graph.reorderChild(groupNode.id, parentId, index) + return this.wrapNode(groupNode.id) as FigmaGroupNode + } + + ungroup(node: FigmaNodeProxy): FigmaNodeProxy[] + ungroup(node: SceneNode & ChildrenMixin): Array + ungroup(node: (SceneNode & ChildrenMixin) | FigmaNodeProxy): Array | FigmaNodeProxy[] { + const nodeId = this._nodeId(node) + const raw = this.graph.getNode(nodeId) + if (!raw || raw.childIds.length === 0) return [] const parentId = raw.parentId ?? this._currentPageId - for (const childId of Array.from(raw.childIds)) { + const children = Array.from(raw.childIds) + for (const childId of children) { this.graph.reparentNode(childId, parentId) } - this.graph.deleteNode(node[INTERNAL_ID]) + this.graph.deleteNode(nodeId) + return children.map((id) => this.wrapNode(id)) } createComponentFromNode(node: FigmaNodeProxy): FigmaNodeProxy { @@ -318,35 +339,93 @@ export class FigmaAPI implements NodeProxyHost { // --- Boolean Operations --- - booleanOperation( + private _booleanOperation( operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE', - nodeIds: string[] - ): FigmaNodeProxy { - if (nodeIds.length < 2) throw new Error('Need at least 2 nodes for boolean operation') - const nodes = nodeIds.map((id) => this.graph.getNode(id)) - const first = nodes[0] - if (!first || nodes.some((n) => !n)) throw new Error('One or more nodes not found') - const parentId = first.parentId ?? this._currentPageId - const group = this.graph.createNode('GROUP', parentId, { + nodes: ReadonlyArray, + parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy, + index?: number + ): FigmaBooleanOperationNode { + if (nodes.length < 2) throw new Error('Need at least 2 nodes for boolean operation') + const parentId = this._nodeId(parent) + const first = this.graph.getNode(this._nodeId(nodes[0])) + if (!first) throw new Error('Node not found') + const group = this.graph.createNode('BOOLEAN_OPERATION', parentId, { name: `Boolean ${operation.toLowerCase()}`, x: first.x, y: first.y, width: first.width, - height: first.height + height: first.height, + booleanOperation: operation }) - for (const id of nodeIds) { - this.graph.reparentNode(id, group.id) + for (const node of nodes) { + this.graph.reparentNode(this._nodeId(node), group.id) } - return this.wrapNode(group.id) + if (index != null) this.graph.reorderChild(group.id, parentId, index) + return this.wrapNode(group.id) as FigmaBooleanOperationNode + } + + private _nodesById(nodeIds: string[]) { + return nodeIds.map((id) => { + const node = this.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found`) + return node + }) + } + + booleanOperation( + operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE', + nodeIds: string[] + ): FigmaBooleanOperationNode { + const first = this.graph.getNode(nodeIds[0]) + const parent = this.wrapNode(first?.parentId ?? this._currentPageId) + return this._booleanOperation(operation, this._nodesById(nodeIds), parent) + } + + union( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): BooleanOperationNode { + return this._booleanOperation('UNION', nodes, parent, index) + } + + subtract( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): BooleanOperationNode { + return this._booleanOperation('SUBTRACT', nodes, parent, index) + } + + intersect( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): BooleanOperationNode { + return this._booleanOperation('INTERSECT', nodes, parent, index) + } + + exclude( + nodes: ReadonlyArray, + parent: BaseNode & ChildrenMixin, + index?: number + ): BooleanOperationNode { + return this._booleanOperation('EXCLUDE', nodes, parent, index) } // --- Flatten --- - flattenNode(nodeIds: string[]): FigmaNodeProxy { - if (nodeIds.length === 0) throw new Error('Need at least 1 node to flatten') - const first = this.graph.getNode(nodeIds[0]) + flatten(nodes: ReadonlyArray, parent?: FigmaNodeProxy, index?: number): FigmaVectorNode + flatten(nodes: ReadonlyArray, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode + flatten( + nodes: ReadonlyArray, + parent?: (BaseNode & ChildrenMixin) | FigmaNodeProxy, + index?: number + ): FigmaVectorNode { + if (nodes.length === 0) throw new Error('Need at least 1 node to flatten') + const parentId = this._nodeId(parent ?? this.currentPage) + const first = this.graph.getNode(this._nodeId(nodes[0])) if (!first) throw new Error('Node not found') - const parentId = first.parentId ?? this._currentPageId const vector = this.graph.createNode('VECTOR', parentId, { name: 'Flatten', x: first.x, @@ -355,10 +434,17 @@ export class FigmaAPI implements NodeProxyHost { height: first.height, fills: copyFills(first.fills) }) - for (const id of nodeIds) { - this.graph.deleteNode(id) + if (index != null) this.graph.reorderChild(vector.id, parentId, index) + for (const node of nodes) { + this.graph.deleteNode(this._nodeId(node)) } - return this.wrapNode(vector.id) + return this.wrapNode(vector.id) as FigmaVectorNode + } + + flattenNode(nodeIds: string[]): FigmaVectorNode { + const first = this.graph.getNode(nodeIds[0]) + const parent = this.wrapNode(first?.parentId ?? this._currentPageId) + return this.flatten(this._nodesById(nodeIds), parent) } // --- Viewport --- diff --git a/packages/core/src/figma-api/node-types.ts b/packages/core/src/figma-api/node-types.ts index 893396ee6..4faaaaeba 100644 --- a/packages/core/src/figma-api/node-types.ts +++ b/packages/core/src/figma-api/node-types.ts @@ -12,3 +12,5 @@ export type FigmaPolygonNode = FigmaNodeProxy & PolygonNode export type FigmaStarNode = FigmaNodeProxy & StarNode export type FigmaComponentNode = FigmaNodeProxy & ComponentNode export type FigmaSectionNode = FigmaNodeProxy & SectionNode +export type FigmaGroupNode = FigmaNodeProxy & GroupNode +export type FigmaBooleanOperationNode = FigmaNodeProxy & BooleanOperationNode diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 96feef92d..c140cfee1 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -132,6 +132,7 @@ export const CONTAINER_TYPES = new Set([ 'CANVAS', 'FRAME', 'GROUP', + 'BOOLEAN_OPERATION', 'SECTION', 'COMPONENT', 'COMPONENT_SET', diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 28c637944..efae1b9e4 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -64,6 +64,7 @@ export type NodeType = | 'STAR' | 'POLYGON' | 'VECTOR' + | 'BOOLEAN_OPERATION' | 'GROUP' | 'SECTION' | 'COMPONENT' @@ -292,6 +293,7 @@ export interface SceneNode { layoutAlignSelf: LayoutAlignSelf vectorNetwork: VectorNetwork | null + booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' fillGeometry: GeometryPath[] strokeGeometry: GeometryPath[] diff --git a/tests/engine/figma/api/grouping-compat.test.ts b/tests/engine/figma/api/grouping-compat.test.ts new file mode 100644 index 000000000..7b519229f --- /dev/null +++ b/tests/engine/figma/api/grouping-compat.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('FigmaAPI grouping compatibility', () => { + test('group accepts parent and index and returns a group node', () => { + const api = createAPI() + const page = api.currentPage + const before = api.createRectangle() + const first = api.createRectangle() + const second = api.createRectangle() + + const group = api.group([first, second], page, 1) + + expect(group.type).toBe('GROUP') + expect(group.children.map((child) => child.id)).toEqual([first.id, second.id]) + expect(page.children.map((child) => child.id)).toEqual([before.id, group.id]) + }) + + test('ungroup returns moved children', () => { + const api = createAPI() + const page = api.currentPage + const first = api.createRectangle() + const second = api.createRectangle() + const group = api.group([first, second], page) + + const children = api.ungroup(group) + + expect(children.map((child) => child.id)).toEqual([first.id, second.id]) + expect(api.getNodeById(group.id)).toBeNull() + expect(page.children.map((child) => child.id)).toEqual([first.id, second.id]) + }) + + test('flatten accepts proxy nodes and optional parent/index', () => { + const api = createAPI() + const page = api.currentPage + const before = api.createRectangle() + const first = api.createRectangle() + const second = api.createRectangle() + + const vector = api.flatten([first, second], page, 1) + + expect(vector.type).toBe('VECTOR') + expect(api.getNodeById(first.id)).toBeNull() + expect(api.getNodeById(second.id)).toBeNull() + expect(page.children.map((child) => child.id)).toEqual([before.id, vector.id]) + }) + + test('boolean wrappers create boolean operation containers', () => { + const api = createAPI() + const page = api.currentPage + const before = api.createRectangle() + const first = api.createRectangle() + const second = api.createRectangle() + + const booleanNode = api.union([first, second], page, 1) + + expect(booleanNode.type).toBe('BOOLEAN_OPERATION') + expect(booleanNode.children.map((child) => child.id)).toEqual([first.id, second.id]) + expect(page.children.map((child) => child.id)).toEqual([before.id, booleanNode.id]) + }) +})