refactor(figma-api): share renderer-backed flatten

This commit is contained in:
Danila Poyarkov 2026-05-17 18:37:53 +03:00
parent 28829a56a9
commit becbf4360c
4 changed files with 127 additions and 43 deletions

View file

@ -0,0 +1,44 @@
import { parseSVGPath } from '#core/io/formats/svg/parse-path'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills } from '#core/scene-graph/copy'
import { makeBooleanSourcePath, nodePathTransform } from './boolean'
import type { SkiaRenderer } from './renderer'
export function flattenNodesToVectorProps(
renderer: SkiaRenderer,
graph: SceneGraph,
nodes: SceneNode[]
): Pick<SceneNode, 'name' | 'x' | 'y' | 'width' | 'height' | 'fills' | 'vectorNetwork'> | null {
const path = new renderer.ck.Path()
for (const node of nodes) {
const nodePath = makeBooleanSourcePath(renderer, node, graph)
if (!nodePath) {
path.delete()
return null
}
nodePath.transform(nodePathTransform(renderer, node))
path.addPath(nodePath)
nodePath.delete()
}
const bounds = path.getBounds()
if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
path.delete()
return null
}
path.transform(renderer.ck.Matrix.translated(-bounds[0], -bounds[1]))
const vectorNetwork = parseSVGPath(path.toSVGString())
path.delete()
return {
name: 'Flatten',
x: bounds[0],
y: bounds[1],
width: bounds[2] - bounds[0],
height: bounds[3] - bounds[1],
fills: copyFills(nodes[0].fills),
vectorNetwork
}
}

View file

@ -1,9 +1,8 @@
import { canMakeBooleanSourcePath, makeBooleanSourcePath, nodePathTransform } from '#core/canvas/boolean'
import { canMakeBooleanSourcePath } from '#core/canvas/boolean'
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history'
import type { EditorContext } from '#core/editor/types'
import { parseSVGPath } from '#core/io/formats/svg/parse-path'
import type { SceneNode } from '#core/scene-graph'
import { copyFills } from '#core/scene-graph/copy'
import { selectedNodesInSharedParent } from './selection'
@ -20,40 +19,10 @@ export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[])
const childSnapshots = childIds.map((id) => ({ id, subtree: snapshotSubtree(ctx.graph, id) }))
const prevSelection = new Set(ctx.state.selectedIds)
const firstIndex = Math.min(...childIds.map((id) => parent.childIds.indexOf(id)))
const path = new renderer.ck.Path()
const vectorProps = flattenNodesToVectorProps(renderer, ctx.graph, topLevel)
if (!vectorProps) return null
for (const node of topLevel) {
const childPath = makeBooleanSourcePath(renderer, node, ctx.graph)
if (!childPath) {
path.delete()
return null
}
childPath.transform(nodePathTransform(renderer, node))
path.addPath(childPath)
childPath.delete()
}
const bounds = path.getBounds()
if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
path.delete()
return null
}
path.transform(renderer.ck.Matrix.translated(-bounds[0], -bounds[1]))
const svgPath = path.toSVGString()
path.delete()
const first = topLevel[0]
const vector = ctx.graph.createNode('VECTOR', parentId, {
name: 'Flatten',
x: bounds[0],
y: bounds[1],
width: bounds[2] - bounds[0],
height: bounds[3] - bounds[1],
fills: copyFills(first.fills),
strokes: [],
vectorNetwork: parseSVGPath(svgPath)
})
const vector = ctx.graph.createNode('VECTOR', parentId, { ...vectorProps, strokes: [] })
const vectorSnapshot = structuredClone(vector)
ctx.graph.insertChildAt(vector.id, parentId, firstIndex)
for (const id of childIds) ctx.graph.deleteNode(id)

View file

@ -1,8 +1,12 @@
import type { SkiaRenderer } from '#core/canvas'
import { canMakeBooleanSourcePath } from '#core/canvas/boolean'
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
import { IS_BROWSER } from '#core/constants'
import { computeBounds } from '#core/geometry'
import type { RasterExportFormat } from '#core/io/formats/raster'
import type {
SceneGraph,
SceneNode as CoreSceneNode,
NodeType,
Variable,
VariableCollection,
@ -96,6 +100,7 @@ export class FigmaAPI implements NodeProxyHost {
private _selection: FigmaNodeProxy[] = []
private _nodeCache = new Map<string, FigmaNodeProxy>()
private _pageProxies = new WeakSet<FigmaNodeProxy>()
private _renderer: SkiaRenderer | null = null
readonly mixed = MIXED
@ -105,6 +110,10 @@ export class FigmaAPI implements NodeProxyHost {
this._currentPageId = pages[0]?.id ?? graph.rootId
}
setRenderer(renderer: SkiaRenderer | null): void {
this._renderer = renderer
}
get currentPageId(): string {
return this._currentPageId
}
@ -424,9 +433,25 @@ export class FigmaAPI implements NodeProxyHost {
): 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 vector = this.graph.createNode('VECTOR', parentId, {
const sourceNodes: CoreSceneNode[] = []
for (const node of nodes) {
const raw = this.graph.getNode(this._nodeId(node))
if (!raw) throw new Error('Node not found')
sourceNodes.push(raw)
}
const vector = this._renderer
? this._flattenWithRenderer(sourceNodes, parentId)
: this._flattenPlaceholder(sourceNodes, parentId)
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) as FigmaVectorNode
}
private _flattenPlaceholder(nodes: CoreSceneNode[], parentId: string): CoreSceneNode {
const first = nodes[0]
return this.graph.createNode('VECTOR', parentId, {
name: 'Flatten',
x: first.x,
y: first.y,
@ -434,11 +459,18 @@ export class FigmaAPI implements NodeProxyHost {
height: first.height,
fills: copyFills(first.fills)
})
if (index != null) this.graph.reorderChild(vector.id, parentId, index)
for (const node of nodes) {
this.graph.deleteNode(this._nodeId(node))
}
private _flattenWithRenderer(nodes: CoreSceneNode[], parentId: string): CoreSceneNode {
const renderer = this._renderer
if (!renderer) return this._flattenPlaceholder(nodes, parentId)
if (nodes.some((node) => !canMakeBooleanSourcePath(node))) {
throw new Error('Cannot flatten unsupported node type')
}
return this.wrapNode(vector.id) as FigmaVectorNode
const vectorProps = flattenNodesToVectorProps(renderer, this.graph, nodes)
if (!vectorProps) throw new Error('Cannot flatten empty node path')
return this.graph.createNode('VECTOR', parentId, vectorProps)
}
flattenNode(nodeIds: string[]): FigmaVectorNode {

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import { initCanvasKit } from '#cli/headless'
import { SkiaRenderer } from '#core/canvas'
import { createAPI } from './helpers'
async function createRenderer() {
const ck = await initCanvasKit()
const surface = ck.MakeSurface(200, 200)
if (!surface) throw new Error('Could not create CanvasKit surface')
return { renderer: new SkiaRenderer(ck, surface), surface }
}
describe('FigmaAPI renderer-backed flatten', () => {
test('creates vector geometry when a renderer is attached', async () => {
const api = createAPI()
const { renderer, surface } = await createRenderer()
api.setRenderer(renderer)
const first = api.createRectangle()
const second = api.createEllipse()
first.resize(50, 40)
second.resize(50, 40)
second.x = 30
second.y = 10
const vector = api.flatten([first, second], api.currentPage)
const raw = api.graph.getNode(vector.id)
expect(raw?.type).toBe('VECTOR')
expect(raw?.x).toBe(0)
expect(raw?.y).toBe(0)
expect(raw?.width).toBe(80)
expect(raw?.height).toBe(50)
expect(raw?.vectorNetwork?.vertices.length).toBeGreaterThan(0)
expect(api.getNodeById(first.id)).toBeNull()
surface.delete()
})
})