- 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)
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
/* eslint-disable max-lines -- analysis tools share helpers and collection logic */
|
|
import { createTwoFilesPatch } from 'diff'
|
|
|
|
import { colorDistance, colorToHex, parseColor } from '../color'
|
|
|
|
import { defineTool } from './schema'
|
|
|
|
import type { Color } from '../types'
|
|
import type { SceneNode } from '../scene-graph'
|
|
import type { FigmaAPI } from '../figma-api'
|
|
|
|
interface ColorEntry {
|
|
hex: string
|
|
color: Color
|
|
count: number
|
|
variableName: string | null
|
|
}
|
|
|
|
function trackColor(
|
|
colorMap: Map<string, ColorEntry>,
|
|
color: Color,
|
|
variableName: string | null
|
|
) {
|
|
const hex = colorToHex(color)
|
|
const entry = colorMap.get(hex)
|
|
if (entry) {
|
|
entry.count++
|
|
if (!entry.variableName && variableName) entry.variableName = variableName
|
|
} else {
|
|
colorMap.set(hex, { hex, color, count: 1, variableName })
|
|
}
|
|
}
|
|
|
|
// ─── Diff helpers ─────────────────────────────────────────────
|
|
|
|
function serializePaintProps(raw: SceneNode, lines: string[]): void {
|
|
const solidFill = raw.fills.find((f) => f.type === 'SOLID' && f.visible)
|
|
if (solidFill) lines.push(`fill: ${colorToHex(solidFill.color)}`)
|
|
|
|
const solidStroke = raw.strokes.find((s) => s.visible)
|
|
if (solidStroke) {
|
|
lines.push(`stroke: ${colorToHex(solidStroke.color)}`)
|
|
if (solidStroke.weight) lines.push(`strokeWeight: ${solidStroke.weight}`)
|
|
}
|
|
}
|
|
|
|
function serializeCornerRadii(raw: SceneNode, lines: string[]): void {
|
|
const tl = raw.topLeftRadius
|
|
const tr = raw.topRightRadius
|
|
const br = raw.bottomRightRadius
|
|
const bl = raw.bottomLeftRadius
|
|
if (tl || tr || br || bl) {
|
|
if (tl === tr && tr === br && br === bl) {
|
|
lines.push(`radius: ${tl}`)
|
|
} else {
|
|
lines.push(`radii: ${tl} ${tr} ${br} ${bl}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
function serializeEffects(raw: SceneNode, lines: string[]): void {
|
|
for (const effect of raw.effects) {
|
|
const parts: string[] = [effect.type]
|
|
parts.push(`r=${effect.radius}`)
|
|
parts.push(`c=${colorToHex(effect.color)}`)
|
|
parts.push(`x=${effect.offset.x} y=${effect.offset.y}`)
|
|
parts.push(`s=${effect.spread}`)
|
|
lines.push(`effect: ${parts.join(' ')}`)
|
|
}
|
|
}
|
|
|
|
function serializeTextProps(raw: SceneNode, lines: string[]): void {
|
|
if (raw.type !== 'TEXT') return
|
|
if (raw.text) lines.push(`text: ${JSON.stringify(raw.text)}`)
|
|
if (raw.fontSize) lines.push(`fontSize: ${raw.fontSize}`)
|
|
if (raw.fontFamily) lines.push(`fontFamily: ${raw.fontFamily}`)
|
|
if (raw.fontWeight) lines.push(`fontWeight: ${raw.fontWeight}`)
|
|
}
|
|
|
|
function serializeNodeProps(raw: SceneNode): string {
|
|
const lines: string[] = []
|
|
lines.push(`type: ${raw.type}`)
|
|
lines.push(`size: ${raw.width} ${raw.height}`)
|
|
lines.push(`pos: ${raw.x} ${raw.y}`)
|
|
|
|
serializePaintProps(raw, lines)
|
|
|
|
if (raw.opacity !== 1) lines.push(`opacity: ${Math.round(raw.opacity * 100) / 100}`)
|
|
|
|
serializeCornerRadii(raw, lines)
|
|
|
|
if (raw.blendMode !== 'NORMAL') lines.push(`blendMode: ${raw.blendMode}`)
|
|
if (raw.rotation !== 0) lines.push(`rotation: ${Math.round(raw.rotation * 100) / 100}`)
|
|
if (raw.clipsContent) lines.push(`clipsContent: true`)
|
|
|
|
serializeEffects(raw, lines)
|
|
serializeTextProps(raw, lines)
|
|
|
|
if (!raw.visible) lines.push(`visible: false`)
|
|
if (raw.locked) lines.push(`locked: true`)
|
|
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function collectNodeTree(
|
|
figma: FigmaAPI,
|
|
nodeId: string,
|
|
parentPath: string,
|
|
depth: number,
|
|
maxDepth: number
|
|
): Map<string, { path: string; id: string; serialized: string }> {
|
|
const result = new Map<string, { path: string; id: string; serialized: string }>()
|
|
const raw = figma.graph.getNode(nodeId)
|
|
if (!raw) return result
|
|
|
|
const path = parentPath ? `${parentPath}/${raw.name}` : `/${raw.name}`
|
|
result.set(path, { path, id: raw.id, serialized: serializeNodeProps(raw) })
|
|
|
|
if (depth < maxDepth) {
|
|
for (const childId of raw.childIds) {
|
|
const childNodes = collectNodeTree(figma, childId, path, depth + 1, maxDepth)
|
|
for (const [k, v] of childNodes) result.set(k, v)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function createUnifiedDiff(
|
|
oldFilename: string,
|
|
newFilename: string,
|
|
oldContent: string,
|
|
newContent: string
|
|
): string {
|
|
const patch = createTwoFilesPatch(oldFilename, newFilename, oldContent, newContent, '', '')
|
|
return patch
|
|
.split('\n')
|
|
.filter((l) => !l.startsWith('Index:') && l !== '===================================================================')
|
|
.join('\n')
|
|
.trim()
|
|
}
|
|
|
|
// ─── Analyze tools ────────────────────────────────────────────
|
|
|
|
export const analyzeColors = defineTool({
|
|
name: 'analyze_colors',
|
|
description:
|
|
'Analyze color palette usage across the current page. Shows frequency, variable bindings, and optionally clusters similar colors.',
|
|
params: {
|
|
limit: { type: 'number', description: 'Max colors to return (default: 30)' },
|
|
show_similar: {
|
|
type: 'boolean',
|
|
description: 'Include similar-color clusters for potential merging'
|
|
},
|
|
threshold: {
|
|
type: 'number',
|
|
description: 'Distance threshold for clustering (0-50, default: 15)'
|
|
}
|
|
},
|
|
execute: (figma, args) => {
|
|
const limit = args.limit ?? 30
|
|
const threshold = args.threshold ?? 15
|
|
const page = figma.currentPage
|
|
const colorMap = new Map<string, ColorEntry>()
|
|
let totalNodes = 0
|
|
|
|
page.findAll((node) => {
|
|
totalNodes++
|
|
const raw = figma.graph.getNode(node.id)
|
|
if (!raw) return false
|
|
|
|
const boundVars = raw.boundVariables
|
|
for (const fill of raw.fills) {
|
|
if (fill.type === 'SOLID' && fill.visible) {
|
|
trackColor(colorMap, fill.color, boundVars['fills'] ? String(boundVars['fills']) : null)
|
|
}
|
|
}
|
|
for (const stroke of raw.strokes) {
|
|
if (stroke.visible) {
|
|
trackColor(colorMap, stroke.color, boundVars['strokes'] ? String(boundVars['strokes']) : null)
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
const colors = [...colorMap.values()].sort((a, b) => b.count - a.count).slice(0, limit)
|
|
|
|
const result: Record<string, unknown> = {
|
|
totalNodes,
|
|
uniqueColors: colorMap.size,
|
|
colors: colors.map((c) => ({ hex: c.hex, count: c.count, variableName: c.variableName }))
|
|
}
|
|
|
|
if (args.show_similar) {
|
|
const hardcoded = [...colorMap.values()]
|
|
.filter((c) => !c.variableName)
|
|
.sort((a, b) => b.count - a.count)
|
|
const used = new Set<string>()
|
|
const clusters: { colors: string[]; totalCount: number; suggestedHex: string }[] = []
|
|
|
|
for (const color of hardcoded) {
|
|
if (used.has(color.hex)) continue
|
|
const cluster = [color]
|
|
used.add(color.hex)
|
|
for (const other of hardcoded) {
|
|
if (used.has(other.hex)) continue
|
|
if (colorDistance(color.color, other.color) <= threshold) {
|
|
cluster.push(other)
|
|
used.add(other.hex)
|
|
}
|
|
}
|
|
if (cluster.length > 1) {
|
|
clusters.push({
|
|
colors: cluster.map((c) => c.hex),
|
|
totalCount: cluster.reduce((sum, c) => sum + c.count, 0),
|
|
suggestedHex: color.hex
|
|
})
|
|
}
|
|
}
|
|
|
|
result.similarClusters = clusters.sort((a, b) => b.colors.length - a.colors.length)
|
|
}
|
|
|
|
return result
|
|
}
|
|
})
|
|
|
|
export const analyzeTypography = defineTool({
|
|
name: 'analyze_typography',
|
|
description:
|
|
'Analyze typography usage across the current page. Shows font families, sizes, weights, and their frequencies.',
|
|
params: {
|
|
limit: { type: 'number', description: 'Max styles to return (default: 30)' },
|
|
group_by: {
|
|
type: 'string',
|
|
description: 'Group results by a property',
|
|
enum: ['family', 'size', 'weight']
|
|
}
|
|
},
|
|
execute: (figma, args) => {
|
|
const limit = args.limit ?? 30
|
|
const page = figma.currentPage
|
|
const styleMap = new Map<
|
|
string,
|
|
{ family: string; size: number; weight: number; lineHeight: string; count: number }
|
|
>()
|
|
let totalTextNodes = 0
|
|
|
|
page.findAll((node) => {
|
|
if (node.type !== 'TEXT') return false
|
|
totalTextNodes++
|
|
const raw = figma.graph.getNode(node.id)
|
|
if (!raw) return false
|
|
const lh = raw.lineHeight === null ? 'auto' : `${raw.lineHeight}`
|
|
const key = `${raw.fontFamily}|${raw.fontSize}|${raw.fontWeight}|${lh}`
|
|
const entry = styleMap.get(key)
|
|
if (entry) {
|
|
entry.count++
|
|
} else {
|
|
styleMap.set(key, {
|
|
family: raw.fontFamily,
|
|
size: raw.fontSize,
|
|
weight: raw.fontWeight,
|
|
lineHeight: lh,
|
|
count: 1
|
|
})
|
|
}
|
|
return false
|
|
})
|
|
|
|
const styles = [...styleMap.values()].sort((a, b) => b.count - a.count)
|
|
|
|
if (args.group_by === 'family') {
|
|
const byFamily = new Map<string, number>()
|
|
for (const s of styles) byFamily.set(s.family, (byFamily.get(s.family) ?? 0) + s.count)
|
|
return {
|
|
totalTextNodes,
|
|
groups: [...byFamily.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([family, count]) => ({ family, count }))
|
|
}
|
|
}
|
|
|
|
if (args.group_by === 'size') {
|
|
const bySize = new Map<number, number>()
|
|
for (const s of styles) bySize.set(s.size, (bySize.get(s.size) ?? 0) + s.count)
|
|
return {
|
|
totalTextNodes,
|
|
groups: [...bySize.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([size, count]) => ({ size, count }))
|
|
}
|
|
}
|
|
|
|
if (args.group_by === 'weight') {
|
|
const byWeight = new Map<number, number>()
|
|
for (const s of styles) byWeight.set(s.weight, (byWeight.get(s.weight) ?? 0) + s.count)
|
|
return {
|
|
totalTextNodes,
|
|
groups: [...byWeight.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([weight, count]) => ({ weight, count }))
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalTextNodes,
|
|
uniqueStyles: styleMap.size,
|
|
styles: styles.slice(0, limit)
|
|
}
|
|
}
|
|
})
|
|
|
|
export const analyzeSpacing = defineTool({
|
|
name: 'analyze_spacing',
|
|
description:
|
|
'Analyze spacing values (gap, padding) across the current page. Checks grid compliance.',
|
|
params: {
|
|
grid: { type: 'number', description: 'Base grid size to check against (default: 8)' }
|
|
},
|
|
execute: (figma, args) => {
|
|
const gridSize = args.grid ?? 8
|
|
const page = figma.currentPage
|
|
const gapMap = new Map<number, number>()
|
|
const paddingMap = new Map<number, number>()
|
|
let totalNodes = 0
|
|
|
|
page.findAll((node) => {
|
|
totalNodes++
|
|
const raw = figma.graph.getNode(node.id)
|
|
if (!raw) return false
|
|
|
|
if (raw.layoutMode !== 'NONE' && raw.itemSpacing > 0) {
|
|
gapMap.set(raw.itemSpacing, (gapMap.get(raw.itemSpacing) ?? 0) + 1)
|
|
}
|
|
|
|
for (const p of [raw.paddingTop, raw.paddingRight, raw.paddingBottom, raw.paddingLeft]) {
|
|
if (p > 0) {
|
|
paddingMap.set(p, (paddingMap.get(p) ?? 0) + 1)
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
const gaps = [...gapMap.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([value, count]) => ({ value, count, onGrid: value % gridSize === 0 }))
|
|
|
|
const paddings = [...paddingMap.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([value, count]) => ({ value, count, onGrid: value % gridSize === 0 }))
|
|
|
|
const offGridGaps = gaps.filter((g) => !g.onGrid)
|
|
const offGridPaddings = paddings.filter((p) => !p.onGrid)
|
|
|
|
return {
|
|
totalNodes,
|
|
gridSize,
|
|
gaps,
|
|
paddings,
|
|
offGridGaps: offGridGaps.map((g) => g.value),
|
|
offGridPaddings: offGridPaddings.map((p) => p.value)
|
|
}
|
|
}
|
|
})
|
|
|
|
export const analyzeClusters = defineTool({
|
|
name: 'analyze_clusters',
|
|
description:
|
|
'Find repeated design patterns (potential components). Groups nodes by structural signature — type, size, and child structure.',
|
|
params: {
|
|
min_count: { type: 'number', description: 'Min instances to form a cluster (default: 2)' },
|
|
min_size: { type: 'number', description: 'Min node size in px (default: 30)' },
|
|
limit: { type: 'number', description: 'Max clusters to return (default: 20)' }
|
|
},
|
|
execute: (figma, args) => {
|
|
const minCount = args.min_count ?? 2
|
|
const minSize = args.min_size ?? 30
|
|
const limit = args.limit ?? 20
|
|
const page = figma.currentPage
|
|
|
|
const signatureMap = new Map<
|
|
string,
|
|
{ id: string; name: string; type: string; width: number; height: number; childCount: number }[]
|
|
>()
|
|
let totalNodes = 0
|
|
|
|
page.findAll((node) => {
|
|
totalNodes++
|
|
const raw = figma.graph.getNode(node.id)
|
|
if (!raw) return false
|
|
if (raw.width < minSize || raw.height < minSize) return false
|
|
if (raw.type === 'CANVAS' || raw.type === 'INSTANCE') return false
|
|
|
|
const childTypes = new Map<string, number>()
|
|
for (const childId of raw.childIds) {
|
|
const child = figma.graph.getNode(childId)
|
|
if (child) childTypes.set(child.type, (childTypes.get(child.type) ?? 0) + 1)
|
|
}
|
|
|
|
const w = Math.round(raw.width / 10) * 10
|
|
const h = Math.round(raw.height / 10) * 10
|
|
const childSig = [...childTypes.entries()]
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([t, c]) => `${t}:${c}`)
|
|
.join(',')
|
|
const signature = `${raw.type}:${w}x${h}|${childSig}`
|
|
|
|
const list = signatureMap.get(signature) ?? []
|
|
list.push({
|
|
id: raw.id,
|
|
name: raw.name,
|
|
type: raw.type,
|
|
width: raw.width,
|
|
height: raw.height,
|
|
childCount: raw.childIds.length
|
|
})
|
|
signatureMap.set(signature, list)
|
|
return false
|
|
})
|
|
|
|
const clusters = [...signatureMap.entries()]
|
|
.filter(([, nodes]) => nodes.length >= minCount)
|
|
.map(([signature, nodes]) => {
|
|
const avgW = nodes.reduce((s, n) => s + n.width, 0) / nodes.length
|
|
const avgH = nodes.reduce((s, n) => s + n.height, 0) / nodes.length
|
|
const widths = nodes.map((n) => n.width)
|
|
const heights = nodes.map((n) => n.height)
|
|
const widthRange = Math.max(...widths) - Math.min(...widths)
|
|
const heightRange = Math.max(...heights) - Math.min(...heights)
|
|
|
|
let confidence = 100
|
|
if (nodes.length >= 2) {
|
|
const base = nodes[0]
|
|
let score = 0
|
|
for (const n of nodes.slice(1)) {
|
|
const sizeDiff = Math.abs(n.width - base.width) + Math.abs(n.height - base.height)
|
|
const childDiff = Math.abs(n.childCount - base.childCount)
|
|
if (sizeDiff <= 4 && childDiff === 0) score++
|
|
else if (sizeDiff <= 10 && childDiff <= 1) score += 0.8
|
|
else if (sizeDiff <= 20 && childDiff <= 2) score += 0.6
|
|
else score += 0.4
|
|
}
|
|
confidence = Math.round((score / (nodes.length - 1)) * 100)
|
|
}
|
|
|
|
return {
|
|
signature,
|
|
count: nodes.length,
|
|
avgWidth: Math.round(avgW),
|
|
avgHeight: Math.round(avgH),
|
|
widthRange: Math.round(widthRange),
|
|
heightRange: Math.round(heightRange),
|
|
confidence,
|
|
examples: nodes.slice(0, 3).map((n) => ({ id: n.id, name: n.name }))
|
|
}
|
|
})
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, limit)
|
|
|
|
return { totalNodes, clusters }
|
|
}
|
|
})
|
|
|
|
// ─── Diff tools ───────────────────────────────────────────────
|
|
|
|
export const diffCreate = defineTool({
|
|
name: 'diff_create',
|
|
description:
|
|
'Create a structural diff between two node trees. Compares properties (fills, strokes, effects, text, size, position) in unified diff format.',
|
|
params: {
|
|
from: { type: 'string', description: 'Source node ID', required: true },
|
|
to: { type: 'string', description: 'Target node ID', required: true },
|
|
depth: { type: 'number', description: 'Max tree depth (default: 10)' }
|
|
},
|
|
execute: (figma, args) => {
|
|
const maxDepth = args.depth ?? 10
|
|
const fromNode = figma.graph.getNode(args.from)
|
|
const toNode = figma.graph.getNode(args.to)
|
|
if (!fromNode) return { error: `Node "${args.from}" not found` }
|
|
if (!toNode) return { error: `Node "${args.to}" not found` }
|
|
|
|
const fromNodes = collectNodeTree(figma, args.from, '', 0, maxDepth)
|
|
const toNodes = collectNodeTree(figma, args.to, '', 0, maxDepth)
|
|
const allPaths = new Set([...fromNodes.keys(), ...toNodes.keys()])
|
|
|
|
const patches: string[] = []
|
|
for (const path of allPaths) {
|
|
const fromEntry = fromNodes.get(path)
|
|
const toEntry = toNodes.get(path)
|
|
|
|
if (!fromEntry && toEntry) {
|
|
const filename = `${path} #${toEntry.id}`
|
|
const newLines = toEntry.serialized.split('\n')
|
|
patches.push(
|
|
`--- /dev/null\n+++ ${filename}\n@@ -0,0 +1,${newLines.length} @@\n${newLines.map((l) => `+${l}`).join('\n')}`
|
|
)
|
|
} else if (fromEntry && !toEntry) {
|
|
const filename = `${path} #${fromEntry.id}`
|
|
const oldLines = fromEntry.serialized.split('\n')
|
|
patches.push(
|
|
`--- ${filename}\n+++ /dev/null\n@@ -1,${oldLines.length} +0,0 @@\n${oldLines.map((l) => `-${l}`).join('\n')}`
|
|
)
|
|
} else if (fromEntry && toEntry && fromEntry.serialized !== toEntry.serialized) {
|
|
patches.push(
|
|
createUnifiedDiff(
|
|
`${path} #${fromEntry.id}`,
|
|
`${path} #${toEntry.id}`,
|
|
fromEntry.serialized,
|
|
toEntry.serialized
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
if (patches.length === 0) return { diff: null, message: 'No differences found' }
|
|
return { diff: patches.join('\n') }
|
|
}
|
|
})
|
|
|
|
export const diffShow = defineTool({
|
|
name: 'diff_show',
|
|
description:
|
|
'Preview what would change if properties were applied to a node. Shows a unified diff of current vs proposed state.',
|
|
params: {
|
|
id: { type: 'string', description: 'Node ID', required: true },
|
|
props: {
|
|
type: 'string',
|
|
description:
|
|
'JSON object of new properties, e.g. \'{"opacity": 1, "fill": "#FF0000", "width": 200}\'',
|
|
required: true
|
|
}
|
|
},
|
|
execute: (figma, args) => {
|
|
const raw = figma.graph.getNode(args.id)
|
|
if (!raw) return { error: `Node "${args.id}" not found` }
|
|
|
|
const oldContent = serializeNodeProps(raw)
|
|
|
|
let newProps: Record<string, unknown>
|
|
try {
|
|
newProps = JSON.parse(args.props)
|
|
} catch {
|
|
return { error: 'Invalid JSON in props' }
|
|
}
|
|
|
|
const modified: SceneNode = structuredClone(raw)
|
|
if (newProps.fill) {
|
|
modified.fills = [
|
|
{ type: 'SOLID', color: parseColor(newProps.fill as string), opacity: 1, visible: true }
|
|
]
|
|
}
|
|
if (newProps.stroke) {
|
|
modified.strokes = [
|
|
{
|
|
color: parseColor(newProps.stroke as string),
|
|
weight: modified.strokes[0]?.weight ?? 1,
|
|
opacity: 1,
|
|
visible: true,
|
|
align: modified.strokes[0]?.align ?? 'INSIDE'
|
|
}
|
|
]
|
|
}
|
|
if (newProps.opacity !== undefined) modified.opacity = Number(newProps.opacity)
|
|
if (newProps.radius !== undefined) {
|
|
const r = Number(newProps.radius)
|
|
modified.cornerRadius = r
|
|
modified.topLeftRadius = r
|
|
modified.topRightRadius = r
|
|
modified.bottomRightRadius = r
|
|
modified.bottomLeftRadius = r
|
|
}
|
|
if (newProps.width !== undefined) modified.width = Number(newProps.width)
|
|
if (newProps.height !== undefined) modified.height = Number(newProps.height)
|
|
if (newProps.x !== undefined) modified.x = Number(newProps.x)
|
|
if (newProps.y !== undefined) modified.y = Number(newProps.y)
|
|
if (newProps.visible !== undefined) modified.visible = Boolean(newProps.visible)
|
|
if (newProps.locked !== undefined) modified.locked = Boolean(newProps.locked)
|
|
if (newProps.rotation !== undefined) modified.rotation = Number(newProps.rotation)
|
|
if (newProps.text !== undefined) modified.text = newProps.text as string
|
|
|
|
const newContent = serializeNodeProps(modified)
|
|
|
|
if (oldContent === newContent) return { diff: null, message: 'No changes' }
|
|
|
|
const filename = `/${raw.name} #${args.id}`
|
|
return { diff: createUnifiedDiff(filename, filename, oldContent, newContent) }
|
|
}
|
|
})
|
|
|
|
// ─── Eval escape hatch ────────────────────────────────────────
|
|
|
|
export const evalCode = defineTool({
|
|
name: 'eval',
|
|
description:
|
|
'Execute JavaScript with full Figma Plugin API access. Use for operations not covered by other tools. The `figma` global is available.',
|
|
params: {
|
|
code: { type: 'string', description: 'JavaScript code to execute', required: true }
|
|
},
|
|
execute: async (figma, { code }) => {
|
|
// eslint-disable-next-line no-empty-function
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()`
|
|
const fn = new AsyncFunction('figma', wrapped)
|
|
const result = await fn(figma)
|
|
if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON()
|
|
return result ?? null
|
|
}
|
|
})
|