diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index a6245941b..ce638f25c 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -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 }) diff --git a/packages/core/src/editor/viewport.ts b/packages/core/src/editor/viewport.ts index cda054257..2da475a1a 100644 --- a/packages/core/src/editor/viewport.ts +++ b/packages/core/src/editor/viewport.ts @@ -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() { diff --git a/packages/core/src/fig-compress.ts b/packages/core/src/fig-compress.ts new file mode 100644 index 000000000..864306a16 --- /dev/null +++ b/packages/core/src/fig-compress.ts @@ -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) +} diff --git a/packages/core/src/fig-export-worker.ts b/packages/core/src/fig-export-worker.ts index 657e0229d..e3eb000a1 100644 --- a/packages/core/src/fig-export-worker.ts +++ b/packages/core/src/fig-export-worker.ts @@ -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) => { 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] }) } diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index 5be63b56a..3a87b8217 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -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 = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' } + const typeMap: Record = { + 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 { 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) => { 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) + ) } diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index 00d34d366..8dc31fe22 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -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 } } } } diff --git a/packages/core/src/geometry.ts b/packages/core/src/geometry.ts index 2e50be8d1..e5d2c4889 100644 --- a/packages/core/src/geometry.ts +++ b/packages/core/src/geometry.ts @@ -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 { + 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 } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8d7867ec4..688f854af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/snap.ts b/packages/core/src/snap.ts index b5d53036d..88e5d0f8e 100644 --- a/packages/core/src/snap.ts +++ b/packages/core/src/snap.ts @@ -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( diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 33436bb79..d64560701 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -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' diff --git a/packages/core/src/tools/read.ts b/packages/core/src/tools/read.ts index 137eac9bf..fa58c479d 100644 --- a/packages/core/src/tools/read.ts +++ b/packages/core/src/tools/read.ts @@ -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)) } }) diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts index 7280b6fe7..83933971c 100644 --- a/packages/core/src/tools/schema.ts +++ b/packages/core/src/tools/schema.ts @@ -56,6 +56,19 @@ export function defineTool

>(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 { + const node = figma.getNodeById(id) + if (!node) throw new NodeNotFoundError(id) + return node +} + export function nodeToResult(node: FigmaNodeProxy, maxDepth?: number): Record { return node.toJSON(maxDepth) } diff --git a/src/components/CodePanel.vue b/src/components/CodePanel.vue index 8a669b0cb..48f91dcd6 100644 --- a/src/components/CodePanel.vue +++ b/src/components/CodePanel.vue @@ -101,7 +101,7 @@ watch(jsxCode, () => { -