- Replace all no-explicit-any with proper types or justified eslint-disable - Extract scene-graph-hit-test.ts (hit testing), scene-graph-instances.ts (component/instance sync) from scene-graph.ts - Extract renderer/text.ts (text rendering), renderer/pen-overlay.ts (vector pen tool overlay) from renderer.ts - Extract figma-api-proxy.ts (FigmaNodeProxy class) from figma-api.ts - Extract kiwi-convert-overrides.ts from kiwi-convert.ts - Add eslint-disable with justification for remaining large class files - Fix unused imports from splits - Warnings: 42 → 2 (only unavoidable no-implied-eval in vendored code)
314 lines
9.7 KiB
TypeScript
314 lines
9.7 KiB
TypeScript
import type { SceneNode } from '../scene-graph'
|
|
|
|
import { defineTool, nodeSummary } from './schema'
|
|
|
|
const CHUNK_SIZE = 0x8000
|
|
|
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
if (typeof Buffer !== 'undefined') {
|
|
return Buffer.from(bytes).toString('base64')
|
|
}
|
|
let binary = ''
|
|
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
const chunk = bytes.subarray(i, i + CHUNK_SIZE)
|
|
binary += String.fromCharCode.apply(null, chunk as unknown as number[])
|
|
}
|
|
return btoa(binary)
|
|
}
|
|
|
|
export const booleanUnion = defineTool({
|
|
name: 'boolean_union',
|
|
mutates: true,
|
|
description: 'Union (combine) multiple nodes.',
|
|
params: {
|
|
ids: { type: 'string[]', description: 'Node IDs to union', required: true }
|
|
},
|
|
execute: (figma, { ids }) => {
|
|
const result = figma.booleanOperation('UNION', ids)
|
|
return nodeSummary(result)
|
|
}
|
|
})
|
|
|
|
export const booleanSubtract = defineTool({
|
|
name: 'boolean_subtract',
|
|
mutates: true,
|
|
description: 'Subtract the second node from the first.',
|
|
params: {
|
|
ids: { type: 'string[]', description: 'Node IDs (first minus rest)', required: true }
|
|
},
|
|
execute: (figma, { ids }) => {
|
|
const result = figma.booleanOperation('SUBTRACT', ids)
|
|
return nodeSummary(result)
|
|
}
|
|
})
|
|
|
|
export const booleanIntersect = defineTool({
|
|
name: 'boolean_intersect',
|
|
mutates: true,
|
|
description: 'Intersect multiple nodes.',
|
|
params: {
|
|
ids: { type: 'string[]', description: 'Node IDs to intersect', required: true }
|
|
},
|
|
execute: (figma, { ids }) => {
|
|
const result = figma.booleanOperation('INTERSECT', ids)
|
|
return nodeSummary(result)
|
|
}
|
|
})
|
|
|
|
export const booleanExclude = defineTool({
|
|
name: 'boolean_exclude',
|
|
mutates: true,
|
|
description: 'Exclude (XOR) multiple nodes.',
|
|
params: {
|
|
ids: { type: 'string[]', description: 'Node IDs to exclude', required: true }
|
|
},
|
|
execute: (figma, { ids }) => {
|
|
const result = figma.booleanOperation('EXCLUDE', ids)
|
|
return nodeSummary(result)
|
|
}
|
|
})
|
|
|
|
export const pathGet = defineTool({
|
|
name: 'path_get',
|
|
description: 'Get vector path data of a node.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true }
|
|
},
|
|
execute: (figma, { id }) => {
|
|
const raw = figma.graph.getNode(id)
|
|
if (!raw) return { error: `Node "${id}" not found` }
|
|
if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` }
|
|
return { id, vectorNetwork: raw.vectorNetwork }
|
|
}
|
|
})
|
|
|
|
export const pathSet = defineTool({
|
|
name: 'path_set',
|
|
mutates: true,
|
|
description: 'Set vector path data on a node. Provide a VectorNetwork JSON.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true },
|
|
path: { type: 'string', description: 'VectorNetwork JSON', required: true }
|
|
},
|
|
execute: (figma, args) => {
|
|
const raw = figma.graph.getNode(args.id)
|
|
if (!raw) return { error: `Node "${args.id}" not found` }
|
|
const network = JSON.parse(args.path)
|
|
figma.graph.updateNode(args.id, { vectorNetwork: network } as Partial<SceneNode>)
|
|
return { id: args.id }
|
|
}
|
|
})
|
|
|
|
export const pathScale = defineTool({
|
|
name: 'path_scale',
|
|
mutates: true,
|
|
description: 'Scale vector path from center.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true },
|
|
factor: { type: 'number', description: 'Scale factor (e.g. 2 for double)', required: true }
|
|
},
|
|
execute: (figma, { id, factor }) => {
|
|
const raw = figma.graph.getNode(id)
|
|
if (!raw) return { error: `Node "${id}" not found` }
|
|
if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` }
|
|
const vn = structuredClone(raw.vectorNetwork)
|
|
const cx = raw.width / 2
|
|
const cy = raw.height / 2
|
|
for (const v of vn.vertices) {
|
|
v.x = cx + (v.x - cx) * factor
|
|
v.y = cy + (v.y - cy) * factor
|
|
}
|
|
for (const s of vn.segments) {
|
|
s.tangentStart.x *= factor
|
|
s.tangentStart.y *= factor
|
|
s.tangentEnd.x *= factor
|
|
s.tangentEnd.y *= factor
|
|
}
|
|
figma.graph.updateNode(id, { vectorNetwork: vn } as Partial<SceneNode>)
|
|
return { id, factor }
|
|
}
|
|
})
|
|
|
|
export const pathFlip = defineTool({
|
|
name: 'path_flip',
|
|
mutates: true,
|
|
description: 'Flip vector path horizontally or vertically.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true },
|
|
axis: {
|
|
type: 'string',
|
|
description: 'Flip axis',
|
|
required: true,
|
|
enum: ['horizontal', 'vertical']
|
|
}
|
|
},
|
|
execute: (figma, { id, axis }) => {
|
|
const raw = figma.graph.getNode(id)
|
|
if (!raw) return { error: `Node "${id}" not found` }
|
|
if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` }
|
|
const vn = structuredClone(raw.vectorNetwork)
|
|
const w = raw.width
|
|
const h = raw.height
|
|
for (const v of vn.vertices) {
|
|
if (axis === 'horizontal') v.x = w - v.x
|
|
else v.y = h - v.y
|
|
}
|
|
for (const s of vn.segments) {
|
|
if (axis === 'horizontal') s.tangentStart.x = -s.tangentStart.x
|
|
else s.tangentStart.y = -s.tangentStart.y
|
|
if (axis === 'horizontal') s.tangentEnd.x = -s.tangentEnd.x
|
|
else s.tangentEnd.y = -s.tangentEnd.y
|
|
}
|
|
figma.graph.updateNode(id, { vectorNetwork: vn } as Partial<SceneNode>)
|
|
return { id, axis }
|
|
}
|
|
})
|
|
|
|
export const pathMove = defineTool({
|
|
name: 'path_move',
|
|
mutates: true,
|
|
description: 'Move all path points by an offset.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true },
|
|
dx: { type: 'number', description: 'X offset', required: true },
|
|
dy: { type: 'number', description: 'Y offset', required: true }
|
|
},
|
|
execute: (figma, { id, dx, dy }) => {
|
|
const raw = figma.graph.getNode(id)
|
|
if (!raw) return { error: `Node "${id}" not found` }
|
|
if (!raw.vectorNetwork) return { error: `Node "${id}" has no vector data` }
|
|
const vn = structuredClone(raw.vectorNetwork)
|
|
for (const v of vn.vertices) {
|
|
v.x += dx
|
|
v.y += dy
|
|
}
|
|
figma.graph.updateNode(id, { vectorNetwork: vn } as Partial<SceneNode>)
|
|
return { id, dx, dy }
|
|
}
|
|
})
|
|
|
|
export const viewportGet = defineTool({
|
|
name: 'viewport_get',
|
|
description: 'Get current viewport position and zoom level.',
|
|
params: {},
|
|
execute: (figma) => {
|
|
return figma.viewport
|
|
}
|
|
})
|
|
|
|
export const viewportSet = defineTool({
|
|
name: 'viewport_set',
|
|
mutates: true,
|
|
description: 'Set viewport position and zoom.',
|
|
params: {
|
|
x: { type: 'number', description: 'Center X', required: true },
|
|
y: { type: 'number', description: 'Center Y', required: true },
|
|
zoom: { type: 'number', description: 'Zoom level', required: true, min: 0.01 }
|
|
},
|
|
execute: (figma, { x, y, zoom }) => {
|
|
figma.viewport = { center: { x, y }, zoom }
|
|
return { x, y, zoom }
|
|
}
|
|
})
|
|
|
|
export const viewportZoomToFit = defineTool({
|
|
name: 'viewport_zoom_to_fit',
|
|
mutates: true,
|
|
description: 'Zoom viewport to fit specified nodes.',
|
|
params: {
|
|
ids: { type: 'string[]', description: 'Node IDs to fit in view', required: true }
|
|
},
|
|
execute: (figma, { ids }) => {
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
for (const id of ids) {
|
|
const node = figma.getNodeById(id)
|
|
if (!node) continue
|
|
const bounds = node.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 { error: 'No valid nodes found' }
|
|
const cx = (minX + maxX) / 2
|
|
const cy = (minY + maxY) / 2
|
|
figma.viewport = { center: { x: cx, y: cy }, zoom: 1 }
|
|
return {
|
|
center: { x: cx, y: cy },
|
|
bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
|
}
|
|
}
|
|
})
|
|
|
|
export const exportSvg = defineTool({
|
|
name: 'export_svg',
|
|
description: 'Export nodes as SVG markup. Returns the SVG string.',
|
|
params: {
|
|
ids: {
|
|
type: 'string[]',
|
|
description: 'Node IDs to export. Omit to export all top-level nodes on the current page.'
|
|
}
|
|
},
|
|
execute: async (figma, args) => {
|
|
const { renderNodesToSVG } = await import('../svg-export/index.js')
|
|
const pageId = figma.currentPageId
|
|
const ids =
|
|
args.ids && args.ids.length > 0
|
|
? args.ids
|
|
: figma.currentPage.children.map((n) => n.id)
|
|
const svg = renderNodesToSVG(figma.graph, pageId, ids)
|
|
if (!svg) return { error: 'No visible nodes to export' }
|
|
return { svg }
|
|
}
|
|
})
|
|
|
|
export const exportImage = defineTool({
|
|
name: 'export_image',
|
|
description:
|
|
'Export nodes as a raster image (PNG, JPG, or WEBP). Returns base64-encoded image data. Use to visually verify designs.',
|
|
params: {
|
|
ids: {
|
|
type: 'string[]',
|
|
description: 'Node IDs to export. Omit to export all top-level nodes on the current page.'
|
|
},
|
|
format: {
|
|
type: 'string',
|
|
description: 'Image format',
|
|
enum: ['PNG', 'JPG', 'WEBP'],
|
|
default: 'PNG'
|
|
},
|
|
scale: {
|
|
type: 'number',
|
|
description: 'Export scale multiplier (default: 1)',
|
|
default: 1,
|
|
min: 0.1,
|
|
max: 4
|
|
}
|
|
},
|
|
execute: async (figma, args) => {
|
|
if (!figma.exportImage) {
|
|
return { error: 'Image export is not available in this environment' }
|
|
}
|
|
const ids =
|
|
args.ids && args.ids.length > 0
|
|
? args.ids
|
|
: figma.currentPage.children.map((n) => n.id)
|
|
const format = (args.format ?? 'PNG').toUpperCase() as 'PNG' | 'JPG' | 'WEBP'
|
|
const data = await figma.exportImage(ids, {
|
|
scale: args.scale ?? 1,
|
|
format
|
|
})
|
|
if (!data || data.length === 0) return { error: 'No visible nodes to export' }
|
|
const base64 = uint8ArrayToBase64(data)
|
|
const mimeMap = { PNG: 'image/png', JPG: 'image/jpeg', WEBP: 'image/webp' } as const
|
|
return {
|
|
mimeType: mimeMap[format],
|
|
base64,
|
|
byteLength: data.length
|
|
}
|
|
}
|
|
})
|