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:
parent
30f8a3fda6
commit
6d19a13526
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
22
packages/core/src/fig-compress.ts
Normal file
22
packages/core/src/fig-compress.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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] })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ watch(jsxCode, () => {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.token.tag {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue