@nnao45/figma-use 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +991 -0
- package/LICENSE +22 -0
- package/README.md +569 -0
- package/SKILL.md +683 -0
- package/bin/figma-use.js +9 -0
- package/dist/cli/index.js +496 -0
- package/package.json +87 -0
- package/packages/cli/src/render/component-set.tsx +157 -0
- package/packages/cli/src/render/components.tsx +115 -0
- package/packages/cli/src/render/icon.ts +166 -0
- package/packages/cli/src/render/index.ts +47 -0
- package/packages/cli/src/render/jsx-dev-runtime.ts +6 -0
- package/packages/cli/src/render/jsx-runtime.ts +90 -0
- package/packages/cli/src/render/mini-react.ts +33 -0
- package/packages/cli/src/render/render-from-string.ts +121 -0
- package/packages/cli/src/render/render-jsx.ts +44 -0
- package/packages/cli/src/render/tree.ts +148 -0
- package/packages/cli/src/render/vars.ts +186 -0
- package/packages/cli/src/render/widget-renderer.ts +163 -0
- package/packages/plugin/src/main.ts +2747 -0
- package/packages/plugin/src/query.ts +253 -0
- package/packages/plugin/src/rpc.ts +5238 -0
- package/packages/plugin/src/ui.html +25 -0
- package/packages/plugin/src/ui.ts +74 -0
|
@@ -0,0 +1,2747 @@
|
|
|
1
|
+
import svgpath from 'svgpath'
|
|
2
|
+
|
|
3
|
+
import { queryNodes } from './query.ts'
|
|
4
|
+
|
|
5
|
+
console.log('[Figma Bridge] Plugin main loaded at', new Date().toISOString())
|
|
6
|
+
|
|
7
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
8
|
+
|
|
9
|
+
async function retry<T>(
|
|
10
|
+
fn: () => Promise<T | null | undefined>,
|
|
11
|
+
maxAttempts = 10,
|
|
12
|
+
delayMs = 50,
|
|
13
|
+
backoff: 'fixed' | 'linear' | 'exponential' = 'fixed'
|
|
14
|
+
): Promise<T | null> {
|
|
15
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
16
|
+
const result = await fn()
|
|
17
|
+
if (result) return result
|
|
18
|
+
if (attempt < maxAttempts - 1) {
|
|
19
|
+
const delay =
|
|
20
|
+
backoff === 'linear'
|
|
21
|
+
? delayMs * (attempt + 1)
|
|
22
|
+
: backoff === 'exponential'
|
|
23
|
+
? delayMs * Math.pow(2, attempt)
|
|
24
|
+
: delayMs
|
|
25
|
+
await sleep(delay)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
figma.showUI(__html__, { width: 300, height: 200 })
|
|
32
|
+
|
|
33
|
+
// Font cache to avoid repeated loadFontAsync calls
|
|
34
|
+
const loadedFonts = new Set<string>()
|
|
35
|
+
const fontLoadPromises = new Map<string, Promise<void>>()
|
|
36
|
+
|
|
37
|
+
// Preload common font
|
|
38
|
+
const interPromise = figma.loadFontAsync({ family: 'Inter', style: 'Regular' })
|
|
39
|
+
fontLoadPromises.set('Inter:Regular', interPromise)
|
|
40
|
+
interPromise.then(() => loadedFonts.add('Inter:Regular'))
|
|
41
|
+
|
|
42
|
+
function loadFont(family: string, style: string): Promise<void> | void {
|
|
43
|
+
const key = `${family}:${style}`
|
|
44
|
+
if (loadedFonts.has(key)) return // sync return if cached
|
|
45
|
+
|
|
46
|
+
// Check if already loading
|
|
47
|
+
const pending = fontLoadPromises.get(key)
|
|
48
|
+
if (pending) return pending
|
|
49
|
+
|
|
50
|
+
// Start new load
|
|
51
|
+
const promise = figma.loadFontAsync({ family, style })
|
|
52
|
+
fontLoadPromises.set(key, promise)
|
|
53
|
+
promise.then(() => {
|
|
54
|
+
loadedFonts.add(key)
|
|
55
|
+
fontLoadPromises.delete(key)
|
|
56
|
+
})
|
|
57
|
+
return promise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fast node creation for batch operations - skips full serialization
|
|
61
|
+
async function createNodeFast(
|
|
62
|
+
command: string,
|
|
63
|
+
args: Record<string, unknown> | undefined,
|
|
64
|
+
nodeCache?: Map<string, SceneNode>,
|
|
65
|
+
deferredLayouts?: Array<{
|
|
66
|
+
frame: FrameNode
|
|
67
|
+
layoutMode: 'HORIZONTAL' | 'VERTICAL'
|
|
68
|
+
itemSpacing?: number
|
|
69
|
+
padding?: { top: number; right: number; bottom: number; left: number }
|
|
70
|
+
}>
|
|
71
|
+
): Promise<SceneNode | null> {
|
|
72
|
+
if (!args) return null
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
x = 0,
|
|
76
|
+
y = 0,
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
name,
|
|
80
|
+
fill,
|
|
81
|
+
stroke,
|
|
82
|
+
strokeWeight,
|
|
83
|
+
radius,
|
|
84
|
+
opacity,
|
|
85
|
+
layoutMode,
|
|
86
|
+
itemSpacing,
|
|
87
|
+
padding,
|
|
88
|
+
text,
|
|
89
|
+
fontSize,
|
|
90
|
+
fontFamily,
|
|
91
|
+
fontStyle
|
|
92
|
+
} = args as {
|
|
93
|
+
x?: number
|
|
94
|
+
y?: number
|
|
95
|
+
width?: number
|
|
96
|
+
height?: number
|
|
97
|
+
name?: string
|
|
98
|
+
fill?: string
|
|
99
|
+
stroke?: string
|
|
100
|
+
strokeWeight?: number
|
|
101
|
+
radius?: number
|
|
102
|
+
opacity?: number
|
|
103
|
+
layoutMode?: string
|
|
104
|
+
itemSpacing?: number
|
|
105
|
+
padding?: { top: number; right: number; bottom: number; left: number }
|
|
106
|
+
text?: string
|
|
107
|
+
fontSize?: number
|
|
108
|
+
fontFamily?: string
|
|
109
|
+
fontStyle?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let node: SceneNode | null = null
|
|
113
|
+
|
|
114
|
+
switch (command) {
|
|
115
|
+
case 'create-frame': {
|
|
116
|
+
const frame = figma.createFrame()
|
|
117
|
+
frame.x = x
|
|
118
|
+
frame.y = y
|
|
119
|
+
frame.resize(width || 100, height || 100)
|
|
120
|
+
if (name) frame.name = name
|
|
121
|
+
if (fill) frame.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
|
|
122
|
+
if (stroke) frame.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
|
|
123
|
+
if (strokeWeight) frame.strokeWeight = strokeWeight
|
|
124
|
+
if (typeof radius === 'number') frame.cornerRadius = radius
|
|
125
|
+
if (typeof opacity === 'number') frame.opacity = opacity
|
|
126
|
+
if (layoutMode && layoutMode !== 'NONE') {
|
|
127
|
+
deferredLayouts?.push({
|
|
128
|
+
frame,
|
|
129
|
+
layoutMode: layoutMode as 'HORIZONTAL' | 'VERTICAL',
|
|
130
|
+
itemSpacing,
|
|
131
|
+
padding
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
node = frame
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
case 'create-rectangle': {
|
|
138
|
+
const rect = figma.createRectangle()
|
|
139
|
+
rect.x = x
|
|
140
|
+
rect.y = y
|
|
141
|
+
rect.resize(width || 100, height || 100)
|
|
142
|
+
if (name) rect.name = name
|
|
143
|
+
if (fill) rect.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
|
|
144
|
+
if (stroke) rect.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
|
|
145
|
+
if (strokeWeight) rect.strokeWeight = strokeWeight
|
|
146
|
+
if (typeof radius === 'number') rect.cornerRadius = radius
|
|
147
|
+
if (typeof opacity === 'number') rect.opacity = opacity
|
|
148
|
+
node = rect
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
case 'create-ellipse': {
|
|
152
|
+
const ellipse = figma.createEllipse()
|
|
153
|
+
ellipse.x = x
|
|
154
|
+
ellipse.y = y
|
|
155
|
+
ellipse.resize(width || 100, height || 100)
|
|
156
|
+
if (name) ellipse.name = name
|
|
157
|
+
if (fill) ellipse.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
|
|
158
|
+
if (stroke) ellipse.strokes = [{ type: 'SOLID', color: hexToRgb(getHexColor(stroke)) }]
|
|
159
|
+
if (strokeWeight) ellipse.strokeWeight = strokeWeight
|
|
160
|
+
if (typeof opacity === 'number') ellipse.opacity = opacity
|
|
161
|
+
node = ellipse
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
case 'create-text': {
|
|
165
|
+
const textNode = figma.createText()
|
|
166
|
+
const family = fontFamily || 'Inter'
|
|
167
|
+
const style = fontStyle || 'Regular'
|
|
168
|
+
await loadFont(family, style)
|
|
169
|
+
textNode.fontName = { family, style }
|
|
170
|
+
textNode.characters = text || ''
|
|
171
|
+
textNode.x = x
|
|
172
|
+
textNode.y = y
|
|
173
|
+
if (name) textNode.name = name
|
|
174
|
+
if (fontSize) textNode.fontSize = fontSize
|
|
175
|
+
if (fill) textNode.fills = [{ type: 'SOLID', color: hexToRgb(getHexColor(fill)) }]
|
|
176
|
+
if (typeof opacity === 'number') textNode.opacity = opacity
|
|
177
|
+
node = textNode
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return node
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Commands that need access to nodes on other pages
|
|
188
|
+
const NEEDS_ALL_PAGES = new Set([
|
|
189
|
+
'get-node-info',
|
|
190
|
+
'get-node-tree',
|
|
191
|
+
'get-node-children',
|
|
192
|
+
'set-parent',
|
|
193
|
+
'clone-node',
|
|
194
|
+
'delete-node',
|
|
195
|
+
'get-pages',
|
|
196
|
+
'set-current-page',
|
|
197
|
+
'get-components',
|
|
198
|
+
'get-styles',
|
|
199
|
+
'export-node',
|
|
200
|
+
'screenshot'
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
let allPagesLoaded = false
|
|
204
|
+
|
|
205
|
+
figma.ui.onmessage = async (msg: {
|
|
206
|
+
type: string
|
|
207
|
+
id?: string
|
|
208
|
+
command?: string
|
|
209
|
+
args?: unknown
|
|
210
|
+
}) => {
|
|
211
|
+
// Handle file info request
|
|
212
|
+
if (msg.type === 'get-file-info') {
|
|
213
|
+
// sessionID is unique per open file
|
|
214
|
+
const sessionId = figma.currentPage.id.split(':')[0]
|
|
215
|
+
const fileName = figma.root.name
|
|
216
|
+
figma.ui.postMessage({ type: 'file-info', sessionId, fileName })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (msg.type !== 'command') return
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Only load all pages when needed
|
|
224
|
+
if (!allPagesLoaded && NEEDS_ALL_PAGES.has(msg.command)) {
|
|
225
|
+
await figma.loadAllPagesAsync()
|
|
226
|
+
allPagesLoaded = true
|
|
227
|
+
}
|
|
228
|
+
const result = await handleCommand(msg.command, msg.args)
|
|
229
|
+
figma.ui.postMessage({ type: 'result', id: msg.id, result })
|
|
230
|
+
} catch (error) {
|
|
231
|
+
figma.ui.postMessage({ type: 'result', id: msg.id, error: String(error) })
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function handleCommand(command: string, args?: unknown): Promise<unknown> {
|
|
236
|
+
switch (command) {
|
|
237
|
+
// ==================== BATCH ====================
|
|
238
|
+
case 'batch': {
|
|
239
|
+
const { commands } = args as {
|
|
240
|
+
commands: Array<{ command: string; args?: Record<string, unknown>; parentRef?: string }>
|
|
241
|
+
}
|
|
242
|
+
const results: Array<{ id: string; name: string }> = []
|
|
243
|
+
const refMap = new Map<string, string>() // ref -> actual node id
|
|
244
|
+
const nodeCache = new Map<string, SceneNode>() // cache created nodes for parent lookups
|
|
245
|
+
const deferredLayouts: Array<{
|
|
246
|
+
frame: FrameNode
|
|
247
|
+
layoutMode: 'HORIZONTAL' | 'VERTICAL'
|
|
248
|
+
itemSpacing?: number
|
|
249
|
+
padding?: { top: number; right: number; bottom: number; left: number }
|
|
250
|
+
}> = []
|
|
251
|
+
const internalAttachments: Array<{ node: SceneNode; parentId: string }> = []
|
|
252
|
+
const externalAttachments: Array<{ node: SceneNode; parentId: string }> = []
|
|
253
|
+
const rootNodes: SceneNode[] = []
|
|
254
|
+
|
|
255
|
+
for (const cmd of commands) {
|
|
256
|
+
// Resolve parent reference if needed
|
|
257
|
+
if (cmd.args?.parentRef && refMap.has(cmd.args.parentRef)) {
|
|
258
|
+
cmd.args.parentId = refMap.get(cmd.args.parentRef)
|
|
259
|
+
delete cmd.args.parentRef
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Use fast path for create commands
|
|
263
|
+
const node = await createNodeFast(cmd.command, cmd.args, nodeCache, deferredLayouts)
|
|
264
|
+
if (node) {
|
|
265
|
+
results.push({ id: node.id, name: node.name })
|
|
266
|
+
nodeCache.set(node.id, node) // cache for child lookups
|
|
267
|
+
if (cmd.args?.ref) {
|
|
268
|
+
refMap.set(cmd.args.ref as string, node.id)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const parentId = cmd.args?.parentId as string | undefined
|
|
272
|
+
if (parentId) {
|
|
273
|
+
if (nodeCache.has(parentId)) {
|
|
274
|
+
internalAttachments.push({ node, parentId })
|
|
275
|
+
} else {
|
|
276
|
+
externalAttachments.push({ node, parentId })
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
rootNodes.push(node)
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Fallback to full handler
|
|
283
|
+
const result = (await handleCommand(cmd.command, cmd.args)) as {
|
|
284
|
+
id: string
|
|
285
|
+
name: string
|
|
286
|
+
}
|
|
287
|
+
results.push(result)
|
|
288
|
+
if (cmd.args?.ref) {
|
|
289
|
+
refMap.set(cmd.args.ref as string, result.id)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const attachment of internalAttachments) {
|
|
295
|
+
const parent = nodeCache.get(attachment.parentId)
|
|
296
|
+
if (parent && 'appendChild' in parent) {
|
|
297
|
+
parent.appendChild(attachment.node)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Apply layouts in reverse order (children first, then parents)
|
|
302
|
+
for (let i = deferredLayouts.length - 1; i >= 0; i--) {
|
|
303
|
+
const layout = deferredLayouts[i]
|
|
304
|
+
layout.frame.layoutMode = layout.layoutMode
|
|
305
|
+
layout.frame.primaryAxisSizingMode = 'AUTO'
|
|
306
|
+
layout.frame.counterAxisSizingMode = 'AUTO'
|
|
307
|
+
if (layout.itemSpacing) layout.frame.itemSpacing = layout.itemSpacing
|
|
308
|
+
if (layout.padding) {
|
|
309
|
+
layout.frame.paddingTop = layout.padding.top
|
|
310
|
+
layout.frame.paddingRight = layout.padding.right
|
|
311
|
+
layout.frame.paddingBottom = layout.padding.bottom
|
|
312
|
+
layout.frame.paddingLeft = layout.padding.left
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const node of rootNodes) {
|
|
317
|
+
figma.currentPage.appendChild(node)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const attachment of externalAttachments) {
|
|
321
|
+
let parent = nodeCache.get(attachment.parentId)
|
|
322
|
+
if (!parent) {
|
|
323
|
+
parent = (await figma.getNodeByIdAsync(attachment.parentId)) as SceneNode | null
|
|
324
|
+
}
|
|
325
|
+
if (parent && 'appendChild' in parent) {
|
|
326
|
+
parent.appendChild(attachment.node)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
figma.commitUndo()
|
|
331
|
+
|
|
332
|
+
return results
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ==================== READ ====================
|
|
336
|
+
case 'get-selection':
|
|
337
|
+
return figma.currentPage.selection.map(serializeNode)
|
|
338
|
+
|
|
339
|
+
case 'get-node-info': {
|
|
340
|
+
const { id } = args as { id: string }
|
|
341
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
342
|
+
return node ? serializeNode(node) : null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'get-current-page':
|
|
346
|
+
return { id: figma.currentPage.id, name: figma.currentPage.name }
|
|
347
|
+
|
|
348
|
+
case 'list-fonts': {
|
|
349
|
+
const fonts = await figma.listAvailableFontsAsync()
|
|
350
|
+
return fonts.map((f) => ({ family: f.fontName.family, style: f.fontName.style }))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case 'get-node-tree': {
|
|
354
|
+
const { id } = args as { id: string }
|
|
355
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
356
|
+
if (!node) throw new Error('Node not found')
|
|
357
|
+
|
|
358
|
+
const serializeTreeNode = (n: BaseNode): object => {
|
|
359
|
+
const base: Record<string, unknown> = {
|
|
360
|
+
id: n.id,
|
|
361
|
+
name: n.name,
|
|
362
|
+
type: n.type
|
|
363
|
+
}
|
|
364
|
+
if ('x' in n) base.x = Math.round(n.x)
|
|
365
|
+
if ('y' in n) base.y = Math.round(n.y)
|
|
366
|
+
if ('width' in n) base.width = Math.round(n.width)
|
|
367
|
+
if ('height' in n) base.height = Math.round(n.height)
|
|
368
|
+
|
|
369
|
+
// Only essential properties for tree view
|
|
370
|
+
if ('fills' in n && Array.isArray(n.fills)) {
|
|
371
|
+
const solid = n.fills.find((f: Paint) => f.type === 'SOLID') as SolidPaint | undefined
|
|
372
|
+
if (solid) base.fills = [{ type: 'SOLID', color: rgbToHex(solid.color) }]
|
|
373
|
+
}
|
|
374
|
+
if ('strokes' in n && Array.isArray(n.strokes) && n.strokes.length > 0) {
|
|
375
|
+
const solid = n.strokes.find((s: Paint) => s.type === 'SOLID') as SolidPaint | undefined
|
|
376
|
+
if (solid) base.strokes = [{ type: 'SOLID', color: rgbToHex(solid.color) }]
|
|
377
|
+
}
|
|
378
|
+
if ('strokeWeight' in n && typeof n.strokeWeight === 'number' && n.strokeWeight > 0) {
|
|
379
|
+
base.strokeWeight = n.strokeWeight
|
|
380
|
+
}
|
|
381
|
+
if ('cornerRadius' in n && typeof n.cornerRadius === 'number' && n.cornerRadius > 0) {
|
|
382
|
+
base.cornerRadius = n.cornerRadius
|
|
383
|
+
}
|
|
384
|
+
if ('opacity' in n && n.opacity !== 1) base.opacity = n.opacity
|
|
385
|
+
if ('visible' in n && !n.visible) base.visible = false
|
|
386
|
+
if ('locked' in n && n.locked) base.locked = true
|
|
387
|
+
if ('layoutMode' in n && n.layoutMode !== 'NONE') {
|
|
388
|
+
base.layoutMode = n.layoutMode
|
|
389
|
+
if ('itemSpacing' in n) base.itemSpacing = n.itemSpacing
|
|
390
|
+
}
|
|
391
|
+
if (n.type === 'TEXT') {
|
|
392
|
+
const t = n as TextNode
|
|
393
|
+
base.characters = t.characters
|
|
394
|
+
if (typeof t.fontSize === 'number') base.fontSize = t.fontSize
|
|
395
|
+
if (typeof t.fontName === 'object') {
|
|
396
|
+
base.fontFamily = t.fontName.family
|
|
397
|
+
base.fontStyle = t.fontName.style
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if ('children' in n && (n as FrameNode).children) {
|
|
402
|
+
base.children = (n as FrameNode).children.map(serializeTreeNode)
|
|
403
|
+
}
|
|
404
|
+
return base
|
|
405
|
+
}
|
|
406
|
+
return serializeTreeNode(node)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
case 'get-all-components': {
|
|
410
|
+
const {
|
|
411
|
+
name,
|
|
412
|
+
limit = 50,
|
|
413
|
+
page
|
|
414
|
+
} = (args as { name?: string; limit?: number; page?: string }) || {}
|
|
415
|
+
const components: object[] = []
|
|
416
|
+
const nameLower = name?.toLowerCase()
|
|
417
|
+
|
|
418
|
+
const searchNode = (node: SceneNode): boolean => {
|
|
419
|
+
if (components.length >= limit) return false
|
|
420
|
+
if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
|
|
421
|
+
if (!nameLower || node.name.toLowerCase().includes(nameLower)) {
|
|
422
|
+
components.push(serializeNode(node))
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if ('children' in node) {
|
|
426
|
+
for (const child of (node as FrameNode).children) {
|
|
427
|
+
if (!searchNode(child)) return false
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return components.length < limit
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const pages = page
|
|
434
|
+
? figma.root.children.filter(
|
|
435
|
+
(p) => p.id === page || p.name === page || p.name.includes(page)
|
|
436
|
+
)
|
|
437
|
+
: figma.root.children
|
|
438
|
+
|
|
439
|
+
for (const pageNode of pages) {
|
|
440
|
+
if (components.length >= limit) break
|
|
441
|
+
for (const child of pageNode.children) {
|
|
442
|
+
if (!searchNode(child)) break
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return components
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
case 'get-pages':
|
|
449
|
+
return figma.root.children.map((page) => ({ id: page.id, name: page.name }))
|
|
450
|
+
|
|
451
|
+
case 'create-page': {
|
|
452
|
+
const { name } = args as { name: string }
|
|
453
|
+
const page = figma.createPage()
|
|
454
|
+
page.name = name
|
|
455
|
+
return { id: page.id, name: page.name }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case 'set-current-page': {
|
|
459
|
+
const { page: pageArg } = args as { page: string }
|
|
460
|
+
let page: PageNode | null = null
|
|
461
|
+
|
|
462
|
+
// Try by ID first
|
|
463
|
+
const byId = (await figma.getNodeByIdAsync(pageArg)) as PageNode | null
|
|
464
|
+
if (byId && byId.type === 'PAGE') {
|
|
465
|
+
page = byId
|
|
466
|
+
} else {
|
|
467
|
+
// Try by name
|
|
468
|
+
page =
|
|
469
|
+
figma.root.children.find((p) => p.name === pageArg || p.name.includes(pageArg)) || null
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!page) throw new Error('Page not found')
|
|
473
|
+
await figma.setCurrentPageAsync(page)
|
|
474
|
+
return { id: page.id, name: page.name }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
case 'get-local-styles': {
|
|
478
|
+
const { type } = (args as { type?: string }) || {}
|
|
479
|
+
const result: Record<string, object[]> = {}
|
|
480
|
+
if (!type || type === 'all' || type === 'paint') {
|
|
481
|
+
const styles = await figma.getLocalPaintStylesAsync()
|
|
482
|
+
if (styles.length > 0) {
|
|
483
|
+
result.paintStyles = styles.map((s) => ({
|
|
484
|
+
id: s.id,
|
|
485
|
+
name: s.name,
|
|
486
|
+
paints: s.paints.map(serializePaint)
|
|
487
|
+
}))
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!type || type === 'all' || type === 'text') {
|
|
491
|
+
const styles = await figma.getLocalTextStylesAsync()
|
|
492
|
+
if (styles.length > 0) {
|
|
493
|
+
result.textStyles = styles.map((s) => ({
|
|
494
|
+
id: s.id,
|
|
495
|
+
name: s.name,
|
|
496
|
+
fontSize: s.fontSize,
|
|
497
|
+
fontFamily: s.fontName.family,
|
|
498
|
+
fontStyle: s.fontName.style
|
|
499
|
+
}))
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (!type || type === 'all' || type === 'effect') {
|
|
503
|
+
const styles = await figma.getLocalEffectStylesAsync()
|
|
504
|
+
if (styles.length > 0) {
|
|
505
|
+
result.effectStyles = styles.map((s) => ({
|
|
506
|
+
id: s.id,
|
|
507
|
+
name: s.name,
|
|
508
|
+
effects: s.effects.map((e) => ({
|
|
509
|
+
type: e.type,
|
|
510
|
+
radius: 'radius' in e ? e.radius : undefined
|
|
511
|
+
}))
|
|
512
|
+
}))
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (!type || type === 'all' || type === 'grid') {
|
|
516
|
+
const styles = await figma.getLocalGridStylesAsync()
|
|
517
|
+
if (styles.length > 0) {
|
|
518
|
+
result.gridStyles = styles.map((s) => ({
|
|
519
|
+
id: s.id,
|
|
520
|
+
name: s.name,
|
|
521
|
+
grids: s.layoutGrids.length
|
|
522
|
+
}))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return result
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case 'get-viewport':
|
|
529
|
+
return {
|
|
530
|
+
center: figma.viewport.center,
|
|
531
|
+
zoom: figma.viewport.zoom,
|
|
532
|
+
bounds: figma.viewport.bounds
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ==================== CREATE SHAPES ====================
|
|
536
|
+
case 'create-rectangle': {
|
|
537
|
+
const { x, y, width, height, name, parentId, fill, stroke, strokeWeight, radius, opacity } =
|
|
538
|
+
args as {
|
|
539
|
+
x: number
|
|
540
|
+
y: number
|
|
541
|
+
width: number
|
|
542
|
+
height: number
|
|
543
|
+
name?: string
|
|
544
|
+
parentId?: string
|
|
545
|
+
fill?: string
|
|
546
|
+
stroke?: string
|
|
547
|
+
strokeWeight?: number
|
|
548
|
+
radius?: number
|
|
549
|
+
opacity?: number
|
|
550
|
+
}
|
|
551
|
+
const rect = figma.createRectangle()
|
|
552
|
+
rect.x = x
|
|
553
|
+
rect.y = y
|
|
554
|
+
rect.resize(width, height)
|
|
555
|
+
if (name) rect.name = name
|
|
556
|
+
if (fill) rect.fills = [await createSolidPaint(fill)]
|
|
557
|
+
if (stroke) rect.strokes = [await createSolidPaint(stroke)]
|
|
558
|
+
if (strokeWeight !== undefined) rect.strokeWeight = strokeWeight
|
|
559
|
+
if (radius !== undefined) rect.cornerRadius = radius
|
|
560
|
+
if (opacity !== undefined) rect.opacity = opacity
|
|
561
|
+
await appendToParent(rect, parentId)
|
|
562
|
+
return serializeNode(rect)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
case 'create-ellipse': {
|
|
566
|
+
const { x, y, width, height, name, parentId, fill, stroke, strokeWeight, opacity } = args as {
|
|
567
|
+
x: number
|
|
568
|
+
y: number
|
|
569
|
+
width: number
|
|
570
|
+
height: number
|
|
571
|
+
name?: string
|
|
572
|
+
parentId?: string
|
|
573
|
+
fill?: string
|
|
574
|
+
stroke?: string
|
|
575
|
+
strokeWeight?: number
|
|
576
|
+
opacity?: number
|
|
577
|
+
}
|
|
578
|
+
const ellipse = figma.createEllipse()
|
|
579
|
+
ellipse.x = x
|
|
580
|
+
ellipse.y = y
|
|
581
|
+
ellipse.resize(width, height)
|
|
582
|
+
if (name) ellipse.name = name
|
|
583
|
+
if (fill) ellipse.fills = [await createSolidPaint(fill)]
|
|
584
|
+
if (stroke) ellipse.strokes = [await createSolidPaint(stroke)]
|
|
585
|
+
if (strokeWeight !== undefined) ellipse.strokeWeight = strokeWeight
|
|
586
|
+
if (opacity !== undefined) ellipse.opacity = opacity
|
|
587
|
+
await appendToParent(ellipse, parentId)
|
|
588
|
+
return serializeNode(ellipse)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
case 'create-line': {
|
|
592
|
+
const { x, y, length, rotation, name, parentId, stroke, strokeWeight } = args as {
|
|
593
|
+
x: number
|
|
594
|
+
y: number
|
|
595
|
+
length: number
|
|
596
|
+
rotation?: number
|
|
597
|
+
name?: string
|
|
598
|
+
parentId?: string
|
|
599
|
+
stroke?: string
|
|
600
|
+
strokeWeight?: number
|
|
601
|
+
}
|
|
602
|
+
const line = figma.createLine()
|
|
603
|
+
line.x = x
|
|
604
|
+
line.y = y
|
|
605
|
+
line.resize(length, 0)
|
|
606
|
+
if (rotation) line.rotation = rotation
|
|
607
|
+
if (name) line.name = name
|
|
608
|
+
if (stroke) line.strokes = [await createSolidPaint(stroke)]
|
|
609
|
+
if (strokeWeight !== undefined) line.strokeWeight = strokeWeight
|
|
610
|
+
await appendToParent(line, parentId)
|
|
611
|
+
return serializeNode(line)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case 'create-polygon': {
|
|
615
|
+
const { x, y, size, sides, name, parentId } = args as {
|
|
616
|
+
x: number
|
|
617
|
+
y: number
|
|
618
|
+
size: number
|
|
619
|
+
sides?: number
|
|
620
|
+
name?: string
|
|
621
|
+
parentId?: string
|
|
622
|
+
}
|
|
623
|
+
const polygon = figma.createPolygon()
|
|
624
|
+
polygon.x = x
|
|
625
|
+
polygon.y = y
|
|
626
|
+
polygon.resize(size, size)
|
|
627
|
+
if (sides) polygon.pointCount = sides
|
|
628
|
+
if (name) polygon.name = name
|
|
629
|
+
await appendToParent(polygon, parentId)
|
|
630
|
+
return serializeNode(polygon)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
case 'create-star': {
|
|
634
|
+
const { x, y, size, points, innerRadius, name, parentId } = args as {
|
|
635
|
+
x: number
|
|
636
|
+
y: number
|
|
637
|
+
size: number
|
|
638
|
+
points?: number
|
|
639
|
+
innerRadius?: number
|
|
640
|
+
name?: string
|
|
641
|
+
parentId?: string
|
|
642
|
+
}
|
|
643
|
+
const star = figma.createStar()
|
|
644
|
+
star.x = x
|
|
645
|
+
star.y = y
|
|
646
|
+
star.resize(size, size)
|
|
647
|
+
if (points) star.pointCount = points
|
|
648
|
+
if (innerRadius !== undefined) star.innerRadius = innerRadius
|
|
649
|
+
if (name) star.name = name
|
|
650
|
+
await appendToParent(star, parentId)
|
|
651
|
+
return serializeNode(star)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
case 'create-vector': {
|
|
655
|
+
const { x, y, path, name, parentId } = args as {
|
|
656
|
+
x: number
|
|
657
|
+
y: number
|
|
658
|
+
path: string
|
|
659
|
+
name?: string
|
|
660
|
+
parentId?: string
|
|
661
|
+
}
|
|
662
|
+
const frame = figma.createNodeFromSvg(`<svg><path d="${path}"/></svg>`)
|
|
663
|
+
frame.x = x
|
|
664
|
+
frame.y = y
|
|
665
|
+
if (name) frame.name = name
|
|
666
|
+
await appendToParent(frame, parentId)
|
|
667
|
+
return serializeNode(frame)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ==================== CREATE CONTAINERS ====================
|
|
671
|
+
case 'create-frame': {
|
|
672
|
+
const {
|
|
673
|
+
x,
|
|
674
|
+
y,
|
|
675
|
+
width,
|
|
676
|
+
height,
|
|
677
|
+
name,
|
|
678
|
+
parentId,
|
|
679
|
+
fill,
|
|
680
|
+
stroke,
|
|
681
|
+
strokeWeight,
|
|
682
|
+
radius,
|
|
683
|
+
opacity,
|
|
684
|
+
layoutMode,
|
|
685
|
+
itemSpacing,
|
|
686
|
+
padding
|
|
687
|
+
} = args as {
|
|
688
|
+
x: number
|
|
689
|
+
y: number
|
|
690
|
+
width: number
|
|
691
|
+
height: number
|
|
692
|
+
name?: string
|
|
693
|
+
parentId?: string
|
|
694
|
+
fill?: string
|
|
695
|
+
stroke?: string
|
|
696
|
+
strokeWeight?: number
|
|
697
|
+
radius?: number
|
|
698
|
+
opacity?: number
|
|
699
|
+
layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE'
|
|
700
|
+
itemSpacing?: number
|
|
701
|
+
padding?: { top: number; right: number; bottom: number; left: number }
|
|
702
|
+
}
|
|
703
|
+
const frame = figma.createFrame()
|
|
704
|
+
frame.x = x
|
|
705
|
+
frame.y = y
|
|
706
|
+
frame.resize(width, height)
|
|
707
|
+
if (name) frame.name = name
|
|
708
|
+
if (fill) frame.fills = [await createSolidPaint(fill)]
|
|
709
|
+
if (stroke) frame.strokes = [await createSolidPaint(stroke)]
|
|
710
|
+
if (strokeWeight !== undefined) frame.strokeWeight = strokeWeight
|
|
711
|
+
if (radius !== undefined) frame.cornerRadius = radius
|
|
712
|
+
if (opacity !== undefined) frame.opacity = opacity
|
|
713
|
+
if (layoutMode && layoutMode !== 'NONE') {
|
|
714
|
+
frame.layoutMode = layoutMode
|
|
715
|
+
if (itemSpacing !== undefined) frame.itemSpacing = itemSpacing
|
|
716
|
+
if (padding) {
|
|
717
|
+
frame.paddingTop = padding.top
|
|
718
|
+
frame.paddingRight = padding.right
|
|
719
|
+
frame.paddingBottom = padding.bottom
|
|
720
|
+
frame.paddingLeft = padding.left
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
await appendToParent(frame, parentId)
|
|
724
|
+
return serializeNode(frame)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
case 'create-section': {
|
|
728
|
+
const { x, y, width, height, name } = args as {
|
|
729
|
+
x: number
|
|
730
|
+
y: number
|
|
731
|
+
width: number
|
|
732
|
+
height: number
|
|
733
|
+
name?: string
|
|
734
|
+
}
|
|
735
|
+
const section = figma.createSection()
|
|
736
|
+
section.x = x
|
|
737
|
+
section.y = y
|
|
738
|
+
section.resizeWithoutConstraints(width, height)
|
|
739
|
+
if (name) section.name = name
|
|
740
|
+
return serializeNode(section)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
case 'create-slice': {
|
|
744
|
+
const { x, y, width, height, name } = args as {
|
|
745
|
+
x: number
|
|
746
|
+
y: number
|
|
747
|
+
width: number
|
|
748
|
+
height: number
|
|
749
|
+
name?: string
|
|
750
|
+
}
|
|
751
|
+
const slice = figma.createSlice()
|
|
752
|
+
slice.x = x
|
|
753
|
+
slice.y = y
|
|
754
|
+
slice.resize(width, height)
|
|
755
|
+
if (name) slice.name = name
|
|
756
|
+
return serializeNode(slice)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ==================== CREATE OTHER ====================
|
|
760
|
+
case 'create-text': {
|
|
761
|
+
const { x, y, text, fontSize, fontFamily, fontStyle, fill, opacity, name, parentId } =
|
|
762
|
+
args as {
|
|
763
|
+
x: number
|
|
764
|
+
y: number
|
|
765
|
+
text: string
|
|
766
|
+
fontSize?: number
|
|
767
|
+
fontFamily?: string
|
|
768
|
+
fontStyle?: string
|
|
769
|
+
fill?: string
|
|
770
|
+
opacity?: number
|
|
771
|
+
name?: string
|
|
772
|
+
parentId?: string
|
|
773
|
+
}
|
|
774
|
+
const textNode = figma.createText()
|
|
775
|
+
const family = fontFamily || 'Inter'
|
|
776
|
+
const style = fontStyle || 'Regular'
|
|
777
|
+
await loadFont(family, style)
|
|
778
|
+
textNode.x = x
|
|
779
|
+
textNode.y = y
|
|
780
|
+
textNode.fontName = { family, style }
|
|
781
|
+
textNode.characters = text
|
|
782
|
+
if (fontSize) textNode.fontSize = fontSize
|
|
783
|
+
if (fill) textNode.fills = [await createSolidPaint(fill)]
|
|
784
|
+
if (opacity !== undefined) textNode.opacity = opacity
|
|
785
|
+
if (name) textNode.name = name
|
|
786
|
+
await appendToParent(textNode, parentId)
|
|
787
|
+
return serializeNode(textNode)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
case 'create-instance': {
|
|
791
|
+
const { componentId, x, y, name, parentId } = args as {
|
|
792
|
+
componentId: string
|
|
793
|
+
x?: number
|
|
794
|
+
y?: number
|
|
795
|
+
name?: string
|
|
796
|
+
parentId?: string
|
|
797
|
+
}
|
|
798
|
+
const component = (await figma.getNodeByIdAsync(componentId)) as ComponentNode | null
|
|
799
|
+
if (!component || component.type !== 'COMPONENT') throw new Error('Component not found')
|
|
800
|
+
const instance = component.createInstance()
|
|
801
|
+
if (x !== undefined) instance.x = x
|
|
802
|
+
if (y !== undefined) instance.y = y
|
|
803
|
+
if (name) instance.name = name
|
|
804
|
+
await appendToParent(instance, parentId)
|
|
805
|
+
return serializeNode(instance)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
case 'create-component': {
|
|
809
|
+
const { name, parentId, x, y, width, height, fill } = args as {
|
|
810
|
+
name: string
|
|
811
|
+
parentId?: string
|
|
812
|
+
x?: number
|
|
813
|
+
y?: number
|
|
814
|
+
width?: number
|
|
815
|
+
height?: number
|
|
816
|
+
fill?: string
|
|
817
|
+
}
|
|
818
|
+
const component = figma.createComponent()
|
|
819
|
+
component.name = name
|
|
820
|
+
if (x !== undefined) component.x = x
|
|
821
|
+
if (y !== undefined) component.y = y
|
|
822
|
+
if (width && height) component.resize(width, height)
|
|
823
|
+
if (fill) component.fills = [await createSolidPaint(fill)]
|
|
824
|
+
await appendToParent(component, parentId)
|
|
825
|
+
return serializeNode(component)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
case 'clone-node': {
|
|
829
|
+
const { id } = args as { id: string }
|
|
830
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
831
|
+
if (!node || !('clone' in node)) throw new Error('Node not found')
|
|
832
|
+
const clone = node.clone()
|
|
833
|
+
return serializeNode(clone)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
case 'convert-to-component': {
|
|
837
|
+
const { id } = args as { id: string }
|
|
838
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
839
|
+
if (!node) throw new Error('Node not found')
|
|
840
|
+
const component = figma.createComponentFromNode(node)
|
|
841
|
+
return serializeNode(component)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ==================== CREATE STYLES ====================
|
|
845
|
+
case 'create-paint-style': {
|
|
846
|
+
const { name, color } = args as { name: string; color: string }
|
|
847
|
+
const style = figma.createPaintStyle()
|
|
848
|
+
style.name = name
|
|
849
|
+
style.paints = [await createSolidPaint(color)]
|
|
850
|
+
return { id: style.id, name: style.name, key: style.key }
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
case 'create-text-style': {
|
|
854
|
+
const { name, fontFamily, fontStyle, fontSize } = args as {
|
|
855
|
+
name: string
|
|
856
|
+
fontFamily?: string
|
|
857
|
+
fontStyle?: string
|
|
858
|
+
fontSize?: number
|
|
859
|
+
}
|
|
860
|
+
const style = figma.createTextStyle()
|
|
861
|
+
style.name = name
|
|
862
|
+
await loadFont(fontFamily || 'Inter', fontStyle || 'Regular')
|
|
863
|
+
style.fontName = { family: fontFamily || 'Inter', style: fontStyle || 'Regular' }
|
|
864
|
+
if (fontSize) style.fontSize = fontSize
|
|
865
|
+
return { id: style.id, name: style.name, key: style.key }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
case 'create-effect-style': {
|
|
869
|
+
const { name, type, radius, color, offsetX, offsetY } = args as {
|
|
870
|
+
name: string
|
|
871
|
+
type: string
|
|
872
|
+
radius?: number
|
|
873
|
+
color?: string
|
|
874
|
+
offsetX?: number
|
|
875
|
+
offsetY?: number
|
|
876
|
+
}
|
|
877
|
+
const style = figma.createEffectStyle()
|
|
878
|
+
style.name = name
|
|
879
|
+
const rgba = color ? hexToRgba(color) : { r: 0, g: 0, b: 0, a: 0.25 }
|
|
880
|
+
if (type === 'DROP_SHADOW' || type === 'INNER_SHADOW') {
|
|
881
|
+
style.effects = [
|
|
882
|
+
{
|
|
883
|
+
type: type as 'DROP_SHADOW' | 'INNER_SHADOW',
|
|
884
|
+
color: rgba,
|
|
885
|
+
offset: { x: offsetX || 0, y: offsetY || 4 },
|
|
886
|
+
radius: radius || 10,
|
|
887
|
+
spread: 0,
|
|
888
|
+
visible: true,
|
|
889
|
+
blendMode: 'NORMAL'
|
|
890
|
+
}
|
|
891
|
+
]
|
|
892
|
+
} else if (type === 'BLUR' || type === 'BACKGROUND_BLUR') {
|
|
893
|
+
style.effects = [
|
|
894
|
+
{
|
|
895
|
+
type: type as 'LAYER_BLUR' | 'BACKGROUND_BLUR',
|
|
896
|
+
blurType: 'NORMAL',
|
|
897
|
+
radius: radius || 10,
|
|
898
|
+
visible: true
|
|
899
|
+
} as BlurEffect
|
|
900
|
+
]
|
|
901
|
+
}
|
|
902
|
+
return { id: style.id, name: style.name, key: style.key }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ==================== UPDATE POSITION/SIZE ====================
|
|
906
|
+
case 'move-node': {
|
|
907
|
+
const { id, x, y } = args as { id: string; x: number; y: number }
|
|
908
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
909
|
+
if (!node) throw new Error('Node not found')
|
|
910
|
+
node.x = x
|
|
911
|
+
node.y = y
|
|
912
|
+
return serializeNode(node)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
case 'resize-node': {
|
|
916
|
+
const { id, width, height } = args as { id: string; width: number; height: number }
|
|
917
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
918
|
+
if (!node) throw new Error('Node not found')
|
|
919
|
+
if ('resize' in node) {
|
|
920
|
+
node.resize(width, height)
|
|
921
|
+
} else if ('width' in node && 'height' in node) {
|
|
922
|
+
;(node as SectionNode).resizeWithoutConstraints(width, height)
|
|
923
|
+
} else {
|
|
924
|
+
throw new Error('Node cannot be resized')
|
|
925
|
+
}
|
|
926
|
+
return serializeNode(node)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ==================== UPDATE APPEARANCE ====================
|
|
930
|
+
case 'set-fill-color': {
|
|
931
|
+
const { id, color } = args as { id: string; color: string }
|
|
932
|
+
const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
|
|
933
|
+
if (!node || !('fills' in node)) throw new Error('Node not found')
|
|
934
|
+
node.fills = [await createSolidPaint(color)]
|
|
935
|
+
return serializeNode(node as BaseNode)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
case 'set-stroke-color': {
|
|
939
|
+
const { id, color, weight, align } = args as {
|
|
940
|
+
id: string
|
|
941
|
+
color: string
|
|
942
|
+
weight?: number
|
|
943
|
+
align?: string
|
|
944
|
+
}
|
|
945
|
+
const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
|
|
946
|
+
if (!node || !('strokes' in node)) throw new Error('Node not found')
|
|
947
|
+
node.strokes = [await createSolidPaint(color)]
|
|
948
|
+
if (weight !== undefined && 'strokeWeight' in node) (node as any).strokeWeight = weight
|
|
949
|
+
if (align && 'strokeAlign' in node)
|
|
950
|
+
(node as any).strokeAlign = align as 'INSIDE' | 'OUTSIDE' | 'CENTER'
|
|
951
|
+
return serializeNode(node as BaseNode)
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
case 'set-corner-radius': {
|
|
955
|
+
const {
|
|
956
|
+
id,
|
|
957
|
+
cornerRadius,
|
|
958
|
+
topLeftRadius,
|
|
959
|
+
topRightRadius,
|
|
960
|
+
bottomLeftRadius,
|
|
961
|
+
bottomRightRadius
|
|
962
|
+
} = args as {
|
|
963
|
+
id: string
|
|
964
|
+
cornerRadius: number
|
|
965
|
+
topLeftRadius?: number
|
|
966
|
+
topRightRadius?: number
|
|
967
|
+
bottomLeftRadius?: number
|
|
968
|
+
bottomRightRadius?: number
|
|
969
|
+
}
|
|
970
|
+
const node = (await figma.getNodeByIdAsync(id)) as RectangleNode | null
|
|
971
|
+
if (!node || !('cornerRadius' in node)) throw new Error('Node not found')
|
|
972
|
+
node.cornerRadius = cornerRadius
|
|
973
|
+
if (topLeftRadius !== undefined) node.topLeftRadius = topLeftRadius
|
|
974
|
+
if (topRightRadius !== undefined) node.topRightRadius = topRightRadius
|
|
975
|
+
if (bottomLeftRadius !== undefined) node.bottomLeftRadius = bottomLeftRadius
|
|
976
|
+
if (bottomRightRadius !== undefined) node.bottomRightRadius = bottomRightRadius
|
|
977
|
+
return serializeNode(node)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
case 'set-opacity': {
|
|
981
|
+
const { id, opacity } = args as { id: string; opacity: number }
|
|
982
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
983
|
+
if (!node || !('opacity' in node)) throw new Error('Node not found')
|
|
984
|
+
node.opacity = opacity
|
|
985
|
+
return serializeNode(node)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
case 'set-image-fill': {
|
|
989
|
+
const { id, url, scaleMode } = args as { id: string; url: string; scaleMode?: string }
|
|
990
|
+
const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
|
|
991
|
+
if (!node || !('fills' in node)) throw new Error('Node not found')
|
|
992
|
+
const image = await figma.createImageAsync(url)
|
|
993
|
+
node.fills = [
|
|
994
|
+
{
|
|
995
|
+
type: 'IMAGE',
|
|
996
|
+
imageHash: image.hash,
|
|
997
|
+
scaleMode: (scaleMode || 'FILL') as 'FILL' | 'FIT' | 'CROP' | 'TILE'
|
|
998
|
+
}
|
|
999
|
+
]
|
|
1000
|
+
return serializeNode(node as BaseNode)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ==================== UPDATE PROPERTIES ====================
|
|
1004
|
+
case 'rename-node': {
|
|
1005
|
+
const { id, name } = args as { id: string; name: string }
|
|
1006
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1007
|
+
if (!node) throw new Error('Node not found')
|
|
1008
|
+
node.name = name
|
|
1009
|
+
return serializeNode(node)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
case 'bind-fill-variable-by-name': {
|
|
1013
|
+
const { id, variableName, recursive } = args as {
|
|
1014
|
+
id: string
|
|
1015
|
+
variableName: string
|
|
1016
|
+
recursive?: boolean
|
|
1017
|
+
}
|
|
1018
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1019
|
+
if (!node) throw new Error('Node not found')
|
|
1020
|
+
|
|
1021
|
+
const variables = await figma.variables.getLocalVariablesAsync('COLOR')
|
|
1022
|
+
const variable = variables.find((v) => v.name === variableName)
|
|
1023
|
+
if (!variable) throw new Error(`Variable "${variableName}" not found`)
|
|
1024
|
+
|
|
1025
|
+
function bindFills(n: SceneNode) {
|
|
1026
|
+
if ('fills' in n && Array.isArray(n.fills) && n.fills.length > 0) {
|
|
1027
|
+
const fills = [...n.fills] as Paint[]
|
|
1028
|
+
for (let i = 0; i < fills.length; i++) {
|
|
1029
|
+
if (fills[i].type === 'SOLID') {
|
|
1030
|
+
fills[i] = figma.variables.setBoundVariableForPaint(fills[i], 'color', variable)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
;(n as GeometryMixin).fills = fills
|
|
1034
|
+
}
|
|
1035
|
+
if (recursive && 'children' in n) {
|
|
1036
|
+
for (const child of (n as ChildrenMixin).children) {
|
|
1037
|
+
bindFills(child as SceneNode)
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
bindFills(node)
|
|
1043
|
+
return serializeNode(node)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
case 'set-visible': {
|
|
1047
|
+
const { id, visible } = args as { id: string; visible: boolean }
|
|
1048
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1049
|
+
if (!node) throw new Error('Node not found')
|
|
1050
|
+
node.visible = visible
|
|
1051
|
+
return serializeNode(node)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
case 'set-locked': {
|
|
1055
|
+
const { id, locked } = args as { id: string; locked: boolean }
|
|
1056
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1057
|
+
if (!node) throw new Error('Node not found')
|
|
1058
|
+
node.locked = locked
|
|
1059
|
+
return serializeNode(node)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
case 'set-effect': {
|
|
1063
|
+
const { id, type, color, offsetX, offsetY, radius, spread } = args as {
|
|
1064
|
+
id: string
|
|
1065
|
+
type: string
|
|
1066
|
+
color?: string
|
|
1067
|
+
offsetX?: number
|
|
1068
|
+
offsetY?: number
|
|
1069
|
+
radius?: number
|
|
1070
|
+
spread?: number
|
|
1071
|
+
}
|
|
1072
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1073
|
+
if (!node || !('effects' in node)) throw new Error('Node not found')
|
|
1074
|
+
const rgba = color ? hexToRgba(color) : { r: 0, g: 0, b: 0, a: 0.25 }
|
|
1075
|
+
if (type === 'DROP_SHADOW' || type === 'INNER_SHADOW') {
|
|
1076
|
+
node.effects = [
|
|
1077
|
+
{
|
|
1078
|
+
type: type as 'DROP_SHADOW' | 'INNER_SHADOW',
|
|
1079
|
+
color: rgba,
|
|
1080
|
+
offset: { x: offsetX ?? 0, y: offsetY ?? 4 },
|
|
1081
|
+
radius: radius ?? 8,
|
|
1082
|
+
spread: spread ?? 0,
|
|
1083
|
+
visible: true,
|
|
1084
|
+
blendMode: 'NORMAL'
|
|
1085
|
+
}
|
|
1086
|
+
]
|
|
1087
|
+
} else if (type === 'BLUR') {
|
|
1088
|
+
node.effects = [
|
|
1089
|
+
{
|
|
1090
|
+
type: 'LAYER_BLUR',
|
|
1091
|
+
blurType: 'NORMAL',
|
|
1092
|
+
radius: radius ?? 8,
|
|
1093
|
+
visible: true
|
|
1094
|
+
} as BlurEffect
|
|
1095
|
+
]
|
|
1096
|
+
}
|
|
1097
|
+
return serializeNode(node)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
case 'set-text': {
|
|
1101
|
+
const { id, text } = args as { id: string; text: string }
|
|
1102
|
+
const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
|
|
1103
|
+
if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
|
|
1104
|
+
const fontName = node.fontName as FontName
|
|
1105
|
+
await loadFont(fontName.family, fontName.style)
|
|
1106
|
+
node.characters = text
|
|
1107
|
+
return serializeNode(node)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
case 'import-svg': {
|
|
1111
|
+
const { svg, x, y, name, parentId, noFill, insertIndex } = args as {
|
|
1112
|
+
svg: string
|
|
1113
|
+
x?: number
|
|
1114
|
+
y?: number
|
|
1115
|
+
name?: string
|
|
1116
|
+
parentId?: string
|
|
1117
|
+
noFill?: boolean
|
|
1118
|
+
insertIndex?: number
|
|
1119
|
+
}
|
|
1120
|
+
const node = figma.createNodeFromSvg(svg)
|
|
1121
|
+
if (x !== undefined) node.x = x
|
|
1122
|
+
if (y !== undefined) node.y = y
|
|
1123
|
+
if (name) node.name = name
|
|
1124
|
+
if (noFill) node.fills = []
|
|
1125
|
+
await appendToParent(node, parentId, insertIndex)
|
|
1126
|
+
return serializeNode(node)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
case 'set-font': {
|
|
1130
|
+
const { id, fontFamily, fontStyle, fontSize } = args as {
|
|
1131
|
+
id: string
|
|
1132
|
+
fontFamily?: string
|
|
1133
|
+
fontStyle?: string
|
|
1134
|
+
fontSize?: number
|
|
1135
|
+
}
|
|
1136
|
+
const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
|
|
1137
|
+
if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
|
|
1138
|
+
const currentFont = node.fontName as FontName
|
|
1139
|
+
const family = fontFamily || currentFont.family
|
|
1140
|
+
const style = fontStyle || currentFont.style
|
|
1141
|
+
await loadFont(family, style)
|
|
1142
|
+
node.fontName = { family, style }
|
|
1143
|
+
if (fontSize !== undefined) node.fontSize = fontSize
|
|
1144
|
+
return serializeNode(node)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
case 'set-font-range': {
|
|
1148
|
+
const { id, start, end, family, style, size, color } = args as {
|
|
1149
|
+
id: string
|
|
1150
|
+
start: number
|
|
1151
|
+
end: number
|
|
1152
|
+
family?: string
|
|
1153
|
+
style?: string
|
|
1154
|
+
size?: number
|
|
1155
|
+
color?: string
|
|
1156
|
+
}
|
|
1157
|
+
const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
|
|
1158
|
+
if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
|
|
1159
|
+
|
|
1160
|
+
if (family || style) {
|
|
1161
|
+
const currentFont = node.getRangeFontName(start, end) as FontName
|
|
1162
|
+
const newFamily = family || currentFont.family
|
|
1163
|
+
const newStyle = style || currentFont.style
|
|
1164
|
+
await loadFont(newFamily, newStyle)
|
|
1165
|
+
node.setRangeFontName(start, end, { family: newFamily, style: newStyle })
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (size !== undefined) {
|
|
1169
|
+
node.setRangeFontSize(start, end, size)
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (color) {
|
|
1173
|
+
node.setRangeFills(start, end, [await createSolidPaint(color)])
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return serializeNode(node)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
case 'get-children': {
|
|
1180
|
+
const { id, depth } = args as { id: string; depth?: number }
|
|
1181
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1182
|
+
if (!node) throw new Error('Node not found')
|
|
1183
|
+
if (!('children' in node)) return []
|
|
1184
|
+
const maxDepth = depth || 1
|
|
1185
|
+
const serializeWithDepth = (n: SceneNode, d: number): object => {
|
|
1186
|
+
const base = serializeNode(n) as Record<string, unknown>
|
|
1187
|
+
if (d < maxDepth && 'children' in n) {
|
|
1188
|
+
base.children = (n as FrameNode).children.map((c) => serializeWithDepth(c, d + 1))
|
|
1189
|
+
}
|
|
1190
|
+
return base
|
|
1191
|
+
}
|
|
1192
|
+
return (node as FrameNode).children.map((c) => serializeWithDepth(c, 1))
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
case 'find-by-name': {
|
|
1196
|
+
const {
|
|
1197
|
+
name,
|
|
1198
|
+
type,
|
|
1199
|
+
exact,
|
|
1200
|
+
limit = 100
|
|
1201
|
+
} = args as { name?: string; type?: string; exact?: boolean; limit?: number }
|
|
1202
|
+
const results: object[] = []
|
|
1203
|
+
const nameLower = name?.toLowerCase()
|
|
1204
|
+
|
|
1205
|
+
const searchNode = (node: SceneNode): boolean => {
|
|
1206
|
+
if (results.length >= limit) return false
|
|
1207
|
+
const nameMatch =
|
|
1208
|
+
!nameLower || (exact ? node.name === name : node.name.toLowerCase().includes(nameLower))
|
|
1209
|
+
const typeMatch = !type || node.type === type
|
|
1210
|
+
if (nameMatch && typeMatch) results.push(serializeNode(node))
|
|
1211
|
+
if ('children' in node) {
|
|
1212
|
+
for (const child of (node as FrameNode).children) {
|
|
1213
|
+
if (!searchNode(child)) return false
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return results.length < limit
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
for (const child of figma.currentPage.children) {
|
|
1220
|
+
if (!searchNode(child)) break
|
|
1221
|
+
}
|
|
1222
|
+
return results
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
case 'select-nodes': {
|
|
1226
|
+
const { ids } = args as { ids: string[] }
|
|
1227
|
+
const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
|
|
1228
|
+
const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'id' in n)
|
|
1229
|
+
figma.currentPage.selection = validNodes
|
|
1230
|
+
return { selected: validNodes.length }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
case 'set-constraints': {
|
|
1234
|
+
const { id, horizontal, vertical } = args as {
|
|
1235
|
+
id: string
|
|
1236
|
+
horizontal?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
|
|
1237
|
+
vertical?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'SCALE'
|
|
1238
|
+
}
|
|
1239
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1240
|
+
if (!node || !('constraints' in node)) throw new Error('Node not found')
|
|
1241
|
+
node.constraints = {
|
|
1242
|
+
horizontal: horizontal || node.constraints.horizontal,
|
|
1243
|
+
vertical: vertical || node.constraints.vertical
|
|
1244
|
+
}
|
|
1245
|
+
return serializeNode(node)
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
case 'set-blend-mode': {
|
|
1249
|
+
const { id, mode } = args as { id: string; mode: BlendMode }
|
|
1250
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1251
|
+
if (!node || !('blendMode' in node)) throw new Error('Node not found')
|
|
1252
|
+
node.blendMode = mode
|
|
1253
|
+
return serializeNode(node)
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
case 'set-auto-layout': {
|
|
1257
|
+
const {
|
|
1258
|
+
id,
|
|
1259
|
+
mode,
|
|
1260
|
+
wrap,
|
|
1261
|
+
itemSpacing,
|
|
1262
|
+
counterSpacing,
|
|
1263
|
+
padding,
|
|
1264
|
+
primaryAlign,
|
|
1265
|
+
counterAlign,
|
|
1266
|
+
sizingH,
|
|
1267
|
+
sizingV,
|
|
1268
|
+
gridColumnSizes,
|
|
1269
|
+
gridRowSizes,
|
|
1270
|
+
gridColumnGap,
|
|
1271
|
+
gridRowGap
|
|
1272
|
+
} = args as {
|
|
1273
|
+
id: string
|
|
1274
|
+
mode?: 'HORIZONTAL' | 'VERTICAL' | 'GRID' | 'NONE'
|
|
1275
|
+
wrap?: boolean
|
|
1276
|
+
itemSpacing?: number
|
|
1277
|
+
counterSpacing?: number
|
|
1278
|
+
padding?: { top: number; right: number; bottom: number; left: number }
|
|
1279
|
+
primaryAlign?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
|
|
1280
|
+
counterAlign?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE'
|
|
1281
|
+
sizingH?: 'FIXED' | 'HUG' | 'FILL'
|
|
1282
|
+
sizingV?: 'FIXED' | 'HUG' | 'FILL'
|
|
1283
|
+
gridColumnSizes?: Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value?: number }>
|
|
1284
|
+
gridRowSizes?: Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value?: number }>
|
|
1285
|
+
gridColumnGap?: number
|
|
1286
|
+
gridRowGap?: number
|
|
1287
|
+
}
|
|
1288
|
+
const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
|
|
1289
|
+
if (!node || !('layoutMode' in node)) throw new Error('Frame not found')
|
|
1290
|
+
if (mode) node.layoutMode = mode
|
|
1291
|
+
if (wrap !== undefined) node.layoutWrap = wrap ? 'WRAP' : 'NO_WRAP'
|
|
1292
|
+
if (itemSpacing !== undefined) node.itemSpacing = itemSpacing
|
|
1293
|
+
if (counterSpacing !== undefined) node.counterAxisSpacing = counterSpacing
|
|
1294
|
+
if (padding) {
|
|
1295
|
+
node.paddingTop = padding.top
|
|
1296
|
+
node.paddingRight = padding.right
|
|
1297
|
+
node.paddingBottom = padding.bottom
|
|
1298
|
+
node.paddingLeft = padding.left
|
|
1299
|
+
}
|
|
1300
|
+
if (primaryAlign) node.primaryAxisAlignItems = primaryAlign
|
|
1301
|
+
if (counterAlign) node.counterAxisAlignItems = counterAlign
|
|
1302
|
+
if (sizingH) node.layoutSizingHorizontal = sizingH
|
|
1303
|
+
if (sizingV) node.layoutSizingVertical = sizingV
|
|
1304
|
+
if (gridColumnSizes) {
|
|
1305
|
+
node.gridColumnCount = gridColumnSizes.length
|
|
1306
|
+
node.gridColumnSizes = gridColumnSizes
|
|
1307
|
+
}
|
|
1308
|
+
if (gridRowSizes) {
|
|
1309
|
+
node.gridRowCount = gridRowSizes.length
|
|
1310
|
+
node.gridRowSizes = gridRowSizes
|
|
1311
|
+
}
|
|
1312
|
+
if (gridColumnGap !== undefined) node.gridColumnGap = gridColumnGap
|
|
1313
|
+
if (gridRowGap !== undefined) node.gridRowGap = gridRowGap
|
|
1314
|
+
return serializeNode(node)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
case 'set-layout-child': {
|
|
1318
|
+
const { id, horizontalSizing, verticalSizing, positioning, x, y } = args as {
|
|
1319
|
+
id: string
|
|
1320
|
+
horizontalSizing?: 'FIXED' | 'FILL' | 'HUG'
|
|
1321
|
+
verticalSizing?: 'FIXED' | 'FILL' | 'HUG'
|
|
1322
|
+
positioning?: 'AUTO' | 'ABSOLUTE'
|
|
1323
|
+
x?: number
|
|
1324
|
+
y?: number
|
|
1325
|
+
}
|
|
1326
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1327
|
+
if (!node) throw new Error('Node not found')
|
|
1328
|
+
if (horizontalSizing && 'layoutSizingHorizontal' in node) {
|
|
1329
|
+
;(node as FrameNode).layoutSizingHorizontal = horizontalSizing
|
|
1330
|
+
}
|
|
1331
|
+
if (verticalSizing && 'layoutSizingVertical' in node) {
|
|
1332
|
+
;(node as FrameNode).layoutSizingVertical = verticalSizing
|
|
1333
|
+
}
|
|
1334
|
+
if (positioning && 'layoutPositioning' in node) {
|
|
1335
|
+
;(node as FrameNode).layoutPositioning = positioning
|
|
1336
|
+
}
|
|
1337
|
+
if (positioning === 'ABSOLUTE') {
|
|
1338
|
+
if (x !== undefined) node.x = x
|
|
1339
|
+
if (y !== undefined) node.y = y
|
|
1340
|
+
}
|
|
1341
|
+
return serializeNode(node)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
case 'set-text-properties': {
|
|
1345
|
+
const {
|
|
1346
|
+
id,
|
|
1347
|
+
lineHeight,
|
|
1348
|
+
letterSpacing,
|
|
1349
|
+
textAlign,
|
|
1350
|
+
verticalAlign,
|
|
1351
|
+
autoResize,
|
|
1352
|
+
maxLines,
|
|
1353
|
+
paragraphSpacing,
|
|
1354
|
+
paragraphIndent
|
|
1355
|
+
} = args as {
|
|
1356
|
+
id: string
|
|
1357
|
+
lineHeight?: number | 'auto'
|
|
1358
|
+
letterSpacing?: number
|
|
1359
|
+
textAlign?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED'
|
|
1360
|
+
verticalAlign?: 'TOP' | 'CENTER' | 'BOTTOM'
|
|
1361
|
+
autoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE'
|
|
1362
|
+
maxLines?: number
|
|
1363
|
+
paragraphSpacing?: number
|
|
1364
|
+
paragraphIndent?: number
|
|
1365
|
+
}
|
|
1366
|
+
const node = (await figma.getNodeByIdAsync(id)) as TextNode | null
|
|
1367
|
+
if (!node || node.type !== 'TEXT') throw new Error('Text node not found')
|
|
1368
|
+
|
|
1369
|
+
const fontName = node.fontName as FontName
|
|
1370
|
+
await loadFont(fontName.family, fontName.style)
|
|
1371
|
+
|
|
1372
|
+
if (lineHeight !== undefined) {
|
|
1373
|
+
node.lineHeight =
|
|
1374
|
+
lineHeight === 'auto' ? { unit: 'AUTO' } : { unit: 'PIXELS', value: lineHeight }
|
|
1375
|
+
}
|
|
1376
|
+
if (letterSpacing !== undefined) {
|
|
1377
|
+
node.letterSpacing = { unit: 'PIXELS', value: letterSpacing }
|
|
1378
|
+
}
|
|
1379
|
+
if (textAlign) node.textAlignHorizontal = textAlign
|
|
1380
|
+
if (verticalAlign) node.textAlignVertical = verticalAlign
|
|
1381
|
+
if (autoResize) node.textAutoResize = autoResize
|
|
1382
|
+
if (maxLines !== undefined) node.maxLines = maxLines
|
|
1383
|
+
if (paragraphSpacing !== undefined) node.paragraphSpacing = paragraphSpacing
|
|
1384
|
+
if (paragraphIndent !== undefined) node.paragraphIndent = paragraphIndent
|
|
1385
|
+
return serializeNode(node)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
case 'set-min-max': {
|
|
1389
|
+
const { id, minWidth, maxWidth, minHeight, maxHeight } = args as {
|
|
1390
|
+
id: string
|
|
1391
|
+
minWidth?: number
|
|
1392
|
+
maxWidth?: number
|
|
1393
|
+
minHeight?: number
|
|
1394
|
+
maxHeight?: number
|
|
1395
|
+
}
|
|
1396
|
+
const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
|
|
1397
|
+
if (!node) throw new Error('Node not found')
|
|
1398
|
+
if (minWidth !== undefined && 'minWidth' in node) node.minWidth = minWidth
|
|
1399
|
+
if (maxWidth !== undefined && 'maxWidth' in node) node.maxWidth = maxWidth
|
|
1400
|
+
if (minHeight !== undefined && 'minHeight' in node) node.minHeight = minHeight
|
|
1401
|
+
if (maxHeight !== undefined && 'maxHeight' in node) node.maxHeight = maxHeight
|
|
1402
|
+
return serializeNode(node)
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
case 'set-rotation': {
|
|
1406
|
+
const { id, angle } = args as { id: string; angle: number }
|
|
1407
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1408
|
+
if (!node) throw new Error('Node not found')
|
|
1409
|
+
if ('rotation' in node) node.rotation = angle
|
|
1410
|
+
return serializeNode(node)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
case 'set-stroke-align': {
|
|
1414
|
+
const { id, align } = args as { id: string; align: 'INSIDE' | 'OUTSIDE' | 'CENTER' }
|
|
1415
|
+
const node = (await figma.getNodeByIdAsync(id)) as GeometryMixin | null
|
|
1416
|
+
if (!node || !('strokeAlign' in node)) throw new Error('Node not found')
|
|
1417
|
+
node.strokeAlign = align
|
|
1418
|
+
return serializeNode(node as BaseNode)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ==================== UPDATE STRUCTURE ====================
|
|
1422
|
+
case 'set-layout': {
|
|
1423
|
+
const {
|
|
1424
|
+
id,
|
|
1425
|
+
mode,
|
|
1426
|
+
wrap,
|
|
1427
|
+
clip,
|
|
1428
|
+
itemSpacing,
|
|
1429
|
+
primaryAxisAlignItems,
|
|
1430
|
+
counterAxisAlignItems,
|
|
1431
|
+
paddingLeft,
|
|
1432
|
+
paddingRight,
|
|
1433
|
+
paddingTop,
|
|
1434
|
+
paddingBottom,
|
|
1435
|
+
layoutSizingVertical,
|
|
1436
|
+
layoutSizingHorizontal
|
|
1437
|
+
} = args as {
|
|
1438
|
+
id: string
|
|
1439
|
+
mode: 'NONE' | 'HORIZONTAL' | 'VERTICAL'
|
|
1440
|
+
wrap?: boolean
|
|
1441
|
+
clip?: boolean
|
|
1442
|
+
itemSpacing?: number
|
|
1443
|
+
primaryAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN'
|
|
1444
|
+
counterAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'BASELINE'
|
|
1445
|
+
paddingLeft?: number
|
|
1446
|
+
paddingRight?: number
|
|
1447
|
+
paddingTop?: number
|
|
1448
|
+
paddingBottom?: number
|
|
1449
|
+
layoutSizingVertical?: 'FIXED' | 'HUG' | 'FILL'
|
|
1450
|
+
layoutSizingHorizontal?: 'FIXED' | 'HUG' | 'FILL'
|
|
1451
|
+
}
|
|
1452
|
+
const node = (await figma.getNodeByIdAsync(id)) as FrameNode | null
|
|
1453
|
+
if (!node || !('layoutMode' in node)) throw new Error('Node not found')
|
|
1454
|
+
node.layoutMode = mode
|
|
1455
|
+
if (wrap !== undefined) node.layoutWrap = wrap ? 'WRAP' : 'NO_WRAP'
|
|
1456
|
+
if (clip !== undefined) node.clipsContent = clip
|
|
1457
|
+
if (itemSpacing !== undefined) node.itemSpacing = itemSpacing
|
|
1458
|
+
if (primaryAxisAlignItems) node.primaryAxisAlignItems = primaryAxisAlignItems
|
|
1459
|
+
if (counterAxisAlignItems) node.counterAxisAlignItems = counterAxisAlignItems
|
|
1460
|
+
if (paddingLeft !== undefined) node.paddingLeft = paddingLeft
|
|
1461
|
+
if (paddingRight !== undefined) node.paddingRight = paddingRight
|
|
1462
|
+
if (paddingTop !== undefined) node.paddingTop = paddingTop
|
|
1463
|
+
if (paddingBottom !== undefined) node.paddingBottom = paddingBottom
|
|
1464
|
+
if (layoutSizingVertical) node.layoutSizingVertical = layoutSizingVertical
|
|
1465
|
+
if (layoutSizingHorizontal) node.layoutSizingHorizontal = layoutSizingHorizontal
|
|
1466
|
+
return serializeNode(node)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
case 'set-parent-id': {
|
|
1470
|
+
const { id, parentId } = args as { id: string; parentId: string }
|
|
1471
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1472
|
+
const parent = (await figma.getNodeByIdAsync(parentId)) as (FrameNode & ChildrenMixin) | null
|
|
1473
|
+
if (!node || !parent) throw new Error('Node or parent not found')
|
|
1474
|
+
parent.appendChild(node)
|
|
1475
|
+
return serializeNode(node)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
case 'group-nodes': {
|
|
1479
|
+
const { ids, name } = args as { ids: string[]; name?: string }
|
|
1480
|
+
const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
|
|
1481
|
+
const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
|
|
1482
|
+
if (validNodes.length === 0) throw new Error('No valid nodes found')
|
|
1483
|
+
const parent = validNodes[0].parent
|
|
1484
|
+
if (!parent || !('children' in parent)) throw new Error('Invalid parent')
|
|
1485
|
+
const group = figma.group(validNodes, parent)
|
|
1486
|
+
if (name) group.name = name
|
|
1487
|
+
return serializeNode(group)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
case 'ungroup-node': {
|
|
1491
|
+
const { id } = args as { id: string }
|
|
1492
|
+
const node = (await figma.getNodeByIdAsync(id)) as GroupNode | null
|
|
1493
|
+
if (!node || node.type !== 'GROUP') throw new Error('Not a group node')
|
|
1494
|
+
const children = figma.ungroup(node)
|
|
1495
|
+
return children.map(serializeNode)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
case 'flatten-nodes': {
|
|
1499
|
+
const { ids } = args as { ids: string[] }
|
|
1500
|
+
const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
|
|
1501
|
+
const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
|
|
1502
|
+
if (validNodes.length === 0) throw new Error('No valid nodes found')
|
|
1503
|
+
const vector = figma.flatten(validNodes)
|
|
1504
|
+
return serializeNode(vector)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// ==================== BOOLEAN OPERATIONS ====================
|
|
1508
|
+
case 'boolean-operation': {
|
|
1509
|
+
const { ids, operation } = args as {
|
|
1510
|
+
ids: string[]
|
|
1511
|
+
operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
|
1512
|
+
}
|
|
1513
|
+
const nodes = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
|
|
1514
|
+
const validNodes = nodes.filter((n): n is SceneNode => n !== null && 'parent' in n)
|
|
1515
|
+
if (validNodes.length < 2) throw new Error('Need at least 2 nodes')
|
|
1516
|
+
const parent = validNodes[0].parent
|
|
1517
|
+
if (!parent || !('children' in parent)) throw new Error('Invalid parent')
|
|
1518
|
+
let result: BooleanOperationNode
|
|
1519
|
+
switch (operation) {
|
|
1520
|
+
case 'UNION':
|
|
1521
|
+
result = figma.union(validNodes, parent)
|
|
1522
|
+
break
|
|
1523
|
+
case 'SUBTRACT':
|
|
1524
|
+
result = figma.subtract(validNodes, parent)
|
|
1525
|
+
break
|
|
1526
|
+
case 'INTERSECT':
|
|
1527
|
+
result = figma.intersect(validNodes, parent)
|
|
1528
|
+
break
|
|
1529
|
+
case 'EXCLUDE':
|
|
1530
|
+
result = figma.exclude(validNodes, parent)
|
|
1531
|
+
break
|
|
1532
|
+
}
|
|
1533
|
+
return serializeNode(result)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ==================== INSTANCE/COMPONENT ====================
|
|
1537
|
+
case 'set-instance-properties': {
|
|
1538
|
+
const { instanceId, properties } = args as {
|
|
1539
|
+
instanceId: string
|
|
1540
|
+
properties: Record<string, unknown>
|
|
1541
|
+
}
|
|
1542
|
+
const instance = (await figma.getNodeByIdAsync(instanceId)) as InstanceNode | null
|
|
1543
|
+
if (!instance || instance.type !== 'INSTANCE') throw new Error('Instance not found')
|
|
1544
|
+
instance.setProperties(properties as { [key: string]: string | boolean })
|
|
1545
|
+
return serializeNode(instance)
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
case 'set-node-component-property-references': {
|
|
1549
|
+
const { id, componentPropertyReferences } = args as {
|
|
1550
|
+
id: string
|
|
1551
|
+
componentPropertyReferences: Record<string, string>
|
|
1552
|
+
}
|
|
1553
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1554
|
+
if (!node || !('componentPropertyReferences' in node)) throw new Error('Node not found')
|
|
1555
|
+
for (const [key, value] of Object.entries(componentPropertyReferences)) {
|
|
1556
|
+
node.componentPropertyReferences = { ...node.componentPropertyReferences, [key]: value }
|
|
1557
|
+
}
|
|
1558
|
+
return serializeNode(node)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
case 'add-component-property': {
|
|
1562
|
+
const { componentId, name, type, defaultValue } = args as {
|
|
1563
|
+
componentId: string
|
|
1564
|
+
name: string
|
|
1565
|
+
type: 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP' | 'VARIANT'
|
|
1566
|
+
defaultValue: string | boolean
|
|
1567
|
+
}
|
|
1568
|
+
const component = (await figma.getNodeByIdAsync(componentId)) as
|
|
1569
|
+
| ComponentNode
|
|
1570
|
+
| ComponentSetNode
|
|
1571
|
+
| null
|
|
1572
|
+
if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
|
|
1573
|
+
throw new Error('Component not found')
|
|
1574
|
+
}
|
|
1575
|
+
let parsedDefault: string | boolean = defaultValue
|
|
1576
|
+
if (type === 'BOOLEAN') parsedDefault = defaultValue === 'true' || defaultValue === true
|
|
1577
|
+
component.addComponentProperty(name, type, parsedDefault)
|
|
1578
|
+
return serializeNode(component)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
case 'edit-component-property': {
|
|
1582
|
+
const { componentId, name, defaultValue, preferredValues } = args as {
|
|
1583
|
+
componentId: string
|
|
1584
|
+
name: string
|
|
1585
|
+
defaultValue: string | boolean
|
|
1586
|
+
preferredValues?: string[]
|
|
1587
|
+
}
|
|
1588
|
+
const component = (await figma.getNodeByIdAsync(componentId)) as
|
|
1589
|
+
| ComponentNode
|
|
1590
|
+
| ComponentSetNode
|
|
1591
|
+
| null
|
|
1592
|
+
if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
|
|
1593
|
+
throw new Error('Component not found')
|
|
1594
|
+
}
|
|
1595
|
+
const props = component.componentPropertyDefinitions
|
|
1596
|
+
const propKey = Object.keys(props).find((k) => k === name || k.startsWith(name + '#'))
|
|
1597
|
+
if (!propKey) throw new Error('Property not found')
|
|
1598
|
+
const propDef = props[propKey]
|
|
1599
|
+
let parsedDefault: string | boolean = defaultValue
|
|
1600
|
+
if (propDef.type === 'BOOLEAN')
|
|
1601
|
+
parsedDefault = defaultValue === 'true' || defaultValue === true
|
|
1602
|
+
component.editComponentProperty(propKey, {
|
|
1603
|
+
name,
|
|
1604
|
+
defaultValue: parsedDefault,
|
|
1605
|
+
preferredValues: preferredValues?.map((v) => ({ type: 'COMPONENT', key: v }))
|
|
1606
|
+
})
|
|
1607
|
+
return serializeNode(component)
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
case 'delete-component-property': {
|
|
1611
|
+
const { componentId, name } = args as { componentId: string; name: string }
|
|
1612
|
+
const component = (await figma.getNodeByIdAsync(componentId)) as
|
|
1613
|
+
| ComponentNode
|
|
1614
|
+
| ComponentSetNode
|
|
1615
|
+
| null
|
|
1616
|
+
if (!component || (component.type !== 'COMPONENT' && component.type !== 'COMPONENT_SET')) {
|
|
1617
|
+
throw new Error('Component not found')
|
|
1618
|
+
}
|
|
1619
|
+
const props = component.componentPropertyDefinitions
|
|
1620
|
+
const propKey = Object.keys(props).find((k) => k === name || k.startsWith(name + '#'))
|
|
1621
|
+
if (!propKey) throw new Error('Property not found')
|
|
1622
|
+
component.deleteComponentProperty(propKey)
|
|
1623
|
+
return serializeNode(component)
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ==================== VIEWPORT ====================
|
|
1627
|
+
case 'set-viewport': {
|
|
1628
|
+
const { x, y, zoom } = args as { x?: number; y?: number; zoom?: number }
|
|
1629
|
+
if (x !== undefined && y !== undefined) {
|
|
1630
|
+
figma.viewport.center = { x, y }
|
|
1631
|
+
}
|
|
1632
|
+
if (zoom !== undefined) {
|
|
1633
|
+
figma.viewport.zoom = zoom
|
|
1634
|
+
}
|
|
1635
|
+
return { center: figma.viewport.center, zoom: figma.viewport.zoom }
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
case 'zoom-to-fit': {
|
|
1639
|
+
const { ids } = args as { ids?: string[] }
|
|
1640
|
+
let nodes: SceneNode[]
|
|
1641
|
+
if (ids && ids.length > 0) {
|
|
1642
|
+
const fetched = await Promise.all(ids.map((id) => figma.getNodeByIdAsync(id)))
|
|
1643
|
+
nodes = fetched.filter((n): n is SceneNode => n !== null && 'absoluteBoundingBox' in n)
|
|
1644
|
+
} else {
|
|
1645
|
+
nodes = figma.currentPage.selection as SceneNode[]
|
|
1646
|
+
}
|
|
1647
|
+
if (nodes.length > 0) {
|
|
1648
|
+
figma.viewport.scrollAndZoomIntoView(nodes)
|
|
1649
|
+
}
|
|
1650
|
+
return { center: figma.viewport.center, zoom: figma.viewport.zoom }
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// ==================== EXPORT ====================
|
|
1654
|
+
case 'export-node': {
|
|
1655
|
+
const { id, format, scale } = args as {
|
|
1656
|
+
id: string
|
|
1657
|
+
format: 'PNG' | 'JPG' | 'SVG' | 'PDF'
|
|
1658
|
+
scale?: number
|
|
1659
|
+
}
|
|
1660
|
+
const node = (await figma.getNodeByIdAsync(id)) as SceneNode | null
|
|
1661
|
+
if (!node) throw new Error('Node not found')
|
|
1662
|
+
const bytes = await node.exportAsync({
|
|
1663
|
+
format: format,
|
|
1664
|
+
...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 1 } : {})
|
|
1665
|
+
} as ExportSettings)
|
|
1666
|
+
return {
|
|
1667
|
+
data: figma.base64Encode(bytes),
|
|
1668
|
+
filename: `${node.name}.${format.toLowerCase()}`
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
case 'screenshot': {
|
|
1673
|
+
const { scale } = args as { scale?: number }
|
|
1674
|
+
const bounds = figma.viewport.bounds
|
|
1675
|
+
const frame = figma.createFrame()
|
|
1676
|
+
frame.name = '__screenshot_temp__'
|
|
1677
|
+
frame.x = bounds.x
|
|
1678
|
+
frame.y = bounds.y
|
|
1679
|
+
frame.resize(bounds.width, bounds.height)
|
|
1680
|
+
frame.fills = []
|
|
1681
|
+
frame.clipsContent = true
|
|
1682
|
+
|
|
1683
|
+
// Clone visible nodes that intersect viewport
|
|
1684
|
+
for (const node of figma.currentPage.children) {
|
|
1685
|
+
if (node.id === frame.id) continue
|
|
1686
|
+
if (!node.visible) continue
|
|
1687
|
+
if ('absoluteBoundingBox' in node && node.absoluteBoundingBox) {
|
|
1688
|
+
const nb = node.absoluteBoundingBox
|
|
1689
|
+
if (
|
|
1690
|
+
nb.x + nb.width > bounds.x &&
|
|
1691
|
+
nb.x < bounds.x + bounds.width &&
|
|
1692
|
+
nb.y + nb.height > bounds.y &&
|
|
1693
|
+
nb.y < bounds.y + bounds.height
|
|
1694
|
+
) {
|
|
1695
|
+
const clone = node.clone()
|
|
1696
|
+
clone.x = node.x - bounds.x
|
|
1697
|
+
clone.y = node.y - bounds.y
|
|
1698
|
+
frame.appendChild(clone)
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const bytes = await frame.exportAsync({
|
|
1704
|
+
format: 'PNG',
|
|
1705
|
+
constraint: { type: 'SCALE', value: scale || 1 }
|
|
1706
|
+
})
|
|
1707
|
+
frame.remove()
|
|
1708
|
+
return { data: figma.base64Encode(bytes) }
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
case 'export-selection': {
|
|
1712
|
+
const { format, scale, padding } = args as {
|
|
1713
|
+
format?: 'PNG' | 'JPG' | 'SVG' | 'PDF'
|
|
1714
|
+
scale?: number
|
|
1715
|
+
padding?: number
|
|
1716
|
+
}
|
|
1717
|
+
const selection = figma.currentPage.selection
|
|
1718
|
+
if (selection.length === 0) throw new Error('No selection')
|
|
1719
|
+
|
|
1720
|
+
// If single node, export directly
|
|
1721
|
+
if (selection.length === 1 && !padding) {
|
|
1722
|
+
const bytes = await selection[0].exportAsync({
|
|
1723
|
+
format: format || 'PNG',
|
|
1724
|
+
...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 2 } : {})
|
|
1725
|
+
} as ExportSettings)
|
|
1726
|
+
return { data: figma.base64Encode(bytes) }
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Multiple nodes or padding: create temp frame
|
|
1730
|
+
let minX = Infinity,
|
|
1731
|
+
minY = Infinity,
|
|
1732
|
+
maxX = -Infinity,
|
|
1733
|
+
maxY = -Infinity
|
|
1734
|
+
for (const node of selection) {
|
|
1735
|
+
if ('absoluteBoundingBox' in node && node.absoluteBoundingBox) {
|
|
1736
|
+
const b = node.absoluteBoundingBox
|
|
1737
|
+
minX = Math.min(minX, b.x)
|
|
1738
|
+
minY = Math.min(minY, b.y)
|
|
1739
|
+
maxX = Math.max(maxX, b.x + b.width)
|
|
1740
|
+
maxY = Math.max(maxY, b.y + b.height)
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const pad = padding || 0
|
|
1745
|
+
const frame = figma.createFrame()
|
|
1746
|
+
frame.name = '__export_temp__'
|
|
1747
|
+
frame.x = minX - pad
|
|
1748
|
+
frame.y = minY - pad
|
|
1749
|
+
frame.resize(maxX - minX + pad * 2, maxY - minY + pad * 2)
|
|
1750
|
+
frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }]
|
|
1751
|
+
frame.clipsContent = true
|
|
1752
|
+
|
|
1753
|
+
for (const node of selection) {
|
|
1754
|
+
const clone = node.clone()
|
|
1755
|
+
clone.x = node.x - frame.x
|
|
1756
|
+
clone.y = node.y - frame.y
|
|
1757
|
+
frame.appendChild(clone)
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
const bytes = await frame.exportAsync({
|
|
1761
|
+
format: format || 'PNG',
|
|
1762
|
+
...(format !== 'SVG' && format !== 'PDF' ? { scale: scale || 2 } : {})
|
|
1763
|
+
} as ExportSettings)
|
|
1764
|
+
frame.remove()
|
|
1765
|
+
return { data: figma.base64Encode(bytes) }
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// ==================== DELETE ====================
|
|
1769
|
+
case 'delete-node': {
|
|
1770
|
+
const { id } = args as { id: string }
|
|
1771
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1772
|
+
if (!node || !('remove' in node)) throw new Error('Node not found')
|
|
1773
|
+
node.remove()
|
|
1774
|
+
return { deleted: true }
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// ==================== BOUNDS ====================
|
|
1778
|
+
case 'node-bounds': {
|
|
1779
|
+
const { id } = args as { id: string }
|
|
1780
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1781
|
+
if (!node || !('x' in node)) throw new Error('Node not found')
|
|
1782
|
+
const sn = node as SceneNode
|
|
1783
|
+
return {
|
|
1784
|
+
x: Math.round(sn.x * 100) / 100,
|
|
1785
|
+
y: Math.round(sn.y * 100) / 100,
|
|
1786
|
+
width: Math.round(sn.width * 100) / 100,
|
|
1787
|
+
height: Math.round(sn.height * 100) / 100,
|
|
1788
|
+
centerX: Math.round((sn.x + sn.width / 2) * 100) / 100,
|
|
1789
|
+
centerY: Math.round((sn.y + sn.height / 2) * 100) / 100,
|
|
1790
|
+
right: Math.round((sn.x + sn.width) * 100) / 100,
|
|
1791
|
+
bottom: Math.round((sn.y + sn.height) * 100) / 100
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// ==================== PATH ====================
|
|
1796
|
+
case 'path-get': {
|
|
1797
|
+
const { id } = args as { id: string }
|
|
1798
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1799
|
+
if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
|
|
1800
|
+
return { paths: node.vectorPaths }
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
case 'path-set': {
|
|
1804
|
+
const {
|
|
1805
|
+
id,
|
|
1806
|
+
path,
|
|
1807
|
+
windingRule = 'NONZERO'
|
|
1808
|
+
} = args as { id: string; path: string; windingRule?: string }
|
|
1809
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1810
|
+
if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
|
|
1811
|
+
node.vectorPaths = [{ windingRule: windingRule as WindingRule, data: path }]
|
|
1812
|
+
return { updated: true }
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
case 'path-move': {
|
|
1816
|
+
const { id, dx = 0, dy = 0 } = args as { id: string; dx?: number; dy?: number }
|
|
1817
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1818
|
+
if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
|
|
1819
|
+
|
|
1820
|
+
const newPaths = node.vectorPaths.map((p) => ({
|
|
1821
|
+
windingRule: p.windingRule,
|
|
1822
|
+
data: svgPathToString(svgpath(p.data).translate(dx, dy).round(2))
|
|
1823
|
+
}))
|
|
1824
|
+
node.vectorPaths = newPaths
|
|
1825
|
+
return { updated: true, paths: newPaths }
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
case 'path-scale': {
|
|
1829
|
+
const { id, factor } = args as { id: string; factor: number }
|
|
1830
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1831
|
+
if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
|
|
1832
|
+
|
|
1833
|
+
const newPaths = node.vectorPaths.map((p) => ({
|
|
1834
|
+
windingRule: p.windingRule,
|
|
1835
|
+
data: svgPathToString(svgpath(p.data).scale(factor).round(2))
|
|
1836
|
+
}))
|
|
1837
|
+
node.vectorPaths = newPaths
|
|
1838
|
+
return { updated: true, paths: newPaths }
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
case 'path-flip': {
|
|
1842
|
+
const { id, axis } = args as { id: string; axis: 'x' | 'y' }
|
|
1843
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
1844
|
+
if (!node || node.type !== 'VECTOR') throw new Error('Vector node not found')
|
|
1845
|
+
|
|
1846
|
+
// Flip using scale with negative value
|
|
1847
|
+
const newPaths = node.vectorPaths.map((p) => ({
|
|
1848
|
+
windingRule: p.windingRule,
|
|
1849
|
+
data:
|
|
1850
|
+
axis === 'x'
|
|
1851
|
+
? svgPathToString(svgpath(p.data).scale(-1, 1).round(2))
|
|
1852
|
+
: svgPathToString(svgpath(p.data).scale(1, -1).round(2))
|
|
1853
|
+
}))
|
|
1854
|
+
node.vectorPaths = newPaths
|
|
1855
|
+
return { updated: true, paths: newPaths }
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// ==================== QUERY ====================
|
|
1859
|
+
case 'query': {
|
|
1860
|
+
const { selector, rootId, select, limit } = args as {
|
|
1861
|
+
selector: string
|
|
1862
|
+
rootId?: string
|
|
1863
|
+
select?: string[]
|
|
1864
|
+
limit?: number
|
|
1865
|
+
}
|
|
1866
|
+
const root = rootId ? await figma.getNodeByIdAsync(rootId) : figma.currentPage
|
|
1867
|
+
if (!root) return { error: 'Root node not found' }
|
|
1868
|
+
|
|
1869
|
+
const nodes = queryNodes(selector, root, { limit: limit ?? 1000 })
|
|
1870
|
+
const fields = select || ['id', 'name', 'type']
|
|
1871
|
+
|
|
1872
|
+
return nodes.map((node) => {
|
|
1873
|
+
const result: Record<string, unknown> = {}
|
|
1874
|
+
for (const field of fields) {
|
|
1875
|
+
if (field in node) {
|
|
1876
|
+
result[field] = (node as unknown as Record<string, unknown>)[field]
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return result
|
|
1880
|
+
})
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// ==================== EVAL ====================
|
|
1884
|
+
case 'eval': {
|
|
1885
|
+
const { code } = args as { code: string }
|
|
1886
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
1887
|
+
// Wrap code to support top-level await
|
|
1888
|
+
const wrappedCode = code.trim().startsWith('return')
|
|
1889
|
+
? code
|
|
1890
|
+
: `return (async () => { ${code} })()`
|
|
1891
|
+
const fn = new AsyncFunction('figma', wrappedCode)
|
|
1892
|
+
return await fn(figma)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// ==================== LAYOUT ====================
|
|
1896
|
+
case 'trigger-layout': {
|
|
1897
|
+
// Fix TEXT nodes and trigger auto-layout recalculation
|
|
1898
|
+
// Multiplayer protocol doesn't auto-size text or trigger layout engine
|
|
1899
|
+
interface PendingInstance {
|
|
1900
|
+
componentSetName: string
|
|
1901
|
+
variantName: string
|
|
1902
|
+
parentGUID: { sessionID: number; localID: number }
|
|
1903
|
+
position: string
|
|
1904
|
+
x: number
|
|
1905
|
+
y: number
|
|
1906
|
+
}
|
|
1907
|
+
interface PendingGridLayout {
|
|
1908
|
+
nodeGUID: { sessionID: number; localID: number }
|
|
1909
|
+
gridTemplateColumns?: string
|
|
1910
|
+
gridTemplateRows?: string
|
|
1911
|
+
}
|
|
1912
|
+
const { nodeId, pendingComponentSetInstances, pendingGridLayouts } = args as {
|
|
1913
|
+
nodeId: string
|
|
1914
|
+
pendingComponentSetInstances?: PendingInstance[]
|
|
1915
|
+
pendingGridLayouts?: PendingGridLayout[]
|
|
1916
|
+
}
|
|
1917
|
+
// Multiplayer nodes may not be immediately visible
|
|
1918
|
+
const root = await retry(() => figma.getNodeByIdAsync(nodeId), 10, 100, 'linear')
|
|
1919
|
+
if (!root) return null
|
|
1920
|
+
|
|
1921
|
+
// Create ComponentSet instances via Plugin API (multiplayer can't link them correctly)
|
|
1922
|
+
if (pendingComponentSetInstances && pendingComponentSetInstances.length > 0) {
|
|
1923
|
+
for (const pending of pendingComponentSetInstances) {
|
|
1924
|
+
// Find the ComponentSet by name
|
|
1925
|
+
const findComponentSet = (node: SceneNode): ComponentSetNode | null => {
|
|
1926
|
+
if (node.type === 'COMPONENT_SET' && node.name === pending.componentSetName) {
|
|
1927
|
+
return node
|
|
1928
|
+
}
|
|
1929
|
+
if ('children' in node) {
|
|
1930
|
+
for (const child of node.children) {
|
|
1931
|
+
const found = findComponentSet(child)
|
|
1932
|
+
if (found) return found
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return null
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const componentSet = findComponentSet(root as SceneNode)
|
|
1939
|
+
if (!componentSet) continue
|
|
1940
|
+
|
|
1941
|
+
// Find the variant component by name
|
|
1942
|
+
const variantComp = componentSet.children.find(
|
|
1943
|
+
(c) => c.type === 'COMPONENT' && c.name === pending.variantName
|
|
1944
|
+
) as ComponentNode | undefined
|
|
1945
|
+
if (!variantComp) continue
|
|
1946
|
+
|
|
1947
|
+
// Create instance
|
|
1948
|
+
const instance = variantComp.createInstance()
|
|
1949
|
+
instance.x = pending.x
|
|
1950
|
+
instance.y = pending.y
|
|
1951
|
+
|
|
1952
|
+
// Find parent node
|
|
1953
|
+
const parentId = `${pending.parentGUID.sessionID}:${pending.parentGUID.localID}`
|
|
1954
|
+
const parent = await figma.getNodeByIdAsync(parentId)
|
|
1955
|
+
if (parent && 'appendChild' in parent) {
|
|
1956
|
+
;(parent as FrameNode).appendChild(instance)
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Apply pending Grid layouts
|
|
1962
|
+
if (pendingGridLayouts && pendingGridLayouts.length > 0) {
|
|
1963
|
+
for (const grid of pendingGridLayouts) {
|
|
1964
|
+
const nodeId = `${grid.nodeGUID.sessionID}:${grid.nodeGUID.localID}`
|
|
1965
|
+
const node = await figma.getNodeByIdAsync(nodeId)
|
|
1966
|
+
if (!node || node.type !== 'FRAME') continue
|
|
1967
|
+
|
|
1968
|
+
const frame = node as FrameNode
|
|
1969
|
+
if (frame.layoutMode !== 'GRID') {
|
|
1970
|
+
frame.layoutMode = 'GRID'
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Parse grid template syntax: "100px 1fr auto" → [{type, value}, ...]
|
|
1974
|
+
const parseGridTemplate = (
|
|
1975
|
+
template: string
|
|
1976
|
+
): Array<{ type: 'FIXED' | 'FLEX' | 'HUG'; value: number }> => {
|
|
1977
|
+
return template
|
|
1978
|
+
.split(/\s+/)
|
|
1979
|
+
.filter(Boolean)
|
|
1980
|
+
.map((part) => {
|
|
1981
|
+
if (part.endsWith('px')) {
|
|
1982
|
+
return { type: 'FIXED' as const, value: parseFloat(part) }
|
|
1983
|
+
} else if (part.endsWith('fr')) {
|
|
1984
|
+
return { type: 'FLEX' as const, value: parseFloat(part) || 1 }
|
|
1985
|
+
} else if (part === 'auto' || part === 'hug') {
|
|
1986
|
+
return { type: 'HUG' as const, value: 1 }
|
|
1987
|
+
} else {
|
|
1988
|
+
return { type: 'FIXED' as const, value: parseFloat(part) || 100 }
|
|
1989
|
+
}
|
|
1990
|
+
})
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Parse templates first to get counts
|
|
1994
|
+
const colSizes = grid.gridTemplateColumns
|
|
1995
|
+
? parseGridTemplate(grid.gridTemplateColumns)
|
|
1996
|
+
: null
|
|
1997
|
+
const rowSizes = grid.gridTemplateRows ? parseGridTemplate(grid.gridTemplateRows) : null
|
|
1998
|
+
|
|
1999
|
+
// Set counts first (Figma requires this before setting sizes)
|
|
2000
|
+
if (colSizes && colSizes.length > 0) {
|
|
2001
|
+
frame.gridColumnCount = colSizes.length
|
|
2002
|
+
}
|
|
2003
|
+
if (rowSizes && rowSizes.length > 0) {
|
|
2004
|
+
frame.gridRowCount = rowSizes.length
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// Now set sizes
|
|
2008
|
+
if (colSizes && colSizes.length > 0) {
|
|
2009
|
+
frame.gridColumnSizes = colSizes
|
|
2010
|
+
}
|
|
2011
|
+
if (rowSizes && rowSizes.length > 0) {
|
|
2012
|
+
frame.gridRowSizes = rowSizes
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// First pass: fix children (bottom-up for correct sizing)
|
|
2018
|
+
const fixRecursive = async (node: SceneNode) => {
|
|
2019
|
+
// Process children first (bottom-up)
|
|
2020
|
+
if ('children' in node) {
|
|
2021
|
+
for (const child of node.children) {
|
|
2022
|
+
await fixRecursive(child)
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// Fix TEXT nodes: reload characters to trigger auto-resize
|
|
2027
|
+
if (node.type === 'TEXT' && node.textAutoResize !== 'NONE') {
|
|
2028
|
+
try {
|
|
2029
|
+
await figma.loadFontAsync(node.fontName as FontName)
|
|
2030
|
+
const chars = node.characters
|
|
2031
|
+
node.characters = ''
|
|
2032
|
+
node.characters = chars
|
|
2033
|
+
} catch {
|
|
2034
|
+
// Font not available, skip
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Fix auto-layout frames
|
|
2039
|
+
if ('layoutMode' in node && node.layoutMode !== 'NONE') {
|
|
2040
|
+
const frame = node as FrameNode
|
|
2041
|
+
const needsPrimaryRecalc = frame.primaryAxisSizingMode === 'AUTO'
|
|
2042
|
+
const needsCounterRecalc = frame.counterAxisSizingMode === 'AUTO'
|
|
2043
|
+
if (needsPrimaryRecalc || needsCounterRecalc) {
|
|
2044
|
+
// Re-apply AUTO sizing to trigger recalculation
|
|
2045
|
+
// Temporarily set to FIXED, then back to AUTO forces Figma to recalc
|
|
2046
|
+
if (needsPrimaryRecalc) {
|
|
2047
|
+
frame.primaryAxisSizingMode = 'FIXED'
|
|
2048
|
+
frame.primaryAxisSizingMode = 'AUTO'
|
|
2049
|
+
}
|
|
2050
|
+
if (needsCounterRecalc) {
|
|
2051
|
+
frame.counterAxisSizingMode = 'FIXED'
|
|
2052
|
+
frame.counterAxisSizingMode = 'AUTO'
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
await fixRecursive(root as SceneNode)
|
|
2059
|
+
return { triggered: true }
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// ==================== VARIABLES ====================
|
|
2063
|
+
case 'get-variables': {
|
|
2064
|
+
const { type, simple } = args as { type?: string; simple?: boolean }
|
|
2065
|
+
const variables = await figma.variables.getLocalVariablesAsync(
|
|
2066
|
+
type as VariableResolvedDataType | undefined
|
|
2067
|
+
)
|
|
2068
|
+
// Simple mode returns only id and name (for variable registry)
|
|
2069
|
+
if (simple) {
|
|
2070
|
+
return variables.map((v) => ({ id: v.id, name: v.name }))
|
|
2071
|
+
}
|
|
2072
|
+
return variables.map((v) => serializeVariable(v))
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
case 'get-variable': {
|
|
2076
|
+
const { id } = args as { id: string }
|
|
2077
|
+
const variable = await figma.variables.getVariableByIdAsync(id)
|
|
2078
|
+
if (!variable) throw new Error('Variable not found')
|
|
2079
|
+
return serializeVariable(variable)
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
case 'create-variable': {
|
|
2083
|
+
const { name, collectionId, type, value } = args as {
|
|
2084
|
+
name: string
|
|
2085
|
+
collectionId: string
|
|
2086
|
+
type: string
|
|
2087
|
+
value?: string
|
|
2088
|
+
}
|
|
2089
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(collectionId)
|
|
2090
|
+
if (!collection) throw new Error('Collection not found')
|
|
2091
|
+
const variable = figma.variables.createVariable(
|
|
2092
|
+
name,
|
|
2093
|
+
collection,
|
|
2094
|
+
type as VariableResolvedDataType
|
|
2095
|
+
)
|
|
2096
|
+
if (value !== undefined && collection.modes.length > 0) {
|
|
2097
|
+
const modeId = collection.modes[0].modeId
|
|
2098
|
+
variable.setValueForMode(modeId, parseVariableValue(value, type))
|
|
2099
|
+
}
|
|
2100
|
+
return serializeVariable(variable)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
case 'set-variable-value': {
|
|
2104
|
+
const { id, modeId, value } = args as { id: string; modeId: string; value: string }
|
|
2105
|
+
const variable = await figma.variables.getVariableByIdAsync(id)
|
|
2106
|
+
if (!variable) throw new Error('Variable not found')
|
|
2107
|
+
variable.setValueForMode(modeId, parseVariableValue(value, variable.resolvedType))
|
|
2108
|
+
return serializeVariable(variable)
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
case 'delete-variable': {
|
|
2112
|
+
const { id } = args as { id: string }
|
|
2113
|
+
const variable = await figma.variables.getVariableByIdAsync(id)
|
|
2114
|
+
if (!variable) throw new Error('Variable not found')
|
|
2115
|
+
variable.remove()
|
|
2116
|
+
return { deleted: true }
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
case 'bind-variable': {
|
|
2120
|
+
const { nodeId, field, variableId } = args as {
|
|
2121
|
+
nodeId: string
|
|
2122
|
+
field: string
|
|
2123
|
+
variableId: string
|
|
2124
|
+
}
|
|
2125
|
+
const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
|
|
2126
|
+
if (!node) throw new Error('Node not found')
|
|
2127
|
+
const variable = await figma.variables.getVariableByIdAsync(variableId)
|
|
2128
|
+
if (!variable) throw new Error('Variable not found')
|
|
2129
|
+
if ('setBoundVariable' in node) {
|
|
2130
|
+
;(node as any).setBoundVariable(field, variable)
|
|
2131
|
+
} else {
|
|
2132
|
+
throw new Error('Node does not support variable binding')
|
|
2133
|
+
}
|
|
2134
|
+
return serializeNode(node)
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
case 'bind-fill-variable': {
|
|
2138
|
+
const {
|
|
2139
|
+
nodeId,
|
|
2140
|
+
variableId,
|
|
2141
|
+
paintIndex = 0
|
|
2142
|
+
} = args as { nodeId: string; variableId: string; paintIndex?: number }
|
|
2143
|
+
const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
|
|
2144
|
+
if (!node) throw new Error('Node not found')
|
|
2145
|
+
if (!('fills' in node)) throw new Error('Node does not have fills')
|
|
2146
|
+
const variable = await figma.variables.getVariableByIdAsync(variableId)
|
|
2147
|
+
if (!variable) throw new Error('Variable not found')
|
|
2148
|
+
const fills = (node as GeometryMixin).fills as Paint[]
|
|
2149
|
+
if (!fills[paintIndex]) throw new Error('Paint not found at index ' + paintIndex)
|
|
2150
|
+
const newFill = figma.variables.setBoundVariableForPaint(fills[paintIndex], 'color', variable)
|
|
2151
|
+
;(node as GeometryMixin).fills = [
|
|
2152
|
+
...fills.slice(0, paintIndex),
|
|
2153
|
+
newFill,
|
|
2154
|
+
...fills.slice(paintIndex + 1)
|
|
2155
|
+
]
|
|
2156
|
+
return serializeNode(node)
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
case 'bind-stroke-variable': {
|
|
2160
|
+
const {
|
|
2161
|
+
nodeId,
|
|
2162
|
+
variableId,
|
|
2163
|
+
paintIndex = 0
|
|
2164
|
+
} = args as { nodeId: string; variableId: string; paintIndex?: number }
|
|
2165
|
+
const node = (await figma.getNodeByIdAsync(nodeId)) as SceneNode | null
|
|
2166
|
+
if (!node) throw new Error('Node not found')
|
|
2167
|
+
if (!('strokes' in node)) throw new Error('Node does not have strokes')
|
|
2168
|
+
const variable = await figma.variables.getVariableByIdAsync(variableId)
|
|
2169
|
+
if (!variable) throw new Error('Variable not found')
|
|
2170
|
+
const strokes = (node as GeometryMixin).strokes as Paint[]
|
|
2171
|
+
if (!strokes[paintIndex]) throw new Error('Paint not found at index ' + paintIndex)
|
|
2172
|
+
const newStroke = figma.variables.setBoundVariableForPaint(
|
|
2173
|
+
strokes[paintIndex],
|
|
2174
|
+
'color',
|
|
2175
|
+
variable
|
|
2176
|
+
)
|
|
2177
|
+
;(node as GeometryMixin).strokes = [
|
|
2178
|
+
...strokes.slice(0, paintIndex),
|
|
2179
|
+
newStroke,
|
|
2180
|
+
...strokes.slice(paintIndex + 1)
|
|
2181
|
+
]
|
|
2182
|
+
return serializeNode(node)
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// ==================== VARIABLE COLLECTIONS ====================
|
|
2186
|
+
case 'get-variable-collections': {
|
|
2187
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync()
|
|
2188
|
+
return collections.map((c) => serializeCollection(c))
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
case 'get-variable-collection': {
|
|
2192
|
+
const { id } = args as { id: string }
|
|
2193
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(id)
|
|
2194
|
+
if (!collection) throw new Error('Collection not found')
|
|
2195
|
+
return serializeCollection(collection)
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
case 'create-variable-collection': {
|
|
2199
|
+
const { name } = args as { name: string }
|
|
2200
|
+
const collection = figma.variables.createVariableCollection(name)
|
|
2201
|
+
return serializeCollection(collection)
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
case 'delete-variable-collection': {
|
|
2205
|
+
const { id } = args as { id: string }
|
|
2206
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(id)
|
|
2207
|
+
if (!collection) throw new Error('Collection not found')
|
|
2208
|
+
collection.remove()
|
|
2209
|
+
return { deleted: true }
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// ==================== CONNECTORS ====================
|
|
2213
|
+
case 'create-connector': {
|
|
2214
|
+
const {
|
|
2215
|
+
fromId,
|
|
2216
|
+
toId,
|
|
2217
|
+
fromMagnet,
|
|
2218
|
+
toMagnet,
|
|
2219
|
+
lineType,
|
|
2220
|
+
startCap,
|
|
2221
|
+
endCap,
|
|
2222
|
+
stroke,
|
|
2223
|
+
strokeWeight,
|
|
2224
|
+
cornerRadius
|
|
2225
|
+
} = args as {
|
|
2226
|
+
fromId: string
|
|
2227
|
+
toId: string
|
|
2228
|
+
fromMagnet?: string
|
|
2229
|
+
toMagnet?: string
|
|
2230
|
+
lineType?: 'STRAIGHT' | 'ELBOWED' | 'CURVED'
|
|
2231
|
+
startCap?: string
|
|
2232
|
+
endCap?: string
|
|
2233
|
+
stroke?: string
|
|
2234
|
+
strokeWeight?: number
|
|
2235
|
+
cornerRadius?: number
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const fromNode = await figma.getNodeByIdAsync(fromId)
|
|
2239
|
+
const toNode = await figma.getNodeByIdAsync(toId)
|
|
2240
|
+
if (!fromNode || !('absoluteBoundingBox' in fromNode))
|
|
2241
|
+
throw new Error('From node not found or invalid')
|
|
2242
|
+
if (!toNode || !('absoluteBoundingBox' in toNode))
|
|
2243
|
+
throw new Error('To node not found or invalid')
|
|
2244
|
+
|
|
2245
|
+
const connector = figma.createConnector()
|
|
2246
|
+
connector.connectorStart = {
|
|
2247
|
+
endpointNodeId: fromId,
|
|
2248
|
+
magnet: (fromMagnet as ConnectorMagnet) || 'AUTO'
|
|
2249
|
+
}
|
|
2250
|
+
connector.connectorEnd = {
|
|
2251
|
+
endpointNodeId: toId,
|
|
2252
|
+
magnet: (toMagnet as ConnectorMagnet) || 'AUTO'
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
if (lineType) connector.connectorLineType = lineType
|
|
2256
|
+
if (startCap) connector.connectorStartStrokeCap = startCap as ConnectorStrokeCap
|
|
2257
|
+
if (endCap) connector.connectorEndStrokeCap = endCap as ConnectorStrokeCap
|
|
2258
|
+
if (cornerRadius !== undefined) connector.cornerRadius = cornerRadius
|
|
2259
|
+
if (strokeWeight !== undefined) connector.strokeWeight = strokeWeight
|
|
2260
|
+
if (stroke) {
|
|
2261
|
+
const hex = stroke.replace('#', '')
|
|
2262
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255
|
|
2263
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255
|
|
2264
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255
|
|
2265
|
+
connector.strokes = [{ type: 'SOLID', color: { r, g, b } }]
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
return serializeNode(connector)
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
case 'get-connector': {
|
|
2272
|
+
const { id } = args as { id: string }
|
|
2273
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
2274
|
+
if (!node || node.type !== 'CONNECTOR') throw new Error('Connector not found')
|
|
2275
|
+
|
|
2276
|
+
const connector = node as ConnectorNode
|
|
2277
|
+
const fromNode = await figma.getNodeByIdAsync(connector.connectorStart.endpointNodeId)
|
|
2278
|
+
const toNode = await figma.getNodeByIdAsync(connector.connectorEnd.endpointNodeId)
|
|
2279
|
+
|
|
2280
|
+
const stroke = connector.strokes[0]
|
|
2281
|
+
const strokeHex =
|
|
2282
|
+
stroke && stroke.type === 'SOLID'
|
|
2283
|
+
? '#' +
|
|
2284
|
+
[stroke.color.r, stroke.color.g, stroke.color.b]
|
|
2285
|
+
.map((c) =>
|
|
2286
|
+
Math.round(c * 255)
|
|
2287
|
+
.toString(16)
|
|
2288
|
+
.padStart(2, '0')
|
|
2289
|
+
)
|
|
2290
|
+
.join('')
|
|
2291
|
+
.toUpperCase()
|
|
2292
|
+
: undefined
|
|
2293
|
+
|
|
2294
|
+
return {
|
|
2295
|
+
id: connector.id,
|
|
2296
|
+
name: connector.name,
|
|
2297
|
+
fromNode: {
|
|
2298
|
+
id: connector.connectorStart.endpointNodeId,
|
|
2299
|
+
name: fromNode?.name || 'Unknown',
|
|
2300
|
+
magnet: connector.connectorStart.magnet
|
|
2301
|
+
},
|
|
2302
|
+
toNode: {
|
|
2303
|
+
id: connector.connectorEnd.endpointNodeId,
|
|
2304
|
+
name: toNode?.name || 'Unknown',
|
|
2305
|
+
magnet: connector.connectorEnd.magnet
|
|
2306
|
+
},
|
|
2307
|
+
lineType: connector.connectorLineType,
|
|
2308
|
+
startCap: connector.connectorStartStrokeCap,
|
|
2309
|
+
endCap: connector.connectorEndStrokeCap,
|
|
2310
|
+
stroke: strokeHex,
|
|
2311
|
+
strokeWeight: connector.strokeWeight,
|
|
2312
|
+
cornerRadius: connector.cornerRadius
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
case 'set-connector': {
|
|
2317
|
+
const {
|
|
2318
|
+
id,
|
|
2319
|
+
fromId,
|
|
2320
|
+
toId,
|
|
2321
|
+
fromMagnet,
|
|
2322
|
+
toMagnet,
|
|
2323
|
+
lineType,
|
|
2324
|
+
startCap,
|
|
2325
|
+
endCap,
|
|
2326
|
+
stroke,
|
|
2327
|
+
strokeWeight,
|
|
2328
|
+
cornerRadius
|
|
2329
|
+
} = args as {
|
|
2330
|
+
id: string
|
|
2331
|
+
fromId?: string
|
|
2332
|
+
toId?: string
|
|
2333
|
+
fromMagnet?: string
|
|
2334
|
+
toMagnet?: string
|
|
2335
|
+
lineType?: 'STRAIGHT' | 'ELBOWED' | 'CURVED'
|
|
2336
|
+
startCap?: string
|
|
2337
|
+
endCap?: string
|
|
2338
|
+
stroke?: string
|
|
2339
|
+
strokeWeight?: number
|
|
2340
|
+
cornerRadius?: number
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const node = await figma.getNodeByIdAsync(id)
|
|
2344
|
+
if (!node || node.type !== 'CONNECTOR') throw new Error('Connector not found')
|
|
2345
|
+
|
|
2346
|
+
const connector = node as ConnectorNode
|
|
2347
|
+
|
|
2348
|
+
if (fromId) {
|
|
2349
|
+
connector.connectorStart = {
|
|
2350
|
+
endpointNodeId: fromId,
|
|
2351
|
+
magnet: (fromMagnet as ConnectorMagnet) || connector.connectorStart.magnet
|
|
2352
|
+
}
|
|
2353
|
+
} else if (fromMagnet) {
|
|
2354
|
+
connector.connectorStart = {
|
|
2355
|
+
...connector.connectorStart,
|
|
2356
|
+
magnet: fromMagnet as ConnectorMagnet
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
if (toId) {
|
|
2361
|
+
connector.connectorEnd = {
|
|
2362
|
+
endpointNodeId: toId,
|
|
2363
|
+
magnet: (toMagnet as ConnectorMagnet) || connector.connectorEnd.magnet
|
|
2364
|
+
}
|
|
2365
|
+
} else if (toMagnet) {
|
|
2366
|
+
connector.connectorEnd = {
|
|
2367
|
+
...connector.connectorEnd,
|
|
2368
|
+
magnet: toMagnet as ConnectorMagnet
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (lineType) connector.connectorLineType = lineType
|
|
2373
|
+
if (startCap) connector.connectorStartStrokeCap = startCap as ConnectorStrokeCap
|
|
2374
|
+
if (endCap) connector.connectorEndStrokeCap = endCap as ConnectorStrokeCap
|
|
2375
|
+
if (cornerRadius !== undefined) connector.cornerRadius = cornerRadius
|
|
2376
|
+
if (strokeWeight !== undefined) connector.strokeWeight = strokeWeight
|
|
2377
|
+
if (stroke) {
|
|
2378
|
+
const hex = stroke.replace('#', '')
|
|
2379
|
+
const r = parseInt(hex.slice(0, 2), 16) / 255
|
|
2380
|
+
const g = parseInt(hex.slice(2, 4), 16) / 255
|
|
2381
|
+
const b = parseInt(hex.slice(4, 6), 16) / 255
|
|
2382
|
+
connector.strokes = [{ type: 'SOLID', color: { r, g, b } }]
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
return serializeNode(connector)
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
case 'list-connectors': {
|
|
2389
|
+
const { fromId, toId } = args as { fromId?: string; toId?: string }
|
|
2390
|
+
const page = figma.currentPage
|
|
2391
|
+
const connectors: ConnectorNode[] = []
|
|
2392
|
+
|
|
2393
|
+
function findConnectors(node: BaseNode) {
|
|
2394
|
+
if (node.type === 'CONNECTOR') {
|
|
2395
|
+
const c = node as ConnectorNode
|
|
2396
|
+
if (fromId && c.connectorStart.endpointNodeId !== fromId) return
|
|
2397
|
+
if (toId && c.connectorEnd.endpointNodeId !== toId) return
|
|
2398
|
+
connectors.push(c)
|
|
2399
|
+
}
|
|
2400
|
+
if ('children' in node) {
|
|
2401
|
+
for (const child of (node as ChildrenMixin).children) {
|
|
2402
|
+
findConnectors(child)
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
findConnectors(page)
|
|
2408
|
+
|
|
2409
|
+
const results = await Promise.all(
|
|
2410
|
+
connectors.map(async (c) => {
|
|
2411
|
+
const fromNode = await figma.getNodeByIdAsync(c.connectorStart.endpointNodeId)
|
|
2412
|
+
const toNode = await figma.getNodeByIdAsync(c.connectorEnd.endpointNodeId)
|
|
2413
|
+
const stroke = c.strokes[0]
|
|
2414
|
+
const strokeHex =
|
|
2415
|
+
stroke && stroke.type === 'SOLID'
|
|
2416
|
+
? '#' +
|
|
2417
|
+
[stroke.color.r, stroke.color.g, stroke.color.b]
|
|
2418
|
+
.map((v) =>
|
|
2419
|
+
Math.round(v * 255)
|
|
2420
|
+
.toString(16)
|
|
2421
|
+
.padStart(2, '0')
|
|
2422
|
+
)
|
|
2423
|
+
.join('')
|
|
2424
|
+
.toUpperCase()
|
|
2425
|
+
: undefined
|
|
2426
|
+
|
|
2427
|
+
return {
|
|
2428
|
+
id: c.id,
|
|
2429
|
+
name: c.name,
|
|
2430
|
+
fromNode: {
|
|
2431
|
+
id: c.connectorStart.endpointNodeId,
|
|
2432
|
+
name: fromNode?.name || 'Unknown',
|
|
2433
|
+
magnet: c.connectorStart.magnet
|
|
2434
|
+
},
|
|
2435
|
+
toNode: {
|
|
2436
|
+
id: c.connectorEnd.endpointNodeId,
|
|
2437
|
+
name: toNode?.name || 'Unknown',
|
|
2438
|
+
magnet: c.connectorEnd.magnet
|
|
2439
|
+
},
|
|
2440
|
+
lineType: c.connectorLineType,
|
|
2441
|
+
stroke: strokeHex
|
|
2442
|
+
}
|
|
2443
|
+
})
|
|
2444
|
+
)
|
|
2445
|
+
|
|
2446
|
+
return results
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
default:
|
|
2450
|
+
throw new Error(`Unknown command: ${command}`)
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function appendToParent(node: SceneNode, parentId?: string, insertIndex?: number) {
|
|
2455
|
+
if (parentId) {
|
|
2456
|
+
const parent = await retry(
|
|
2457
|
+
() => figma.getNodeByIdAsync(parentId) as Promise<(FrameNode & ChildrenMixin) | null>,
|
|
2458
|
+
10,
|
|
2459
|
+
50
|
|
2460
|
+
)
|
|
2461
|
+
if (parent && 'appendChild' in parent) {
|
|
2462
|
+
if (insertIndex !== undefined && 'insertChild' in parent) {
|
|
2463
|
+
parent.insertChild(insertIndex, node)
|
|
2464
|
+
} else {
|
|
2465
|
+
parent.appendChild(node)
|
|
2466
|
+
}
|
|
2467
|
+
return
|
|
2468
|
+
}
|
|
2469
|
+
console.warn(`Parent ${parentId} not found after retries, appending to page`)
|
|
2470
|
+
}
|
|
2471
|
+
figma.currentPage.appendChild(node)
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
function serializeNode(node: BaseNode): object {
|
|
2475
|
+
const base: Record<string, unknown> = {
|
|
2476
|
+
id: node.id,
|
|
2477
|
+
name: node.name,
|
|
2478
|
+
type: node.type
|
|
2479
|
+
}
|
|
2480
|
+
if (node.parent && node.parent.type !== 'PAGE') {
|
|
2481
|
+
base.parentId = node.parent.id
|
|
2482
|
+
}
|
|
2483
|
+
if ('x' in node) base.x = Math.round(node.x)
|
|
2484
|
+
if ('y' in node) base.y = Math.round(node.y)
|
|
2485
|
+
if ('width' in node) base.width = Math.round(node.width)
|
|
2486
|
+
if ('height' in node) base.height = Math.round(node.height)
|
|
2487
|
+
|
|
2488
|
+
// Only include non-default values
|
|
2489
|
+
if ('opacity' in node && node.opacity !== 1) base.opacity = node.opacity
|
|
2490
|
+
if ('visible' in node && !node.visible) base.visible = false
|
|
2491
|
+
if ('locked' in node && node.locked) base.locked = true
|
|
2492
|
+
|
|
2493
|
+
// Serialize fills/strokes compactly
|
|
2494
|
+
if ('fills' in node && Array.isArray(node.fills) && node.fills.length > 0) {
|
|
2495
|
+
base.fills = node.fills.map(serializePaint)
|
|
2496
|
+
}
|
|
2497
|
+
if ('strokes' in node && Array.isArray(node.strokes) && node.strokes.length > 0) {
|
|
2498
|
+
base.strokes = node.strokes.map(serializePaint)
|
|
2499
|
+
}
|
|
2500
|
+
if ('strokeWeight' in node && typeof node.strokeWeight === 'number' && node.strokeWeight > 0) {
|
|
2501
|
+
base.strokeWeight = node.strokeWeight
|
|
2502
|
+
}
|
|
2503
|
+
if ('cornerRadius' in node && typeof node.cornerRadius === 'number' && node.cornerRadius > 0) {
|
|
2504
|
+
base.cornerRadius = node.cornerRadius
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
if ('componentPropertyDefinitions' in node) {
|
|
2508
|
+
try {
|
|
2509
|
+
base.componentPropertyDefinitions = node.componentPropertyDefinitions
|
|
2510
|
+
} catch {
|
|
2511
|
+
// Variant components throw when accessing componentPropertyDefinitions
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if ('componentProperties' in node) {
|
|
2515
|
+
base.componentProperties = node.componentProperties
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// Layout properties for frames
|
|
2519
|
+
if ('layoutMode' in node && node.layoutMode !== 'NONE') {
|
|
2520
|
+
base.layoutMode = node.layoutMode
|
|
2521
|
+
if ('itemSpacing' in node) base.itemSpacing = node.itemSpacing
|
|
2522
|
+
if (
|
|
2523
|
+
'paddingLeft' in node &&
|
|
2524
|
+
(node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom)
|
|
2525
|
+
) {
|
|
2526
|
+
base.padding = {
|
|
2527
|
+
left: node.paddingLeft,
|
|
2528
|
+
right: node.paddingRight,
|
|
2529
|
+
top: node.paddingTop,
|
|
2530
|
+
bottom: node.paddingBottom
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
// Grid layout properties
|
|
2534
|
+
if (node.layoutMode === 'GRID') {
|
|
2535
|
+
const gridNode = node as FrameNode
|
|
2536
|
+
if (gridNode.gridColumnGap !== undefined) base.gridColumnGap = gridNode.gridColumnGap
|
|
2537
|
+
if (gridNode.gridRowGap !== undefined) base.gridRowGap = gridNode.gridRowGap
|
|
2538
|
+
if (gridNode.gridColumnCount !== undefined) base.gridColumnCount = gridNode.gridColumnCount
|
|
2539
|
+
if (gridNode.gridRowCount !== undefined) base.gridRowCount = gridNode.gridRowCount
|
|
2540
|
+
if (gridNode.gridColumnSizes?.length > 0) base.gridColumnSizes = gridNode.gridColumnSizes
|
|
2541
|
+
if (gridNode.gridRowSizes?.length > 0) base.gridRowSizes = gridNode.gridRowSizes
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// Text properties
|
|
2546
|
+
if (node.type === 'TEXT') {
|
|
2547
|
+
const textNode = node as TextNode
|
|
2548
|
+
base.characters = textNode.characters
|
|
2549
|
+
if (typeof textNode.fontSize === 'number') base.fontSize = textNode.fontSize
|
|
2550
|
+
if (typeof textNode.fontName === 'object') {
|
|
2551
|
+
base.fontFamily = textNode.fontName.family
|
|
2552
|
+
base.fontStyle = textNode.fontName.style
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Children count for containers
|
|
2557
|
+
if ('children' in node) {
|
|
2558
|
+
base.childCount = (node as ChildrenMixin).children.length
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
return base
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function serializePaint(paint: Paint): object {
|
|
2565
|
+
if (paint.type === 'SOLID') {
|
|
2566
|
+
const result: Record<string, unknown> = {
|
|
2567
|
+
type: 'SOLID',
|
|
2568
|
+
color: rgbToHex(paint.color)
|
|
2569
|
+
}
|
|
2570
|
+
if (paint.opacity !== undefined && paint.opacity !== 1) result.opacity = paint.opacity
|
|
2571
|
+
return result
|
|
2572
|
+
}
|
|
2573
|
+
if (paint.type === 'IMAGE') {
|
|
2574
|
+
return { type: 'IMAGE', imageHash: paint.imageHash, scaleMode: paint.scaleMode }
|
|
2575
|
+
}
|
|
2576
|
+
if (
|
|
2577
|
+
paint.type === 'GRADIENT_LINEAR' ||
|
|
2578
|
+
paint.type === 'GRADIENT_RADIAL' ||
|
|
2579
|
+
paint.type === 'GRADIENT_ANGULAR' ||
|
|
2580
|
+
paint.type === 'GRADIENT_DIAMOND'
|
|
2581
|
+
) {
|
|
2582
|
+
return {
|
|
2583
|
+
type: paint.type,
|
|
2584
|
+
stops: paint.gradientStops.map((s) => ({ color: rgbToHex(s.color), position: s.position }))
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return { type: paint.type }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function rgbToHex(color: RGB): string {
|
|
2591
|
+
const r = Math.round(color.r * 255)
|
|
2592
|
+
.toString(16)
|
|
2593
|
+
.padStart(2, '0')
|
|
2594
|
+
const g = Math.round(color.g * 255)
|
|
2595
|
+
.toString(16)
|
|
2596
|
+
.padStart(2, '0')
|
|
2597
|
+
const b = Math.round(color.b * 255)
|
|
2598
|
+
.toString(16)
|
|
2599
|
+
.padStart(2, '0')
|
|
2600
|
+
return `#${r}${g}${b}`.toUpperCase()
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function expandHex(hex: string): string {
|
|
2604
|
+
const clean = hex.replace('#', '')
|
|
2605
|
+
if (clean.length === 3) {
|
|
2606
|
+
return clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2]
|
|
2607
|
+
}
|
|
2608
|
+
if (clean.length === 4) {
|
|
2609
|
+
return clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2] + clean[3] + clean[3]
|
|
2610
|
+
}
|
|
2611
|
+
return clean
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function hexToRgb(hex: string): RGB {
|
|
2615
|
+
const clean = expandHex(hex)
|
|
2616
|
+
return {
|
|
2617
|
+
r: parseInt(clean.slice(0, 2), 16) / 255,
|
|
2618
|
+
g: parseInt(clean.slice(2, 4), 16) / 255,
|
|
2619
|
+
b: parseInt(clean.slice(4, 6), 16) / 255
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* Parse color string - supports hex and variable references (var:Name or $Name)
|
|
2625
|
+
*/
|
|
2626
|
+
function parsestring(color: string): { hex?: string; variable?: string } {
|
|
2627
|
+
const varMatch = color.match(/^(?:var:|[$])(.+)$/)
|
|
2628
|
+
if (varMatch) {
|
|
2629
|
+
return { variable: varMatch[1] }
|
|
2630
|
+
}
|
|
2631
|
+
return { hex: color }
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Get hex color from color string (for sync operations, ignores variables)
|
|
2636
|
+
*/
|
|
2637
|
+
function getHexColor(color: string): string {
|
|
2638
|
+
const parsed = parsestring(color)
|
|
2639
|
+
return parsed.hex || '#000000'
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
/**
|
|
2643
|
+
* Create a solid paint from color string (hex or var:Name/$Name)
|
|
2644
|
+
*/
|
|
2645
|
+
async function createSolidPaint(color: string): Promise<SolidPaint> {
|
|
2646
|
+
const parsed = parsestring(color)
|
|
2647
|
+
|
|
2648
|
+
if (parsed.hex) {
|
|
2649
|
+
return { type: 'SOLID', color: hexToRgb(parsed.hex) }
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// Variable reference
|
|
2653
|
+
const variables = await figma.variables.getLocalVariablesAsync('COLOR')
|
|
2654
|
+
const variable = variables.find((v) => v.name === parsed.variable)
|
|
2655
|
+
|
|
2656
|
+
if (!variable) {
|
|
2657
|
+
console.warn(`Variable "${parsed.variable}" not found, using black`)
|
|
2658
|
+
return { type: 'SOLID', color: { r: 0, g: 0, b: 0 } }
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const paint: SolidPaint = {
|
|
2662
|
+
type: 'SOLID',
|
|
2663
|
+
color: { r: 0, g: 0, b: 0 } // Will be overridden by variable
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
return figma.variables.setBoundVariableForPaint(paint, 'color', variable)
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
function hexToRgba(hex: string): RGBA {
|
|
2670
|
+
const clean = expandHex(hex)
|
|
2671
|
+
const hasAlpha = clean.length === 8
|
|
2672
|
+
return {
|
|
2673
|
+
r: parseInt(clean.slice(0, 2), 16) / 255,
|
|
2674
|
+
g: parseInt(clean.slice(2, 4), 16) / 255,
|
|
2675
|
+
b: parseInt(clean.slice(4, 6), 16) / 255,
|
|
2676
|
+
a: hasAlpha ? parseInt(clean.slice(6, 8), 16) / 255 : 1
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
function serializeVariable(v: Variable): object {
|
|
2681
|
+
return {
|
|
2682
|
+
id: v.id,
|
|
2683
|
+
name: v.name,
|
|
2684
|
+
type: v.resolvedType,
|
|
2685
|
+
collectionId: v.variableCollectionId,
|
|
2686
|
+
description: v.description || undefined,
|
|
2687
|
+
valuesByMode: Object.fromEntries(
|
|
2688
|
+
Object.entries(v.valuesByMode).map(([modeId, value]) => [
|
|
2689
|
+
modeId,
|
|
2690
|
+
serializeVariableValue(value, v.resolvedType)
|
|
2691
|
+
])
|
|
2692
|
+
)
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
function serializeCollection(c: VariableCollection): object {
|
|
2697
|
+
return {
|
|
2698
|
+
id: c.id,
|
|
2699
|
+
name: c.name,
|
|
2700
|
+
modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
|
|
2701
|
+
variableIds: c.variableIds
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
function serializeVariableValue(value: VariableValue, type: string): unknown {
|
|
2706
|
+
if (type === 'COLOR' && typeof value === 'object' && 'r' in value) {
|
|
2707
|
+
return rgbToHex(value as RGB)
|
|
2708
|
+
}
|
|
2709
|
+
if (typeof value === 'object' && 'type' in value && (value as any).type === 'VARIABLE_ALIAS') {
|
|
2710
|
+
return { alias: (value as VariableAlias).id }
|
|
2711
|
+
}
|
|
2712
|
+
return value
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function parseVariableValue(value: string, type: string): VariableValue {
|
|
2716
|
+
switch (type) {
|
|
2717
|
+
case 'COLOR':
|
|
2718
|
+
return hexToRgb(value)
|
|
2719
|
+
case 'FLOAT':
|
|
2720
|
+
return parseFloat(value)
|
|
2721
|
+
case 'BOOLEAN':
|
|
2722
|
+
return value === 'true'
|
|
2723
|
+
case 'STRING':
|
|
2724
|
+
default:
|
|
2725
|
+
return value
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// Convert svgpath segments to string with spaces (Figma requires spaces between commands)
|
|
2730
|
+
function svgPathToString(sp: ReturnType<typeof svgpath>): string {
|
|
2731
|
+
return sp.segments.map((seg: (string | number)[]) => seg.join(' ')).join(' ')
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
// Expose RPC for CDP injection
|
|
2735
|
+
declare const window: Window & { __figmaRpc?: typeof rpcHandler }
|
|
2736
|
+
|
|
2737
|
+
async function rpcHandler(command: string, args?: unknown): Promise<unknown> {
|
|
2738
|
+
if (!allPagesLoaded && NEEDS_ALL_PAGES.has(command)) {
|
|
2739
|
+
await figma.loadAllPagesAsync()
|
|
2740
|
+
allPagesLoaded = true
|
|
2741
|
+
}
|
|
2742
|
+
return handleCommand(command, args)
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
if (typeof window !== 'undefined') {
|
|
2746
|
+
window.__figmaRpc = rpcHandler
|
|
2747
|
+
}
|