refactor(figma-api): align grouping API signatures
This commit is contained in:
parent
7da08789cd
commit
bf57a429d9
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export const CONTAINER_TYPES = new Set<NodeType>([
|
|||
'CANVAS',
|
||||
'FRAME',
|
||||
'GROUP',
|
||||
'BOOLEAN_OPERATION',
|
||||
'SECTION',
|
||||
'COMPONENT',
|
||||
'COMPONENT_SET',
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
62
tests/engine/figma/api/grouping-compat.test.ts
Normal file
62
tests/engine/figma/api/grouping-compat.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue