Add computeBounds/requireNode helpers, scope CodePanel style, dedupe snap/zip

- geometry.ts: add computeBounds() utility for bounding box accumulation
- tools/schema.ts: add requireNode() helper + NodeNotFoundError
- CodePanel.vue: scope <style> to prevent global CSS leak
- snap.ts: replace 44-line getEdges() with rotatedBBox() from geometry
- fig-compress.ts: extract shared zip assembly from fig-export + worker
- Remove unused computeBounds import from vector.ts
This commit is contained in:
Danila Poyarkov 2026-03-16 23:01:51 +03:00
parent 30f8a3fda6
commit 6d19a13526
13 changed files with 106 additions and 147 deletions

View file

@ -1,3 +1,4 @@
import { computeBounds } from '../geometry'
import { computeAllLayouts } from '../layout'
import {
buildFigmaClipboardHTML,
@ -25,21 +26,11 @@ export function createClipboardActions(ctx: EditorContext) {
}
function centerNodesAt(nodeIds: string[], cx: number, cy: number) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const id of nodeIds) {
const n = ctx.graph.getNode(id)
if (!n) continue
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
if (minX === Infinity) return
const dx = cx - (minX + maxX) / 2
const dy = cy - (minY + maxY) / 2
const items = nodeIds.map((id) => ctx.graph.getNode(id)).filter((n) => n != null)
const b = computeBounds(items)
if (b.width === 0 && b.height === 0 && items.length === 0) return
const dx = cx - (b.x + b.width / 2)
const dy = cy - (b.y + b.height / 2)
for (const id of nodeIds) {
const n = ctx.graph.getNode(id)
if (n) ctx.graph.updateNode(id, { x: n.x + dx, y: n.y + dy })

View file

@ -1,4 +1,5 @@
import { ZOOM_DIVISOR, ZOOM_SCALE_MAX, ZOOM_SCALE_MIN } from '../constants'
import { computeBounds } from '../geometry'
import type { EditorContext } from './types'
@ -46,18 +47,8 @@ export function createViewportActions(ctx: EditorContext) {
const nodes = ctx.graph.getChildren(ctx.state.currentPageId)
if (nodes.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
zoomToBounds(minX, minY, maxX, maxY)
const b = computeBounds(nodes)
zoomToBounds(b.x, b.y, b.x + b.width, b.y + b.height)
}
function zoomTo100() {

View file

@ -0,0 +1,22 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from './kiwi/kiwi-serialize'
export function compressFigDataSync(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
): Uint8Array {
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
}
for (const entry of imageEntries) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
return zipSync(zipEntries)
}

View file

@ -1,5 +1,4 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from './kiwi/kiwi-serialize'
import { compressFigDataSync } from './fig-compress'
interface CompressMessage {
schemaDeflated: Uint8Array
@ -11,18 +10,6 @@ interface CompressMessage {
self.onmessage = (e: MessageEvent<CompressMessage>) => {
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images } = e.data
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
}
for (const entry of images) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
const result = zipSync(zipEntries)
const result = compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, images)
self.postMessage(result, { transfer: [result.buffer] })
}

View file

@ -1,16 +1,22 @@
import { zipSync, deflateSync, type Zippable } from 'fflate'
import { deflateSync } from 'fflate'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi, buildFontDigestMap, safeColor } from './kiwi/kiwi-serialize'
import { compressFigDataSync } from './fig-compress'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
import {
sceneNodeToKiwi,
fractionalPosition,
buildFontDigestMap,
safeColor
} from './kiwi/kiwi-serialize'
import { renderThumbnail } from './render-image'
import type { NodeChange } from './kiwi/codec'
import type { SkiaRenderer } from './renderer'
import type { SceneGraph, VariableValue } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
import type { GUID } from './types'
import type { CanvasKit } from 'canvaskit-wasm'
const THUMBNAIL_1X1 = Uint8Array.from(
atob(
@ -128,7 +134,17 @@ export async function exportFigFile(
const children = graph.getChildren(page.id)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(
...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid, fontDigestMap, varIdToGuid)
...sceneNodeToKiwi(
children[i],
canvasGuid,
i,
localIdCounter,
graph,
blobs,
nodeIdToGuid,
fontDigestMap,
varIdToGuid
)
)
}
}
@ -182,7 +198,11 @@ export async function exportFigFile(
const varGuid = { sessionID: 0, localID: localIdCounter.value++ }
varIdToGuid.set(varId, varGuid)
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
const typeMap: Record<string, string> = {
COLOR: 'COLOR',
BOOLEAN: 'BOOLEAN',
STRING: 'STRING'
}
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
const entries = Object.entries(variable.valuesByMode).map(([modeId, value]) => ({
@ -243,7 +263,7 @@ export async function exportFigFile(
kiwiData: Array.from(kiwiData),
thumbnailPng: Array.from(thumbnailPng),
metaJson,
images: imageEntries.map(e => ({ name: e.name, data: Array.from(e.data) }))
images: imageEntries.map((e) => ({ name: e.name, data: Array.from(e.data) }))
})
)
}
@ -251,24 +271,7 @@ export async function exportFigFile(
return compressFigData(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
}
export function compressFigDataSync(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
): Uint8Array {
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
}
for (const entry of imageEntries) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
return zipSync(zipEntries)
}
export { compressFigDataSync } from './fig-compress'
function canUseWorker(): boolean {
return typeof Worker !== 'undefined' && IS_BROWSER
@ -282,7 +285,9 @@ function compressViaWorker(
imageEntries: Array<{ name: string; data: Uint8Array }>
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), { type: 'module' })
const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), {
type: 'module'
})
worker.onmessage = (e: MessageEvent<Uint8Array>) => {
resolve(e.data)
@ -322,5 +327,7 @@ export function compressFigData(
if (canUseWorker()) {
return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
}
return Promise.resolve(compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries))
return Promise.resolve(
compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
)
}

View file

@ -8,6 +8,7 @@ import type {
} from './scene-graph'
import type { Rect, Vector } from './types'
import { IS_BROWSER } from './constants'
import { computeBounds } from './geometry'
import { copyFills, copyStrokes, copyEffects } from './copy'
import {
@ -322,26 +323,16 @@ export class FigmaAPI implements NodeProxyHost {
center: { x: this._viewport.x, y: this._viewport.y },
zoom: this._viewport.zoom,
scrollAndZoomIntoView: (nodes) => {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
const b = node.absoluteBoundingBox
minX = Math.min(minX, b.x)
minY = Math.min(minY, b.y)
maxX = Math.max(maxX, b.x + b.width)
maxY = Math.max(maxY, b.y + b.height)
}
if (minX === Infinity) return
const b = computeBounds(nodes.map((n) => n.absoluteBoundingBox))
if (b.width === 0 && b.height === 0 && nodes.length === 0) return
const padding = 80
const contentW = maxX - minX + padding * 2
const contentH = maxY - minY + padding * 2
const contentW = b.width + padding * 2
const contentH = b.height + padding * 2
const viewW = IS_BROWSER ? window.innerWidth : 1280
const viewH = IS_BROWSER ? window.innerHeight : 720
const zoom = Math.min(viewW / contentW, viewH / contentH, 1)
this._viewport = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, zoom }
this._viewport = { x: b.x + b.width / 2, y: b.y + b.height / 2, zoom }
}
}
}

View file

@ -1,4 +1,4 @@
import type { Vector } from './types'
import type { Rect, Vector } from './types'
export function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180
@ -71,3 +71,15 @@ export function rotatedBBox(
}
return { left, right, top, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 }
}
export function computeBounds(items: Iterable<Rect>): Rect {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const item of items) {
minX = Math.min(minX, item.x)
minY = Math.min(minY, item.y)
maxX = Math.max(maxX, item.x + item.width)
maxY = Math.max(maxY, item.y + item.height)
}
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 }
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}

View file

@ -1,5 +1,5 @@
export type { GUID, Color, Vector, Matrix, Rect } from './types'
export { degToRad, radToDeg, rotatePoint, rotatedCorners, rotatedBBox } from './geometry'
export { computeBounds, degToRad, radToDeg, rotatePoint, rotatedCorners, rotatedBBox } from './geometry'
export { randomHex, randomInt, randomIndex } from './random'
export * from './constants'
@ -67,7 +67,7 @@ export {
} from './scene-graph'
export { FigmaAPI, FigmaNodeProxy, computeImageHash, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, CORE_TOOLS, EXTENDED_TOOLS, defineTool, toolsToAI, buildDebugLog } from './tools'
export { ALL_TOOLS, CORE_TOOLS, EXTENDED_TOOLS, defineTool, toolsToAI, buildDebugLog, requireNode, NodeNotFoundError } from './tools'
export type { ToolDef, ParamDef, ParamType, ToolLogEntry, ToolDebugLog, AIAdapterOptions, StepBudget } from './tools'
export { executeRpcCommand, ALL_RPC_COMMANDS } from './rpc'
export { queryByXPath, matchByXPath } from './xpath'

View file

@ -1,3 +1,4 @@
import { rotatedBBox } from './geometry'
import type { SceneNode } from './scene-graph'
import type { Rect } from './types'
@ -17,51 +18,7 @@ export interface SnapResult {
}
function getEdges(node: SceneNode) {
if (node.rotation === 0) {
return {
left: node.x,
right: node.x + node.width,
centerX: node.x + node.width / 2,
top: node.y,
bottom: node.y + node.height,
centerY: node.y + node.height / 2
}
}
const cx = node.x + node.width / 2
const cy = node.y + node.height / 2
const rad = (node.rotation * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
const hw = node.width / 2
const hh = node.height / 2
const corners = [
{ x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos },
{ x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos },
{ x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos },
{ x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos }
]
let left = Infinity,
right = -Infinity,
top = Infinity,
bottom = -Infinity
for (const c of corners) {
left = Math.min(left, c.x)
right = Math.max(right, c.x)
top = Math.min(top, c.y)
bottom = Math.max(bottom, c.y)
}
return {
left,
right,
centerX: (left + right) / 2,
top,
bottom,
centerY: (top + bottom) / 2
}
return rotatedBBox(node.x, node.y, node.width, node.height, node.rotation)
}
export function computeSnap(

View file

@ -1,6 +1,6 @@
export { ALL_TOOLS, CORE_TOOLS, EXTENDED_TOOLS } from './registry'
export { exportImage } from './vector'
export { defineTool, nodeToResult, nodeSummary } from './schema'
export { defineTool, nodeToResult, nodeSummary, requireNode, NodeNotFoundError } from './schema'
export type { ToolDef, ParamDef, ParamType } from './schema'
export { toolsToAI, buildDebugLog } from './ai-adapter'
export type { ToolLogEntry, ToolDebugLog, AIAdapterOptions, StepBudget } from './ai-adapter'

View file

@ -1,5 +1,6 @@
import { createTwoFilesPatch } from 'diff'
import { computeBounds } from '../geometry'
import { sceneNodeToJSX } from '../render/export-jsx'
import { defineTool, nodeSummary, nodeToResult } from './schema'
import { queryByXPath } from '../xpath'
@ -165,20 +166,7 @@ export const pageBounds = defineTool({
description: 'Get bounding box of all objects on the current page.',
params: {},
execute: (figma) => {
const page = figma.currentPage
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const child of page.children) {
const bounds = child.absoluteBoundingBox
minX = Math.min(minX, bounds.x)
minY = Math.min(minY, bounds.y)
maxX = Math.max(maxX, bounds.x + bounds.width)
maxY = Math.max(maxY, bounds.y + bounds.height)
}
if (minX === Infinity) return { x: 0, y: 0, width: 0, height: 0 }
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
return computeBounds(figma.currentPage.children.map((c) => c.absoluteBoundingBox))
}
})

View file

@ -56,6 +56,19 @@ export function defineTool<P extends Record<string, ParamDef>>(def: {
return def as unknown as ToolDef
}
export class NodeNotFoundError extends Error {
constructor(id: string) {
super(`Node not found: ${id}`)
this.name = 'NodeNotFoundError'
}
}
export function requireNode(figma: FigmaAPI, id: string): ReturnType<FigmaAPI['getNodeById']> {
const node = figma.getNodeById(id)
if (!node) throw new NodeNotFoundError(id)
return node
}
export function nodeToResult(node: FigmaNodeProxy, maxDepth?: number): Record<string, unknown> {
return node.toJSON(maxDepth)
}

View file

@ -101,7 +101,7 @@ watch(jsxCode, () => {
</div>
</template>
<style>
<style scoped>
.token.tag {
color: #7dd3fc;
}