refactor(figma-api): align grouping API signatures

This commit is contained in:
Danila Poyarkov 2026-05-17 17:11:15 +03:00
parent 7da08789cd
commit bf57a429d9
6 changed files with 196 additions and 33 deletions

View file

@ -4,7 +4,9 @@ import type { FigmaAPI } from './index'
type Expect<T extends true> = T
type IsAssignable<Actual, Expected> = Actual extends Expected ? true : false
type IncompatibleKeys<Actual, Expected> = {
[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<IsAssignable<FigmaAPI, SupportedPluginAPI>>
export type FigmaAPIIncompatibleKeys = IncompatibleKeys<FigmaAPI, SupportedPluginAPI>
export type FigmaAPICompatibility = Expect<FigmaAPIIncompatibleKeys extends never ? true : false>

View file

@ -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<FigmaNodeProxy>, parent: FigmaNodeProxy, index?: number): FigmaGroupNode
group(nodes: ReadonlyArray<BaseNode>, parent: BaseNode & ChildrenMixin, index?: number): GroupNode
group(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
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<SceneNode>
ungroup(node: (SceneNode & ChildrenMixin) | FigmaNodeProxy): Array<SceneNode> | 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<BaseNode | FigmaNodeProxy>,
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<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('UNION', nodes, parent, index)
}
subtract(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('SUBTRACT', nodes, parent, index)
}
intersect(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('INTERSECT', nodes, parent, index)
}
exclude(
nodes: ReadonlyArray<BaseNode>,
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<FigmaNodeProxy>, parent?: FigmaNodeProxy, index?: number): FigmaVectorNode
flatten(nodes: ReadonlyArray<BaseNode>, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode
flatten(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
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 ---

View file

@ -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

View file

@ -132,6 +132,7 @@ export const CONTAINER_TYPES = new Set<NodeType>([
'CANVAS',
'FRAME',
'GROUP',
'BOOLEAN_OPERATION',
'SECTION',
'COMPONENT',
'COMPONENT_SET',

View file

@ -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[]

View file

@ -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])
})
})