refactor(figma-api): share renderer-backed flatten
This commit is contained in:
parent
28829a56a9
commit
becbf4360c
44
packages/core/src/canvas/flatten.ts
Normal file
44
packages/core/src/canvas/flatten.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
39
tests/engine/figma/api/flatten-renderer.test.ts
Normal file
39
tests/engine/figma/api/flatten-renderer.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue