figma-local 1.0.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/README.md +342 -0
- package/bin/fig-start +289 -0
- package/bin/setup-alias.sh +48 -0
- package/package.json +47 -0
- package/src/blocks/dashboard-01.js +379 -0
- package/src/blocks/index.js +27 -0
- package/src/daemon.js +664 -0
- package/src/figjam-client.js +313 -0
- package/src/figma-client.js +4198 -0
- package/src/figma-patch.js +185 -0
- package/src/index.js +8543 -0
- package/src/platform.js +206 -0
- package/src/prompt-templates.js +289 -0
- package/src/read.js +243 -0
- package/src/shadcn.js +237 -0
|
@@ -0,0 +1,4198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma CDP Client
|
|
3
|
+
*
|
|
4
|
+
* Connects directly to Figma via Chrome DevTools Protocol.
|
|
5
|
+
* No external dependencies required.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import WebSocket from 'ws';
|
|
9
|
+
import { getCdpPort } from './figma-patch.js';
|
|
10
|
+
|
|
11
|
+
export class FigmaClient {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.ws = null;
|
|
14
|
+
this.msgId = 0;
|
|
15
|
+
this.callbacks = new Map();
|
|
16
|
+
this.pageTitle = null;
|
|
17
|
+
this.pageUrl = null;
|
|
18
|
+
this.fileType = null; // 'design', 'file', or 'unknown'
|
|
19
|
+
this.executionContextId = null; // For Figma v39+ sandboxed context
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List all available Figma pages
|
|
24
|
+
*/
|
|
25
|
+
static async listPages() {
|
|
26
|
+
const port = getCdpPort();
|
|
27
|
+
const response = await fetch(`http://localhost:${port}/json`);
|
|
28
|
+
const pages = await response.json();
|
|
29
|
+
return pages
|
|
30
|
+
.filter(p => p.url && p.url.includes('figma.com'))
|
|
31
|
+
.map(p => ({ title: p.title, id: p.id, url: p.url, wsUrl: p.webSocketDebuggerUrl }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if Figma is running with debug port
|
|
36
|
+
*/
|
|
37
|
+
static async isConnected() {
|
|
38
|
+
try {
|
|
39
|
+
const port = getCdpPort();
|
|
40
|
+
const response = await fetch(`http://localhost:${port}/json`);
|
|
41
|
+
const pages = await response.json();
|
|
42
|
+
return pages.some(p => p.url && p.url.includes('figma.com'));
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Connect to a Figma design file
|
|
50
|
+
*/
|
|
51
|
+
async connect(pageTitle = null) {
|
|
52
|
+
const port = getCdpPort();
|
|
53
|
+
const response = await fetch(`http://localhost:${port}/json`);
|
|
54
|
+
const pages = await response.json();
|
|
55
|
+
|
|
56
|
+
// Find design/file pages (not feed, home, etc.)
|
|
57
|
+
// Use regex with trailing slash to avoid matching /files/ (feed/home pages)
|
|
58
|
+
const isDesignPage = (p) =>
|
|
59
|
+
p.url && /figma\.com\/(design|file)\//.test(p.url);
|
|
60
|
+
|
|
61
|
+
let page;
|
|
62
|
+
if (pageTitle) {
|
|
63
|
+
page = pages.find(p => p.title.includes(pageTitle) && isDesignPage(p));
|
|
64
|
+
} else {
|
|
65
|
+
page = pages.find(isDesignPage);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!page) {
|
|
69
|
+
throw new Error('No Figma design file open. Please open a design file in Figma Desktop.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.pageTitle = page.title;
|
|
73
|
+
this.pageUrl = page.url;
|
|
74
|
+
|
|
75
|
+
// Detect file type from URL
|
|
76
|
+
const typeMatch = page.url.match(/figma\.com\/(design|file)\//);
|
|
77
|
+
this.fileType = typeMatch ? typeMatch[1] : 'unknown';
|
|
78
|
+
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
this.ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
81
|
+
const executionContexts = [];
|
|
82
|
+
|
|
83
|
+
this.ws.on('open', async () => {
|
|
84
|
+
try {
|
|
85
|
+
// Enable Runtime to discover execution contexts (needed for Figma v39+)
|
|
86
|
+
await this.send('Runtime.enable');
|
|
87
|
+
|
|
88
|
+
// Give time for context events to arrive
|
|
89
|
+
await new Promise(r => setTimeout(r, 500));
|
|
90
|
+
|
|
91
|
+
// First try default context (works on older Figma versions)
|
|
92
|
+
const defaultCheck = await this.send('Runtime.evaluate', {
|
|
93
|
+
expression: 'typeof figma !== "undefined"',
|
|
94
|
+
returnByValue: true
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (defaultCheck.result?.result?.value === true) {
|
|
98
|
+
// figma is in default context (older Figma)
|
|
99
|
+
this.executionContextId = null;
|
|
100
|
+
resolve(this);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Figma v39+: search all execution contexts for figma
|
|
105
|
+
for (const ctx of executionContexts) {
|
|
106
|
+
try {
|
|
107
|
+
const check = await this.send('Runtime.evaluate', {
|
|
108
|
+
expression: 'typeof figma !== "undefined"',
|
|
109
|
+
contextId: ctx.id,
|
|
110
|
+
returnByValue: true
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (check.result?.result?.value === true) {
|
|
114
|
+
this.executionContextId = ctx.id;
|
|
115
|
+
resolve(this);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Context may have been destroyed, skip
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
reject(new Error('Could not find Figma execution context. Make sure a design file is open.'));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
reject(err);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.ws.on('message', (data) => {
|
|
130
|
+
const msg = JSON.parse(data);
|
|
131
|
+
|
|
132
|
+
// Collect execution contexts as they're created
|
|
133
|
+
if (msg.method === 'Runtime.executionContextCreated') {
|
|
134
|
+
executionContexts.push(msg.params.context);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (msg.id && this.callbacks.has(msg.id)) {
|
|
138
|
+
this.callbacks.get(msg.id)(msg);
|
|
139
|
+
this.callbacks.delete(msg.id);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.ws.on('error', reject);
|
|
144
|
+
|
|
145
|
+
setTimeout(() => reject(new Error('Connection timeout')), 15000);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
send(method, params = {}) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const id = ++this.msgId;
|
|
152
|
+
this.callbacks.set(id, resolve);
|
|
153
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Evaluate JavaScript in the Figma context
|
|
159
|
+
*/
|
|
160
|
+
async eval(expression) {
|
|
161
|
+
if (!this.ws) {
|
|
162
|
+
throw new Error('Not connected to Figma');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const params = {
|
|
166
|
+
expression,
|
|
167
|
+
returnByValue: true,
|
|
168
|
+
awaitPromise: true
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Use specific execution context if found (Figma v39+)
|
|
172
|
+
if (this.executionContextId) {
|
|
173
|
+
params.contextId = this.executionContextId;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = await this.send('Runtime.evaluate', params);
|
|
177
|
+
|
|
178
|
+
if (result.result?.exceptionDetails) {
|
|
179
|
+
const error = result.result.exceptionDetails;
|
|
180
|
+
// Get the actual error message - Figma puts detailed errors in exception.value
|
|
181
|
+
const errorValue = error.exception?.value || error.exception?.description || error.text || 'Evaluation error';
|
|
182
|
+
throw new Error(errorValue);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result.result?.result?.value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get current page info
|
|
190
|
+
*/
|
|
191
|
+
async getPageInfo() {
|
|
192
|
+
return await this.eval(`
|
|
193
|
+
(function() {
|
|
194
|
+
return {
|
|
195
|
+
name: figma.currentPage.name,
|
|
196
|
+
id: figma.currentPage.id,
|
|
197
|
+
childCount: figma.currentPage.children.length,
|
|
198
|
+
fileKey: figma.fileKey
|
|
199
|
+
};
|
|
200
|
+
})()
|
|
201
|
+
`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get canvas bounds (for smart positioning)
|
|
206
|
+
*/
|
|
207
|
+
async getCanvasBounds() {
|
|
208
|
+
return await this.eval(`
|
|
209
|
+
(function() {
|
|
210
|
+
const children = figma.currentPage.children;
|
|
211
|
+
if (children.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0, isEmpty: true };
|
|
212
|
+
|
|
213
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
214
|
+
children.forEach(n => {
|
|
215
|
+
if (n.x < minX) minX = n.x;
|
|
216
|
+
if (n.y < minY) minY = n.y;
|
|
217
|
+
if (n.x + n.width > maxX) maxX = n.x + n.width;
|
|
218
|
+
if (n.y + n.height > maxY) maxY = n.y + n.height;
|
|
219
|
+
});
|
|
220
|
+
return { minX, minY, maxX, maxY, isEmpty: false };
|
|
221
|
+
})()
|
|
222
|
+
`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* List all nodes on current page
|
|
227
|
+
*/
|
|
228
|
+
async listNodes(limit = 50) {
|
|
229
|
+
return await this.eval(`
|
|
230
|
+
figma.currentPage.children.slice(0, ${limit}).map(function(n) {
|
|
231
|
+
return {
|
|
232
|
+
id: n.id,
|
|
233
|
+
type: n.type,
|
|
234
|
+
name: n.name || '',
|
|
235
|
+
x: Math.round(n.x),
|
|
236
|
+
y: Math.round(n.y),
|
|
237
|
+
width: Math.round(n.width),
|
|
238
|
+
height: Math.round(n.height)
|
|
239
|
+
};
|
|
240
|
+
})
|
|
241
|
+
`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get all local variables
|
|
246
|
+
*/
|
|
247
|
+
async getVariables(type = null) {
|
|
248
|
+
const typeFilter = type ? `'${type}'` : 'null';
|
|
249
|
+
return await this.eval(`
|
|
250
|
+
(function() {
|
|
251
|
+
const vars = figma.variables.getLocalVariables(${typeFilter});
|
|
252
|
+
return vars.map(v => ({
|
|
253
|
+
id: v.id,
|
|
254
|
+
name: v.name,
|
|
255
|
+
resolvedType: v.resolvedType
|
|
256
|
+
}));
|
|
257
|
+
})()
|
|
258
|
+
`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get all variable collections
|
|
263
|
+
*/
|
|
264
|
+
async getCollections() {
|
|
265
|
+
return await this.eval(`
|
|
266
|
+
(function() {
|
|
267
|
+
const cols = figma.variables.getLocalVariableCollections();
|
|
268
|
+
return cols.map(c => ({
|
|
269
|
+
id: c.id,
|
|
270
|
+
name: c.name,
|
|
271
|
+
modes: c.modes,
|
|
272
|
+
variableIds: c.variableIds
|
|
273
|
+
}));
|
|
274
|
+
})()
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Render JSX-like syntax to Figma
|
|
280
|
+
*/
|
|
281
|
+
async render(jsx) {
|
|
282
|
+
// Parse JSX and generate Figma code (async for icon fetching)
|
|
283
|
+
const code = await this.parseJSX(jsx);
|
|
284
|
+
return await this.eval(code);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse multiple JSX strings into a SINGLE eval call (10x faster)
|
|
289
|
+
* Returns code that creates all frames and returns array of { id, name }
|
|
290
|
+
*/
|
|
291
|
+
parseJSXBatch(jsxArray, options = {}) {
|
|
292
|
+
const gap = options.gap || 40;
|
|
293
|
+
const vertical = options.vertical || false;
|
|
294
|
+
|
|
295
|
+
// Parse each JSX to get props and children
|
|
296
|
+
const parsed = jsxArray.map(jsx => {
|
|
297
|
+
const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
|
|
298
|
+
if (!openMatch) throw new Error('Invalid JSX: must start with <Frame>');
|
|
299
|
+
const propsStr = openMatch[1];
|
|
300
|
+
const startIdx = openMatch.index + openMatch[0].length;
|
|
301
|
+
const children = this.extractContent(jsx.slice(startIdx), 'Frame');
|
|
302
|
+
const props = this.parseProps(propsStr);
|
|
303
|
+
const childElements = this.parseChildren(children);
|
|
304
|
+
return { props, children: childElements };
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Collect all fonts needed
|
|
308
|
+
const allFonts = new Set();
|
|
309
|
+
let anyUsesVars = false;
|
|
310
|
+
|
|
311
|
+
parsed.forEach(({ props, children }) => {
|
|
312
|
+
const bg = props.bg || props.fill || '#ffffff';
|
|
313
|
+
const stroke = props.stroke || null;
|
|
314
|
+
if (this.isVarRef(bg)) anyUsesVars = true;
|
|
315
|
+
if (stroke && this.isVarRef(stroke)) anyUsesVars = true;
|
|
316
|
+
|
|
317
|
+
const collectFonts = (items) => {
|
|
318
|
+
items.forEach(item => {
|
|
319
|
+
if (item._type === 'text') {
|
|
320
|
+
const weight = item.weight || 'regular';
|
|
321
|
+
const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
|
|
322
|
+
allFonts.add(style);
|
|
323
|
+
if (item.color && this.isVarRef(item.color)) anyUsesVars = true;
|
|
324
|
+
} else if (item._type === 'frame') {
|
|
325
|
+
if (item.bg && this.isVarRef(item.bg)) anyUsesVars = true;
|
|
326
|
+
if (item.stroke && this.isVarRef(item.stroke)) anyUsesVars = true;
|
|
327
|
+
if (item._children) collectFonts(item._children);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
collectFonts(children);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Font caching: only load fonts not yet loaded in this session
|
|
335
|
+
const fontStyles = Array.from(allFonts);
|
|
336
|
+
const fontLoads = fontStyles.length > 0
|
|
337
|
+
? `
|
|
338
|
+
if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
|
|
339
|
+
const fontsToLoad = ${JSON.stringify(fontStyles)}.filter(s => !globalThis.__loadedFonts.has(s));
|
|
340
|
+
if (fontsToLoad.length > 0) {
|
|
341
|
+
await Promise.all(fontsToLoad.map(s => figma.loadFontAsync({family:'Inter',style:s})));
|
|
342
|
+
fontsToLoad.forEach(s => globalThis.__loadedFonts.add(s));
|
|
343
|
+
}
|
|
344
|
+
`
|
|
345
|
+
: `
|
|
346
|
+
if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
|
|
347
|
+
if (!globalThis.__loadedFonts.has('Regular')) {
|
|
348
|
+
await figma.loadFontAsync({family:'Inter',style:'Regular'});
|
|
349
|
+
globalThis.__loadedFonts.add('Regular');
|
|
350
|
+
}
|
|
351
|
+
`;
|
|
352
|
+
|
|
353
|
+
// Variable caching: reuse loaded vars across calls
|
|
354
|
+
const varLoadCode = anyUsesVars ? `
|
|
355
|
+
if (!globalThis.__varsCache || Date.now() - (globalThis.__varsCacheTime || 0) > 30000) {
|
|
356
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
357
|
+
globalThis.__varsCache = {};
|
|
358
|
+
for (const col of collections) {
|
|
359
|
+
if (!col.name.startsWith('shadcn')) continue;
|
|
360
|
+
for (const id of col.variableIds) {
|
|
361
|
+
const v = await figma.variables.getVariableByIdAsync(id);
|
|
362
|
+
if (v) globalThis.__varsCache[v.name] = v;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
globalThis.__varsCacheTime = Date.now();
|
|
366
|
+
}
|
|
367
|
+
const vars = globalThis.__varsCache;
|
|
368
|
+
const boundFill = (variable) => figma.variables.setBoundVariableForPaint(
|
|
369
|
+
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, 'color', variable
|
|
370
|
+
);
|
|
371
|
+
` : '';
|
|
372
|
+
|
|
373
|
+
// Generate code for each frame
|
|
374
|
+
const framesCodes = parsed.map(({ props, children }, frameIdx) => {
|
|
375
|
+
const name = props.name || 'Frame';
|
|
376
|
+
const hasExplicitWidth = props.w !== undefined || props.width !== undefined;
|
|
377
|
+
const width = props.w || props.width || 320;
|
|
378
|
+
const hasExplicitHeight = props.h !== undefined || props.height !== undefined;
|
|
379
|
+
const height = props.h || props.height || 200;
|
|
380
|
+
const bg = props.bg || props.fill || '#ffffff';
|
|
381
|
+
const stroke = props.stroke || null;
|
|
382
|
+
const rounded = props.rounded || props.radius || 0;
|
|
383
|
+
const flex = props.flex || 'col';
|
|
384
|
+
const itemGap = props.gap || 0;
|
|
385
|
+
const p = props.p || props.padding || 0;
|
|
386
|
+
const px = props.px || p;
|
|
387
|
+
const py = props.py || p;
|
|
388
|
+
const align = props.items || props.align || 'MIN';
|
|
389
|
+
const justify = props.justify || 'MIN';
|
|
390
|
+
const wrap = props.wrap === true || props.wrap === 'true';
|
|
391
|
+
const wrapGap = Number(props.wrapGap || props.counterAxisSpacing || 0);
|
|
392
|
+
const hug = props.hug || '';
|
|
393
|
+
const hugWidth = hug === 'both' || hug === 'w' || hug === 'width';
|
|
394
|
+
const hugHeight = hug === 'both' || hug === 'h' || hug === 'height';
|
|
395
|
+
const clip = props.clip === 'true' || props.clip === true;
|
|
396
|
+
|
|
397
|
+
const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
|
|
398
|
+
const alignVal = alignMap[align] || 'MIN';
|
|
399
|
+
const justifyVal = alignMap[justify] || 'MIN';
|
|
400
|
+
|
|
401
|
+
const fillCode = this.generateFillCode(bg, `f${frameIdx}`);
|
|
402
|
+
const strokeCode = stroke ? this.generateStrokeCode(stroke, `f${frameIdx}`) : { code: '' };
|
|
403
|
+
|
|
404
|
+
// Generate child code
|
|
405
|
+
let childCounter = 0;
|
|
406
|
+
const generateChildCode = (items, parentVar, parentFlex) => {
|
|
407
|
+
return items.map(item => {
|
|
408
|
+
const idx = `${frameIdx}_${childCounter++}`;
|
|
409
|
+
if (item._type === 'text') {
|
|
410
|
+
const weight = item.weight || 'regular';
|
|
411
|
+
const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
|
|
412
|
+
const size = item.size || 14;
|
|
413
|
+
const color = item.color || '#000000';
|
|
414
|
+
const fillWidth = item.w === 'fill';
|
|
415
|
+
const textAlign = item.align || 'left';
|
|
416
|
+
const textAlignVal = textAlign === 'center' ? 'CENTER' : textAlign === 'right' ? 'RIGHT' : 'LEFT';
|
|
417
|
+
const textFillCode = this.generateFillCode(color, `el${idx}`);
|
|
418
|
+
return `
|
|
419
|
+
const el${idx} = figma.createText();
|
|
420
|
+
el${idx}.fontName = {family:'Inter',style:'${style}'};
|
|
421
|
+
el${idx}.characters = ${JSON.stringify(item.content || '')};
|
|
422
|
+
el${idx}.fontSize = ${size};
|
|
423
|
+
${textFillCode.code}
|
|
424
|
+
el${idx}.textAlignHorizontal = '${textAlignVal}';
|
|
425
|
+
${parentVar}.appendChild(el${idx});
|
|
426
|
+
${fillWidth ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}`;
|
|
427
|
+
} else if (item._type === 'frame') {
|
|
428
|
+
const fName = item.name || 'Frame';
|
|
429
|
+
const fillW = item.w === 'fill';
|
|
430
|
+
const fillH = item.h === 'fill';
|
|
431
|
+
const hasExplicitW = (item.w !== undefined || item.width !== undefined) && !fillW;
|
|
432
|
+
const hasExplicitH = (item.h !== undefined || item.height !== undefined) && !fillH;
|
|
433
|
+
const fWidth = hasExplicitW ? (item.w || item.width) : 100;
|
|
434
|
+
const fHeight = hasExplicitH ? (item.h || item.height) : 40;
|
|
435
|
+
// Frames without explicit bg should be transparent (not white)
|
|
436
|
+
const fBg = item.bg || item.fill || null;
|
|
437
|
+
const fStroke = item.stroke || null;
|
|
438
|
+
const fRounded = item.rounded || item.radius || 0;
|
|
439
|
+
const fFlex = item.flex || 'row'; // Default to row (always auto-layout like single render)
|
|
440
|
+
const fGap = item.gap || 0;
|
|
441
|
+
const fP = item.p !== undefined ? item.p : 0;
|
|
442
|
+
const fPx = item.px !== undefined ? item.px : fP;
|
|
443
|
+
const fPy = item.py !== undefined ? item.py : fP;
|
|
444
|
+
const fPt = item.pt !== undefined ? Number(item.pt) : Number(fPy);
|
|
445
|
+
const fPr = item.pr !== undefined ? Number(item.pr) : Number(fPx);
|
|
446
|
+
const fPb = item.pb !== undefined ? Number(item.pb) : Number(fPy);
|
|
447
|
+
const fPl = item.pl !== undefined ? Number(item.pl) : Number(fPx);
|
|
448
|
+
const fGrow = item.grow || 0;
|
|
449
|
+
const fJustify = item.justify || 'center';
|
|
450
|
+
const fItems = item.items || 'center';
|
|
451
|
+
const justifyMap = { start: 'MIN', center: 'CENTER', end: 'MAX', between: 'SPACE_BETWEEN' };
|
|
452
|
+
const fJustifyVal = justifyMap[fJustify] || 'CENTER';
|
|
453
|
+
const fItemsVal = alignMap[fItems] || 'CENTER';
|
|
454
|
+
const fFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
|
|
455
|
+
const fStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`) : { code: '' };
|
|
456
|
+
const nestedChildren = item._children ? generateChildCode(item._children, `el${idx}`, fFlex) : '';
|
|
457
|
+
return `
|
|
458
|
+
const el${idx} = figma.createFrame();
|
|
459
|
+
el${idx}.name = ${JSON.stringify(fName)};
|
|
460
|
+
el${idx}.layoutMode = '${fFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
461
|
+
el${idx}.primaryAxisSizingMode = '${hasExplicitW ? 'FIXED' : 'AUTO'}';
|
|
462
|
+
el${idx}.counterAxisSizingMode = '${hasExplicitH ? 'FIXED' : 'AUTO'}';
|
|
463
|
+
${hasExplicitW || hasExplicitH ? `el${idx}.resize(${fWidth}, ${fHeight});` : ''}
|
|
464
|
+
el${idx}.cornerRadius = ${fRounded};
|
|
465
|
+
${fFillCode.code}
|
|
466
|
+
${fStrokeCode.code}
|
|
467
|
+
el${idx}.itemSpacing = ${fGap};
|
|
468
|
+
el${idx}.paddingTop = ${fPt};
|
|
469
|
+
el${idx}.paddingBottom = ${fPb};
|
|
470
|
+
el${idx}.paddingLeft = ${fPl};
|
|
471
|
+
el${idx}.paddingRight = ${fPr};
|
|
472
|
+
el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
|
|
473
|
+
el${idx}.counterAxisAlignItems = '${fItemsVal}';
|
|
474
|
+
${parentVar}.appendChild(el${idx});
|
|
475
|
+
${fillW ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}
|
|
476
|
+
${fillH ? `el${idx}.layoutSizingVertical = 'FILL';` : ''}
|
|
477
|
+
${fGrow && parentFlex === 'row' ? `el${idx}.layoutSizingHorizontal = 'FILL';` : ''}
|
|
478
|
+
${fGrow && parentFlex === 'col' ? `el${idx}.layoutSizingVertical = 'FILL';` : ''}
|
|
479
|
+
${nestedChildren}`;
|
|
480
|
+
}
|
|
481
|
+
return '';
|
|
482
|
+
}).join('\n');
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const childCode = generateChildCode(children, `f${frameIdx}`, flex);
|
|
486
|
+
|
|
487
|
+
return `
|
|
488
|
+
const f${frameIdx} = figma.createFrame();
|
|
489
|
+
f${frameIdx}.name = ${JSON.stringify(name)};
|
|
490
|
+
f${frameIdx}.resize(${width}, ${height});
|
|
491
|
+
f${frameIdx}.x = posX;
|
|
492
|
+
f${frameIdx}.y = posY;
|
|
493
|
+
f${frameIdx}.cornerRadius = ${rounded};
|
|
494
|
+
${fillCode.code}
|
|
495
|
+
${strokeCode.code}
|
|
496
|
+
f${frameIdx}.layoutMode = '${flex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
497
|
+
${wrap && flex === 'row' ? `f${frameIdx}.layoutWrap = 'WRAP';` : ''}
|
|
498
|
+
f${frameIdx}.itemSpacing = ${itemGap};
|
|
499
|
+
f${frameIdx}.paddingTop = f${frameIdx}.paddingBottom = ${py};
|
|
500
|
+
f${frameIdx}.paddingLeft = f${frameIdx}.paddingRight = ${px};
|
|
501
|
+
f${frameIdx}.primaryAxisAlignItems = '${justifyVal}';
|
|
502
|
+
f${frameIdx}.counterAxisAlignItems = '${alignVal}';
|
|
503
|
+
f${frameIdx}.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
|
|
504
|
+
f${frameIdx}.counterAxisSizingMode = '${flex === 'col' ? (hugWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED') : (hugHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED')}';
|
|
505
|
+
${wrap && flex === 'row' && wrapGap > 0 ? `f${frameIdx}.counterAxisSpacing = ${wrapGap};` : ''}
|
|
506
|
+
f${frameIdx}.clipsContent = ${clip};
|
|
507
|
+
${childCode}
|
|
508
|
+
results.push({ id: f${frameIdx}.id, name: f${frameIdx}.name, width: f${frameIdx}.width, height: f${frameIdx}.height });
|
|
509
|
+
${vertical ? `posY += f${frameIdx}.height + ${gap};` : `posX += f${frameIdx}.width + ${gap};`}
|
|
510
|
+
`;
|
|
511
|
+
}).join('\n');
|
|
512
|
+
|
|
513
|
+
return `
|
|
514
|
+
(async function() {
|
|
515
|
+
${fontLoads}
|
|
516
|
+
${varLoadCode}
|
|
517
|
+
|
|
518
|
+
// Calculate start position
|
|
519
|
+
let posX = 0, posY = 100;
|
|
520
|
+
const children = figma.currentPage.children;
|
|
521
|
+
if (children.length > 0) {
|
|
522
|
+
let maxRight = 0;
|
|
523
|
+
children.forEach(n => {
|
|
524
|
+
const right = n.x + (n.width || 0);
|
|
525
|
+
if (right > maxRight) maxRight = right;
|
|
526
|
+
});
|
|
527
|
+
posX = Math.round(maxRight + 100);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const results = [];
|
|
531
|
+
${framesCodes}
|
|
532
|
+
return results;
|
|
533
|
+
})()
|
|
534
|
+
`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Parse JSX-like syntax to Figma Plugin API code
|
|
539
|
+
*/
|
|
540
|
+
async parseJSX(jsx) {
|
|
541
|
+
// Find opening Frame tag
|
|
542
|
+
const openMatch = jsx.match(/<Frame\s+([^>]*)>/);
|
|
543
|
+
if (!openMatch) {
|
|
544
|
+
throw new Error('Invalid JSX: must start with <Frame>');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const propsStr = openMatch[1];
|
|
548
|
+
const startIdx = openMatch.index + openMatch[0].length;
|
|
549
|
+
|
|
550
|
+
// Find matching closing tag by counting open/close tags
|
|
551
|
+
const children = this.extractContent(jsx.slice(startIdx), 'Frame');
|
|
552
|
+
|
|
553
|
+
// Parse props
|
|
554
|
+
const props = this.parseProps(propsStr);
|
|
555
|
+
|
|
556
|
+
// Parse children
|
|
557
|
+
const childElements = this.parseChildren(children);
|
|
558
|
+
|
|
559
|
+
// Warn if children content exists but nothing was parsed
|
|
560
|
+
const trimmedChildren = children.trim();
|
|
561
|
+
if (trimmedChildren && childElements.length === 0) {
|
|
562
|
+
console.warn('[render] Warning: Frame has content but no elements were parsed.');
|
|
563
|
+
console.warn('[render] Content:', trimmedChildren.slice(0, 200) + (trimmedChildren.length > 200 ? '...' : ''));
|
|
564
|
+
console.warn('[render] Supported elements: <Frame>, <Text>, <Rectangle>, <Rect>, <Image>, <Icon>');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Pre-fetch any icon SVGs before code generation
|
|
568
|
+
const iconSvgMap = await this.prefetchIconSvgs(childElements);
|
|
569
|
+
|
|
570
|
+
// Generate code
|
|
571
|
+
return this.generateCode(props, childElements, iconSvgMap);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Extract content between matching open/close tags
|
|
576
|
+
*/
|
|
577
|
+
extractContent(str, tagName) {
|
|
578
|
+
let depth = 1;
|
|
579
|
+
let i = 0;
|
|
580
|
+
const closeTag = `</${tagName}>`;
|
|
581
|
+
|
|
582
|
+
while (i < str.length && depth > 0) {
|
|
583
|
+
const remaining = str.slice(i);
|
|
584
|
+
|
|
585
|
+
if (remaining.startsWith(closeTag)) {
|
|
586
|
+
depth--;
|
|
587
|
+
if (depth === 0) {
|
|
588
|
+
return str.slice(0, i);
|
|
589
|
+
}
|
|
590
|
+
i += closeTag.length;
|
|
591
|
+
} else if (remaining.startsWith(`<${tagName} `) || remaining.startsWith(`<${tagName}>`)) {
|
|
592
|
+
// Check if this is a self-closing tag (e.g. <Frame ... />)
|
|
593
|
+
const selfCloseCheck = remaining.match(new RegExp(`^<${tagName}(?:\\s[^>]*?)?\\s*\\/>`));
|
|
594
|
+
if (selfCloseCheck) {
|
|
595
|
+
// Self-closing: skip entirely, don't change depth
|
|
596
|
+
i += selfCloseCheck[0].length;
|
|
597
|
+
} else {
|
|
598
|
+
depth++;
|
|
599
|
+
i++;
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
i++;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return str;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Collect all icon names from parsed children tree
|
|
611
|
+
*/
|
|
612
|
+
collectIconNames(items) {
|
|
613
|
+
const names = new Set();
|
|
614
|
+
for (const item of items) {
|
|
615
|
+
if (item._type === 'icon' && item.name && item.name.includes(':')) {
|
|
616
|
+
names.add(item.name);
|
|
617
|
+
}
|
|
618
|
+
if (item._children) {
|
|
619
|
+
for (const n of this.collectIconNames(item._children)) {
|
|
620
|
+
names.add(n);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return names;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Pre-fetch SVGs for all icons in the tree from Iconify API
|
|
629
|
+
* Returns map: { "lucide:chevron-left": "<svg...>" }
|
|
630
|
+
*/
|
|
631
|
+
async prefetchIconSvgs(children) {
|
|
632
|
+
const iconNames = this.collectIconNames(children);
|
|
633
|
+
if (iconNames.size === 0) return {};
|
|
634
|
+
|
|
635
|
+
const svgMap = {};
|
|
636
|
+
const fetches = [...iconNames].map(async (iconName) => {
|
|
637
|
+
try {
|
|
638
|
+
const [prefix, name] = iconName.split(':');
|
|
639
|
+
const response = await fetch(`https://api.iconify.design/${prefix}/${name}.svg?width=24&height=24`);
|
|
640
|
+
if (response.ok) {
|
|
641
|
+
svgMap[iconName] = await response.text();
|
|
642
|
+
}
|
|
643
|
+
} catch (e) {
|
|
644
|
+
// Silently fall back to placeholder
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
await Promise.all(fetches);
|
|
648
|
+
return svgMap;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
parseProps(propsStr) {
|
|
652
|
+
const props = {};
|
|
653
|
+
|
|
654
|
+
// Match name="value" or name={value}
|
|
655
|
+
const regex = /(\w+)=(?:"([^"]*)"|{([^}]*)})/g;
|
|
656
|
+
let match;
|
|
657
|
+
|
|
658
|
+
while ((match = regex.exec(propsStr)) !== null) {
|
|
659
|
+
const key = match[1];
|
|
660
|
+
const value = match[2] !== undefined ? match[2] : match[3];
|
|
661
|
+
props[key] = value;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return props;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
parseChildren(childrenStr) {
|
|
668
|
+
const children = [];
|
|
669
|
+
const frameRanges = [];
|
|
670
|
+
|
|
671
|
+
// First: find all open/close Frame elements (recursive, handles nesting)
|
|
672
|
+
const frameOpenRegex = /<Frame(?:\s+([^>]*?))?>/g;
|
|
673
|
+
let match;
|
|
674
|
+
|
|
675
|
+
while ((match = frameOpenRegex.exec(childrenStr)) !== null) {
|
|
676
|
+
// Skip self-closing frames (regex matches /> because > is part of />)
|
|
677
|
+
if (match[0].endsWith('/>')) continue;
|
|
678
|
+
|
|
679
|
+
const frameProps = this.parseProps(match[1] || '');
|
|
680
|
+
frameProps._type = 'frame';
|
|
681
|
+
frameProps._index = match.index;
|
|
682
|
+
|
|
683
|
+
// Get content between opening and matching closing tag
|
|
684
|
+
const afterOpen = childrenStr.slice(match.index + match[0].length);
|
|
685
|
+
const innerContent = this.extractContent(afterOpen, 'Frame');
|
|
686
|
+
|
|
687
|
+
// Calculate full frame length
|
|
688
|
+
const fullLength = match[0].length + innerContent.length + '</Frame>'.length;
|
|
689
|
+
|
|
690
|
+
// Recursively parse children of nested frame
|
|
691
|
+
frameProps._children = this.parseChildren(innerContent);
|
|
692
|
+
children.push(frameProps);
|
|
693
|
+
|
|
694
|
+
// Mark this range as consumed
|
|
695
|
+
frameRanges.push({ start: match.index, end: match.index + fullLength });
|
|
696
|
+
|
|
697
|
+
// Move regex past this frame to avoid re-matching nested frames
|
|
698
|
+
frameOpenRegex.lastIndex = match.index + fullLength;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Then: parse self-closing Frame elements NOT inside open/close frames
|
|
702
|
+
const frameSelfCloseRegex = /<Frame(?:\s+([^>]*?))?\s*\/>/g;
|
|
703
|
+
|
|
704
|
+
while ((match = frameSelfCloseRegex.exec(childrenStr)) !== null) {
|
|
705
|
+
// Skip if inside an already-consumed open/close frame
|
|
706
|
+
const insideFrame = frameRanges.some(r => match.index >= r.start && match.index < r.end);
|
|
707
|
+
if (insideFrame) continue;
|
|
708
|
+
|
|
709
|
+
const frameProps = this.parseProps(match[1] || '');
|
|
710
|
+
frameProps._type = 'frame';
|
|
711
|
+
frameProps._index = match.index;
|
|
712
|
+
frameProps._children = [];
|
|
713
|
+
children.push(frameProps);
|
|
714
|
+
frameRanges.push({ start: match.index, end: match.index + match[0].length });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Parse Slot elements (with children) - must be before Text parsing
|
|
718
|
+
// Slots can have children (default content)
|
|
719
|
+
const slotOpenRegex = /<Slot(?:\s+([^>]*?))?>/g;
|
|
720
|
+
while ((match = slotOpenRegex.exec(childrenStr)) !== null) {
|
|
721
|
+
const idx = match.index;
|
|
722
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
723
|
+
if (!insideFrame) {
|
|
724
|
+
const slotProps = this.parseProps(match[1] || '');
|
|
725
|
+
slotProps._type = 'slot';
|
|
726
|
+
slotProps._index = idx;
|
|
727
|
+
|
|
728
|
+
// Get content between opening and matching closing tag
|
|
729
|
+
const afterOpen = childrenStr.slice(match.index + match[0].length);
|
|
730
|
+
const innerContent = this.extractContent(afterOpen, 'Slot');
|
|
731
|
+
const fullLength = match[0].length + innerContent.length + '</Slot>'.length;
|
|
732
|
+
|
|
733
|
+
// Recursively parse children of slot (default content)
|
|
734
|
+
slotProps._children = this.parseChildren(innerContent);
|
|
735
|
+
children.push(slotProps);
|
|
736
|
+
|
|
737
|
+
// Mark this range as consumed (so text/other elements inside are skipped)
|
|
738
|
+
frameRanges.push({ start: idx, end: idx + fullLength });
|
|
739
|
+
slotOpenRegex.lastIndex = idx + fullLength;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Parse self-closing Slot elements
|
|
744
|
+
const slotSelfCloseRegex = /<Slot(?:\s+([^/]*?))?\s*\/>/g;
|
|
745
|
+
while ((match = slotSelfCloseRegex.exec(childrenStr)) !== null) {
|
|
746
|
+
const idx = match.index;
|
|
747
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
748
|
+
if (!insideFrame) {
|
|
749
|
+
const slotProps = this.parseProps(match[1] || '');
|
|
750
|
+
slotProps._type = 'slot';
|
|
751
|
+
slotProps._index = idx;
|
|
752
|
+
slotProps._children = [];
|
|
753
|
+
children.push(slotProps);
|
|
754
|
+
// Mark as consumed
|
|
755
|
+
frameRanges.push({ start: idx, end: idx + match[0].length });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Parse Text elements, but skip those inside nested Frames/Slots
|
|
760
|
+
// Use (?:\s+([^>]*?))? to allow Text with or without attributes
|
|
761
|
+
const textRegex = /<Text(?:\s+([^>]*?))?>([^<]*)<\/Text>/g;
|
|
762
|
+
while ((match = textRegex.exec(childrenStr)) !== null) {
|
|
763
|
+
const idx = match.index;
|
|
764
|
+
// Check if this text is inside a nested frame
|
|
765
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
766
|
+
if (!insideFrame) {
|
|
767
|
+
const textProps = this.parseProps(match[1] || '');
|
|
768
|
+
textProps._type = 'text';
|
|
769
|
+
textProps.content = match[2];
|
|
770
|
+
textProps._index = idx;
|
|
771
|
+
children.push(textProps);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Parse Rectangle elements (self-closing)
|
|
776
|
+
// Use (?:\s+([^/]*?))? to allow Rect with or without attributes
|
|
777
|
+
const rectRegex = /<(?:Rectangle|Rect)(?:\s+([^/]*?))?\s*\/>/g;
|
|
778
|
+
while ((match = rectRegex.exec(childrenStr)) !== null) {
|
|
779
|
+
const idx = match.index;
|
|
780
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
781
|
+
if (!insideFrame) {
|
|
782
|
+
const rectProps = this.parseProps(match[1] || '');
|
|
783
|
+
rectProps._type = 'rect';
|
|
784
|
+
rectProps._index = idx;
|
|
785
|
+
children.push(rectProps);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Parse Image elements (self-closing) - creates placeholder rectangle
|
|
790
|
+
const imageRegex = /<Image\s+([^/]*)\s*\/>/g;
|
|
791
|
+
while ((match = imageRegex.exec(childrenStr)) !== null) {
|
|
792
|
+
const idx = match.index;
|
|
793
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
794
|
+
if (!insideFrame) {
|
|
795
|
+
const imgProps = this.parseProps(match[1]);
|
|
796
|
+
imgProps._type = 'image';
|
|
797
|
+
imgProps._index = idx;
|
|
798
|
+
children.push(imgProps);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Parse Icon elements (self-closing) - creates placeholder
|
|
803
|
+
const iconRegex = /<Icon\s+([^/]*)\s*\/>/g;
|
|
804
|
+
while ((match = iconRegex.exec(childrenStr)) !== null) {
|
|
805
|
+
const idx = match.index;
|
|
806
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
807
|
+
if (!insideFrame) {
|
|
808
|
+
const iconProps = this.parseProps(match[1]);
|
|
809
|
+
iconProps._type = 'icon';
|
|
810
|
+
iconProps._index = idx;
|
|
811
|
+
children.push(iconProps);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Parse Instance elements (self-closing) - creates component instance
|
|
816
|
+
const instanceRegex = /<Instance\s+([^/]*)\s*\/>/g;
|
|
817
|
+
while ((match = instanceRegex.exec(childrenStr)) !== null) {
|
|
818
|
+
const idx = match.index;
|
|
819
|
+
const insideFrame = frameRanges.some(r => idx >= r.start && idx < r.end);
|
|
820
|
+
if (!insideFrame) {
|
|
821
|
+
const instProps = this.parseProps(match[1]);
|
|
822
|
+
instProps._type = 'instance';
|
|
823
|
+
instProps._index = idx;
|
|
824
|
+
children.push(instProps);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Sort by original position in JSX to maintain order
|
|
829
|
+
children.sort((a, b) => a._index - b._index);
|
|
830
|
+
|
|
831
|
+
return children;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
generateCode(props, children, iconSvgMap = {}) {
|
|
835
|
+
const name = props.name || 'Frame';
|
|
836
|
+
const rawWidth = props.w || props.width;
|
|
837
|
+
const rawHeight = props.h || props.height;
|
|
838
|
+
const hasExplicitWidth = props.w !== undefined || props.width !== undefined;
|
|
839
|
+
const hasExplicitHeight = props.h !== undefined || props.height !== undefined;
|
|
840
|
+
// Support w="fill" / h="fill" for root frame
|
|
841
|
+
const fillWidth = rawWidth === 'fill';
|
|
842
|
+
const fillHeight = rawHeight === 'fill';
|
|
843
|
+
const width = fillWidth ? 100 : (rawWidth || 320);
|
|
844
|
+
const height = fillHeight ? 100 : (rawHeight || 200);
|
|
845
|
+
const bg = props.bg || props.fill || '#ffffff';
|
|
846
|
+
const stroke = props.stroke || null;
|
|
847
|
+
const strokeWidth = props.strokeWidth || 1;
|
|
848
|
+
const strokeAlignProp = props.strokeAlign || null;
|
|
849
|
+
const rounded = props.rounded || props.radius || 0;
|
|
850
|
+
const flex = props.flex || 'col';
|
|
851
|
+
const gap = props.gap || 0;
|
|
852
|
+
const p = props.p || props.padding || 0;
|
|
853
|
+
const px = props.px || p;
|
|
854
|
+
const py = props.py || p;
|
|
855
|
+
const align = props.items || props.align || 'MIN';
|
|
856
|
+
const justify = props.justify || 'MIN';
|
|
857
|
+
const useSmartPos = props.x === undefined;
|
|
858
|
+
const explicitX = props.x || 0;
|
|
859
|
+
const y = props.y || 0;
|
|
860
|
+
// New: clip defaults to false (don't clip auto-layout overflow). overflow="hidden" also sets clip.
|
|
861
|
+
const clip = props.clip === 'true' || props.clip === true || props.overflow === 'hidden';
|
|
862
|
+
// New: hug for auto-sizing (hug="both" | "w" | "h" | "width" | "height")
|
|
863
|
+
const hug = props.hug || '';
|
|
864
|
+
const hugWidth = hug === 'both' || hug === 'w' || hug === 'width';
|
|
865
|
+
const hugHeight = hug === 'both' || hug === 'h' || hug === 'height';
|
|
866
|
+
// New: wrap and wrapGap for horizontal layouts
|
|
867
|
+
const wrap = props.wrap === true || props.wrap === 'true';
|
|
868
|
+
const wrapGap = Number(props.wrapGap || props.counterAxisSpacing || 0);
|
|
869
|
+
|
|
870
|
+
// Track variable usage for fast binding
|
|
871
|
+
let usesVars = false;
|
|
872
|
+
const checkVarUsage = (value) => {
|
|
873
|
+
if (this.isVarRef(value)) usesVars = true;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// Check root frame for var usage
|
|
877
|
+
checkVarUsage(bg);
|
|
878
|
+
if (stroke) checkVarUsage(stroke);
|
|
879
|
+
|
|
880
|
+
// Collect all fonts and check variable usage recursively
|
|
881
|
+
const fonts = new Set();
|
|
882
|
+
const collectFontsAndVars = (items) => {
|
|
883
|
+
items.forEach(item => {
|
|
884
|
+
if (item._type === 'text') {
|
|
885
|
+
const weight = item.weight || 'regular';
|
|
886
|
+
const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
|
|
887
|
+
fonts.add(style);
|
|
888
|
+
const color = item.color || '#000000';
|
|
889
|
+
checkVarUsage(color);
|
|
890
|
+
} else if (item._type === 'frame') {
|
|
891
|
+
const fBg = item.bg || item.fill || null;
|
|
892
|
+
const fStroke = item.stroke || null;
|
|
893
|
+
if (fBg) checkVarUsage(fBg);
|
|
894
|
+
if (fStroke) checkVarUsage(fStroke);
|
|
895
|
+
if (item._children) collectFontsAndVars(item._children);
|
|
896
|
+
} else if (item._type === 'rect' || item._type === 'image' || item._type === 'icon') {
|
|
897
|
+
const itemBg = item.bg || item.fill || item.color || item.c || '#e4e4e7';
|
|
898
|
+
checkVarUsage(itemBg);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
collectFontsAndVars(children);
|
|
903
|
+
|
|
904
|
+
// Font caching for single render
|
|
905
|
+
const fontStyles = Array.from(fonts);
|
|
906
|
+
|
|
907
|
+
// Generate child code recursively
|
|
908
|
+
let childCounter = 0;
|
|
909
|
+
const generateChildCode = (items, parentVar, parentFlex) => {
|
|
910
|
+
return items.map(item => {
|
|
911
|
+
const idx = childCounter++;
|
|
912
|
+
if (item._type === 'text') {
|
|
913
|
+
const weight = item.weight || 'regular';
|
|
914
|
+
const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : weight === 'semibold' ? 'Semi Bold' : 'Regular';
|
|
915
|
+
const size = item.size || 14;
|
|
916
|
+
const color = item.color || '#000000';
|
|
917
|
+
const fillWidth = item.w === 'fill';
|
|
918
|
+
const textFillCode = this.generateFillCode(color, `el${idx}`);
|
|
919
|
+
|
|
920
|
+
return `
|
|
921
|
+
__currentNode = 'Text: ${item.content.substring(0, 30).replace(/'/g, "\\'")}';
|
|
922
|
+
const el${idx} = figma.createText();
|
|
923
|
+
el${idx}.fontName = {family:'Inter',style:'${style}'};
|
|
924
|
+
el${idx}.fontSize = ${size};
|
|
925
|
+
el${idx}.characters = ${JSON.stringify(item.content)};
|
|
926
|
+
${textFillCode.code}
|
|
927
|
+
${parentVar}.appendChild(el${idx});
|
|
928
|
+
${fillWidth ? `el${idx}.layoutSizingHorizontal = 'FILL'; el${idx}.textAutoResize = 'HEIGHT';` : ''}`;
|
|
929
|
+
} else if (item._type === 'frame') {
|
|
930
|
+
// Nested frame (button, etc.)
|
|
931
|
+
const fName = item.name || 'Nested Frame';
|
|
932
|
+
const fBg = item.bg || item.fill || null;
|
|
933
|
+
const fStroke = item.stroke || null;
|
|
934
|
+
const fStrokeWidth = item.strokeWidth || 1;
|
|
935
|
+
const fStrokeAlign = item.strokeAlign || null;
|
|
936
|
+
const fRounded = item.rounded || item.radius || 0;
|
|
937
|
+
const fFlex = item.flex || 'row';
|
|
938
|
+
const fGap = item.gap || 0;
|
|
939
|
+
// Default padding is 0 (only set padding when explicitly specified)
|
|
940
|
+
const fP = item.p !== undefined ? item.p : (item.padding !== undefined ? item.padding : null);
|
|
941
|
+
const fPx = item.px !== undefined ? item.px : (fP !== null ? fP : 0);
|
|
942
|
+
const fPy = item.py !== undefined ? item.py : (fP !== null ? fP : 0);
|
|
943
|
+
// Individual padding overrides (pt, pr, pb, pl)
|
|
944
|
+
const fPt = item.pt !== undefined ? Number(item.pt) : Number(fPy);
|
|
945
|
+
const fPr = item.pr !== undefined ? Number(item.pr) : Number(fPx);
|
|
946
|
+
const fPb = item.pb !== undefined ? Number(item.pb) : Number(fPy);
|
|
947
|
+
const fPl = item.pl !== undefined ? Number(item.pl) : Number(fPx);
|
|
948
|
+
const fAlign = item.align || 'center';
|
|
949
|
+
const fJustify = item.justify || 'center';
|
|
950
|
+
// Clip defaults to false for nested frames (overflow="hidden" also sets clip)
|
|
951
|
+
const fClip = item.clip === 'true' || item.clip === true || item.overflow === 'hidden';
|
|
952
|
+
|
|
953
|
+
// NEW: wrap, wrapGap, grow, position props
|
|
954
|
+
const fWrap = item.wrap === true || item.wrap === 'true';
|
|
955
|
+
const fWrapGap = Number(item.wrapGap || item.counterAxisSpacing || 0);
|
|
956
|
+
const fGrow = item.grow !== undefined ? Number(item.grow) : null;
|
|
957
|
+
const fPosition = item.position || 'auto';
|
|
958
|
+
const fAbsoluteX = item.x !== undefined ? Number(item.x) : 0;
|
|
959
|
+
const fAbsoluteY = item.y !== undefined ? Number(item.y) : 0;
|
|
960
|
+
|
|
961
|
+
// Support w="fill" for nested frames (check BEFORE setting fWidth/fHeight)
|
|
962
|
+
const fillWidth = item.w === 'fill';
|
|
963
|
+
const fillHeight = item.h === 'fill';
|
|
964
|
+
|
|
965
|
+
// HUG by default, FIXED only if explicit numeric size given
|
|
966
|
+
const hasWidth = (item.w !== undefined || item.width !== undefined) && !fillWidth;
|
|
967
|
+
const hasHeight = (item.h !== undefined || item.height !== undefined) && !fillHeight;
|
|
968
|
+
const fWidth = fillWidth ? 100 : (item.w || item.width || 100);
|
|
969
|
+
const fHeight = fillHeight ? 100 : (item.h || item.height || 40);
|
|
970
|
+
|
|
971
|
+
// Map align/justify to Figma values
|
|
972
|
+
const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
|
|
973
|
+
const fAlignVal = alignMap[fAlign] || 'CENTER';
|
|
974
|
+
const fJustifyVal = alignMap[fJustify] || 'CENTER';
|
|
975
|
+
|
|
976
|
+
const nestedChildren = item._children ? generateChildCode(item._children, `el${idx}`, fFlex) : '';
|
|
977
|
+
const frameFillCode = fBg ? this.generateFillCode(fBg, `el${idx}`) : { code: `el${idx}.fills = [];`, usesVars: false };
|
|
978
|
+
const frameStrokeCode = fStroke ? this.generateStrokeCode(fStroke, `el${idx}`, fStrokeWidth, fStrokeAlign) : { code: '' };
|
|
979
|
+
|
|
980
|
+
// Determine sizing: FILL, FIXED, or HUG for each axis
|
|
981
|
+
const wantFillH = fillWidth || (fGrow !== null && parentFlex === 'row');
|
|
982
|
+
const wantFillV = fillHeight || (fGrow !== null && parentFlex === 'col');
|
|
983
|
+
const hSizing = wantFillH ? 'FILL' : (hasWidth ? 'FIXED' : 'HUG');
|
|
984
|
+
const vSizing = wantFillV ? 'FILL' : (hasHeight ? 'FIXED' : 'HUG');
|
|
985
|
+
|
|
986
|
+
return `
|
|
987
|
+
__currentNode = 'Frame: ${fName.replace(/'/g, "\\'")}';
|
|
988
|
+
const el${idx} = figma.createFrame();
|
|
989
|
+
el${idx}.name = ${JSON.stringify(fName)};
|
|
990
|
+
el${idx}.layoutMode = '${fFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
991
|
+
${fWrap && fFlex === 'row' ? `el${idx}.layoutWrap = 'WRAP';` : ''}
|
|
992
|
+
${hasWidth || hasHeight ? `el${idx}.resize(${hasWidth ? fWidth : 100}, ${hasHeight ? fHeight : 100});` : ''}
|
|
993
|
+
el${idx}.itemSpacing = ${fGap};
|
|
994
|
+
el${idx}.paddingTop = ${fPt};
|
|
995
|
+
el${idx}.paddingBottom = ${fPb};
|
|
996
|
+
el${idx}.paddingLeft = ${fPl};
|
|
997
|
+
el${idx}.paddingRight = ${fPr};
|
|
998
|
+
el${idx}.cornerRadius = ${fRounded};
|
|
999
|
+
${frameFillCode.code}
|
|
1000
|
+
${frameStrokeCode.code}
|
|
1001
|
+
el${idx}.primaryAxisAlignItems = '${fJustifyVal}';
|
|
1002
|
+
el${idx}.counterAxisAlignItems = '${fAlignVal}';
|
|
1003
|
+
el${idx}.clipsContent = ${fClip};
|
|
1004
|
+
${parentVar}.appendChild(el${idx});
|
|
1005
|
+
el${idx}.layoutSizingHorizontal = '${hSizing}';
|
|
1006
|
+
el${idx}.layoutSizingVertical = '${vSizing}';
|
|
1007
|
+
${nestedChildren}
|
|
1008
|
+
${fWrap && fFlex === 'row' && fWrapGap > 0 ? `el${idx}.counterAxisSpacing = ${fWrapGap};` : ''}
|
|
1009
|
+
${fPosition === 'absolute' ? `el${idx}.layoutPositioning = 'ABSOLUTE'; el${idx}.x = ${fAbsoluteX}; el${idx}.y = ${fAbsoluteY};` : ''}`;
|
|
1010
|
+
} else if (item._type === 'rect') {
|
|
1011
|
+
// Rectangle element
|
|
1012
|
+
const rWidth = item.w || item.width || 100;
|
|
1013
|
+
const rHeight = item.h || item.height || 100;
|
|
1014
|
+
const rBg = item.bg || item.fill || '#e4e4e7';
|
|
1015
|
+
const rRounded = item.rounded || item.radius || 0;
|
|
1016
|
+
const rName = item.name || 'Rectangle';
|
|
1017
|
+
const rectFillCode = this.generateFillCode(rBg, `el${idx}`);
|
|
1018
|
+
|
|
1019
|
+
return `
|
|
1020
|
+
const el${idx} = figma.createRectangle();
|
|
1021
|
+
el${idx}.name = ${JSON.stringify(rName)};
|
|
1022
|
+
el${idx}.resize(${rWidth}, ${rHeight});
|
|
1023
|
+
el${idx}.cornerRadius = ${rRounded};
|
|
1024
|
+
${rectFillCode.code}
|
|
1025
|
+
${parentVar}.appendChild(el${idx});`;
|
|
1026
|
+
} else if (item._type === 'image') {
|
|
1027
|
+
// Image placeholder (gray rectangle with image icon concept)
|
|
1028
|
+
const iWidth = item.w || item.width || 200;
|
|
1029
|
+
const iHeight = item.h || item.height || 150;
|
|
1030
|
+
const iBg = item.bg || '#f4f4f5';
|
|
1031
|
+
const iRounded = item.rounded || item.radius || 8;
|
|
1032
|
+
const iName = item.name || 'Image';
|
|
1033
|
+
const imgFillCode = this.generateFillCode(iBg, `el${idx}`);
|
|
1034
|
+
|
|
1035
|
+
return `
|
|
1036
|
+
const el${idx} = figma.createRectangle();
|
|
1037
|
+
el${idx}.name = ${JSON.stringify(iName)};
|
|
1038
|
+
el${idx}.resize(${iWidth}, ${iHeight});
|
|
1039
|
+
el${idx}.cornerRadius = ${iRounded};
|
|
1040
|
+
${imgFillCode.code}
|
|
1041
|
+
${parentVar}.appendChild(el${idx});`;
|
|
1042
|
+
} else if (item._type === 'icon') {
|
|
1043
|
+
const icSize = item.size || item.s || 24;
|
|
1044
|
+
const icBg = item.color || item.c || '#71717a';
|
|
1045
|
+
const icName = item.name || 'Icon';
|
|
1046
|
+
const svgData = iconSvgMap[icName];
|
|
1047
|
+
|
|
1048
|
+
if (svgData) {
|
|
1049
|
+
// Real SVG icon from Iconify
|
|
1050
|
+
// IMPORTANT: createNodeFromSvg creates a Frame wrapper. We must:
|
|
1051
|
+
// 1. Clear fills on the wrapper frame (otherwise it shows as a filled square)
|
|
1052
|
+
// 2. Only colorize the vector children inside, not the wrapper
|
|
1053
|
+
const colorCode = icBg.startsWith('var:') ? '' : (() => {
|
|
1054
|
+
const rgb = this.hexToRgb(icBg);
|
|
1055
|
+
return rgb ? `
|
|
1056
|
+
function colorize${idx}(n) {
|
|
1057
|
+
if (n.fills && n.fills.length > 0) n.fills = [{type:'SOLID',color:{r:${rgb.r},g:${rgb.g},b:${rgb.b}}}];
|
|
1058
|
+
if (n.strokes && n.strokes.length > 0) n.strokes = [{type:'SOLID',color:{r:${rgb.r},g:${rgb.g},b:${rgb.b}}}];
|
|
1059
|
+
if (n.children) n.children.forEach(colorize${idx});
|
|
1060
|
+
}
|
|
1061
|
+
if (el${idx}.children) el${idx}.children.forEach(colorize${idx});` : '';
|
|
1062
|
+
})();
|
|
1063
|
+
|
|
1064
|
+
// Variable color binding for icons
|
|
1065
|
+
const varColorCode = icBg.startsWith('var:') ? (() => {
|
|
1066
|
+
const varName = icBg.slice(4);
|
|
1067
|
+
return `
|
|
1068
|
+
if (vars && vars[${JSON.stringify(varName)}]) {
|
|
1069
|
+
function colorizeVar${idx}(n) {
|
|
1070
|
+
if (n.fills && n.fills.length > 0) n.fills = [boundFill(vars[${JSON.stringify(varName)}])];
|
|
1071
|
+
if (n.strokes && n.strokes.length > 0) n.strokes = [figma.variables.setBoundVariableForPaint({type:'SOLID',color:{r:0.5,g:0.5,b:0.5}},'color',vars[${JSON.stringify(varName)}])];
|
|
1072
|
+
if (n.children) n.children.forEach(colorizeVar${idx});
|
|
1073
|
+
}
|
|
1074
|
+
if (el${idx}.children) el${idx}.children.forEach(colorizeVar${idx});
|
|
1075
|
+
}`;
|
|
1076
|
+
})() : '';
|
|
1077
|
+
|
|
1078
|
+
return `
|
|
1079
|
+
const el${idx} = figma.createNodeFromSvg(${JSON.stringify(svgData)});
|
|
1080
|
+
el${idx}.name = ${JSON.stringify(icName)};
|
|
1081
|
+
el${idx}.fills = [];
|
|
1082
|
+
el${idx}.resize(${icSize}, ${icSize});
|
|
1083
|
+
${colorCode}${varColorCode}
|
|
1084
|
+
${parentVar}.appendChild(el${idx});`;
|
|
1085
|
+
} else {
|
|
1086
|
+
// Fallback: placeholder rectangle
|
|
1087
|
+
const iconFillCode = this.generateFillCode(icBg, `el${idx}`);
|
|
1088
|
+
return `
|
|
1089
|
+
const el${idx} = figma.createRectangle();
|
|
1090
|
+
el${idx}.name = ${JSON.stringify(icName)};
|
|
1091
|
+
el${idx}.resize(${icSize}, ${icSize});
|
|
1092
|
+
el${idx}.cornerRadius = ${Math.round(icSize / 4)};
|
|
1093
|
+
${iconFillCode.code}
|
|
1094
|
+
${parentVar}.appendChild(el${idx});`;
|
|
1095
|
+
}
|
|
1096
|
+
} else if (item._type === 'instance') {
|
|
1097
|
+
// Component instance
|
|
1098
|
+
const compId = item.component || item.id;
|
|
1099
|
+
const compName = item.name;
|
|
1100
|
+
|
|
1101
|
+
if (compId) {
|
|
1102
|
+
// Create instance by component ID
|
|
1103
|
+
return `
|
|
1104
|
+
const comp${idx} = figma.getNodeById(${JSON.stringify(compId)});
|
|
1105
|
+
if (comp${idx} && comp${idx}.type === 'COMPONENT') {
|
|
1106
|
+
const el${idx} = comp${idx}.createInstance();
|
|
1107
|
+
${parentVar}.appendChild(el${idx});
|
|
1108
|
+
}`;
|
|
1109
|
+
} else if (compName) {
|
|
1110
|
+
// Find component by name and create instance
|
|
1111
|
+
return `
|
|
1112
|
+
const comp${idx} = figma.currentPage.findOne(n => n.type === 'COMPONENT' && n.name === ${JSON.stringify(compName)});
|
|
1113
|
+
if (comp${idx}) {
|
|
1114
|
+
const el${idx} = comp${idx}.createInstance();
|
|
1115
|
+
${parentVar}.appendChild(el${idx});
|
|
1116
|
+
}`;
|
|
1117
|
+
}
|
|
1118
|
+
return '';
|
|
1119
|
+
} else if (item._type === 'slot') {
|
|
1120
|
+
// Slot element - creates slot inside component
|
|
1121
|
+
// NOTE: createSlot only works when parent is a component
|
|
1122
|
+
const slotName = item.name || 'Slot';
|
|
1123
|
+
const slotFlex = item.flex || 'col';
|
|
1124
|
+
const slotGap = item.gap || 0;
|
|
1125
|
+
const slotP = item.p !== undefined ? item.p : (item.padding !== undefined ? item.padding : null);
|
|
1126
|
+
const slotPx = item.px !== undefined ? item.px : (slotP !== null ? slotP : 0);
|
|
1127
|
+
const slotPy = item.py !== undefined ? item.py : (slotP !== null ? slotP : 0);
|
|
1128
|
+
const slotBg = item.bg || item.fill || null;
|
|
1129
|
+
const slotWidth = item.w || item.width;
|
|
1130
|
+
const slotHeight = item.h || item.height;
|
|
1131
|
+
const fillWidth = item.w === 'fill';
|
|
1132
|
+
const fillHeight = item.h === 'fill';
|
|
1133
|
+
|
|
1134
|
+
const nestedChildren = item._children ? generateChildCode(item._children, `slot${idx}`) : '';
|
|
1135
|
+
const slotFillCode = slotBg ? this.generateFillCode(slotBg, `slot${idx}`) : { code: '' };
|
|
1136
|
+
|
|
1137
|
+
return `
|
|
1138
|
+
// Create slot (only works if parent is a component)
|
|
1139
|
+
let slot${idx} = null;
|
|
1140
|
+
if (${parentVar}.type === 'COMPONENT' || ${parentVar}.type === 'COMPONENT_SET') {
|
|
1141
|
+
slot${idx} = ${parentVar}.createSlot(${JSON.stringify(slotName)});
|
|
1142
|
+
} else {
|
|
1143
|
+
// Fall back to regular frame if parent is not a component
|
|
1144
|
+
slot${idx} = figma.createFrame();
|
|
1145
|
+
slot${idx}.name = ${JSON.stringify(slotName)};
|
|
1146
|
+
${parentVar}.appendChild(slot${idx});
|
|
1147
|
+
}
|
|
1148
|
+
slot${idx}.layoutMode = '${slotFlex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
1149
|
+
slot${idx}.itemSpacing = ${slotGap};
|
|
1150
|
+
slot${idx}.paddingTop = ${slotPy};
|
|
1151
|
+
slot${idx}.paddingBottom = ${slotPy};
|
|
1152
|
+
slot${idx}.paddingLeft = ${slotPx};
|
|
1153
|
+
slot${idx}.paddingRight = ${slotPx};
|
|
1154
|
+
${slotWidth && !fillWidth ? `slot${idx}.resize(${slotWidth}, ${slotHeight || 100});` : ''}
|
|
1155
|
+
${fillWidth ? `slot${idx}.layoutSizingHorizontal = 'FILL';` : ''}
|
|
1156
|
+
${fillHeight ? `slot${idx}.layoutSizingVertical = 'FILL';` : ''}
|
|
1157
|
+
${slotFillCode.code}
|
|
1158
|
+
${nestedChildren}`;
|
|
1159
|
+
}
|
|
1160
|
+
return '';
|
|
1161
|
+
}).join('\n');
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const childCode = generateChildCode(children, 'frame', flex);
|
|
1165
|
+
|
|
1166
|
+
// Map align/justify to Figma values for root frame
|
|
1167
|
+
const alignMap = { start: 'MIN', center: 'CENTER', end: 'MAX', stretch: 'STRETCH' };
|
|
1168
|
+
const alignVal = alignMap[align] || 'MIN';
|
|
1169
|
+
const justifyVal = alignMap[justify] || 'MIN';
|
|
1170
|
+
|
|
1171
|
+
// Smart positioning code
|
|
1172
|
+
const smartPosCode = useSmartPos ? `
|
|
1173
|
+
let smartX = 0;
|
|
1174
|
+
const children = figma.currentPage.children;
|
|
1175
|
+
if (children.length > 0) {
|
|
1176
|
+
let maxRight = 0;
|
|
1177
|
+
children.forEach(n => {
|
|
1178
|
+
const right = n.x + (n.width || 0);
|
|
1179
|
+
if (right > maxRight) maxRight = right;
|
|
1180
|
+
});
|
|
1181
|
+
smartX = Math.round(maxRight + 100);
|
|
1182
|
+
}
|
|
1183
|
+
` : `const smartX = ${explicitX};`;
|
|
1184
|
+
|
|
1185
|
+
// Generate fill/stroke code for root frame
|
|
1186
|
+
const rootFillCode = this.generateFillCode(bg, 'frame');
|
|
1187
|
+
const rootStrokeCode = stroke ? this.generateStrokeCode(stroke, 'frame', strokeWidth, strokeAlignProp) : { code: '', usesVars: false };
|
|
1188
|
+
|
|
1189
|
+
// Variable loading code with caching (only if any vars used)
|
|
1190
|
+
const varLoadCode = usesVars ? `
|
|
1191
|
+
// Load shadcn variables (cached for 30s)
|
|
1192
|
+
if (!globalThis.__varsCache || Date.now() - (globalThis.__varsCacheTime || 0) > 30000) {
|
|
1193
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
1194
|
+
globalThis.__varsCache = {};
|
|
1195
|
+
for (const col of collections) {
|
|
1196
|
+
if (!col.name.startsWith('shadcn')) continue;
|
|
1197
|
+
for (const id of col.variableIds) {
|
|
1198
|
+
const v = await figma.variables.getVariableByIdAsync(id);
|
|
1199
|
+
if (v) globalThis.__varsCache[v.name] = v;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
globalThis.__varsCacheTime = Date.now();
|
|
1203
|
+
}
|
|
1204
|
+
const vars = globalThis.__varsCache;
|
|
1205
|
+
const boundFill = (variable) => figma.variables.setBoundVariableForPaint(
|
|
1206
|
+
{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }, 'color', variable
|
|
1207
|
+
);
|
|
1208
|
+
` : '';
|
|
1209
|
+
|
|
1210
|
+
// Font loading with caching
|
|
1211
|
+
const fontLoadCode = fontStyles.length > 0
|
|
1212
|
+
? `
|
|
1213
|
+
if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
|
|
1214
|
+
const fontsToLoad = ${JSON.stringify(fontStyles)}.filter(s => !globalThis.__loadedFonts.has(s));
|
|
1215
|
+
if (fontsToLoad.length > 0) {
|
|
1216
|
+
await Promise.all(fontsToLoad.map(s => figma.loadFontAsync({family:'Inter',style:s})));
|
|
1217
|
+
fontsToLoad.forEach(s => globalThis.__loadedFonts.add(s));
|
|
1218
|
+
}
|
|
1219
|
+
`
|
|
1220
|
+
: `
|
|
1221
|
+
if (!globalThis.__loadedFonts) globalThis.__loadedFonts = new Set();
|
|
1222
|
+
if (!globalThis.__loadedFonts.has('Regular')) {
|
|
1223
|
+
await figma.loadFontAsync({family:'Inter',style:'Regular'});
|
|
1224
|
+
globalThis.__loadedFonts.add('Regular');
|
|
1225
|
+
}
|
|
1226
|
+
`;
|
|
1227
|
+
|
|
1228
|
+
return `
|
|
1229
|
+
(async function() {
|
|
1230
|
+
${fontLoadCode}
|
|
1231
|
+
${varLoadCode}
|
|
1232
|
+
${smartPosCode}
|
|
1233
|
+
|
|
1234
|
+
let __currentNode = 'root';
|
|
1235
|
+
try {
|
|
1236
|
+
const frame = figma.createFrame();
|
|
1237
|
+
__currentNode = ${JSON.stringify(name)};
|
|
1238
|
+
frame.name = ${JSON.stringify(name)};
|
|
1239
|
+
frame.resize(${width}, ${height});
|
|
1240
|
+
frame.x = smartX;
|
|
1241
|
+
frame.y = ${y};
|
|
1242
|
+
frame.cornerRadius = ${rounded};
|
|
1243
|
+
${rootFillCode.code}
|
|
1244
|
+
${rootStrokeCode.code}
|
|
1245
|
+
frame.layoutMode = '${flex === 'row' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
1246
|
+
${wrap && flex === 'row' ? `frame.layoutWrap = 'WRAP';` : ''}
|
|
1247
|
+
frame.itemSpacing = ${gap};
|
|
1248
|
+
frame.paddingTop = ${py};
|
|
1249
|
+
frame.paddingBottom = ${py};
|
|
1250
|
+
frame.paddingLeft = ${px};
|
|
1251
|
+
frame.paddingRight = ${px};
|
|
1252
|
+
frame.primaryAxisAlignItems = '${justifyVal}';
|
|
1253
|
+
frame.counterAxisAlignItems = '${alignVal}';
|
|
1254
|
+
frame.primaryAxisSizingMode = '${flex === 'col' ? (hugHeight || fillHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED') : (hugWidth || fillWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED')}';
|
|
1255
|
+
frame.counterAxisSizingMode = '${flex === 'col' ? (hugWidth || fillWidth || !hasExplicitWidth ? 'AUTO' : 'FIXED') : (hugHeight || fillHeight || !hasExplicitHeight ? 'AUTO' : 'FIXED')}';
|
|
1256
|
+
${fillWidth ? `frame.layoutSizingHorizontal = 'FILL';` : ''}
|
|
1257
|
+
${fillHeight ? `frame.layoutSizingVertical = 'FILL';` : ''}
|
|
1258
|
+
${wrap && flex === 'row' && wrapGap > 0 ? `frame.counterAxisSpacing = ${wrapGap};` : ''}
|
|
1259
|
+
frame.clipsContent = ${clip};
|
|
1260
|
+
|
|
1261
|
+
${childCode}
|
|
1262
|
+
|
|
1263
|
+
return { id: frame.id, name: frame.name };
|
|
1264
|
+
} catch(e) {
|
|
1265
|
+
frame.remove();
|
|
1266
|
+
throw new Error('[Node: ' + __currentNode + '] ' + e.message);
|
|
1267
|
+
}
|
|
1268
|
+
})()
|
|
1269
|
+
`;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
hexToRgb(hex) {
|
|
1273
|
+
if (!hex || !hex.startsWith('#')) return null;
|
|
1274
|
+
let r, g, b;
|
|
1275
|
+
if (hex.length === 4) {
|
|
1276
|
+
r = parseInt(hex[1] + hex[1], 16) / 255;
|
|
1277
|
+
g = parseInt(hex[2] + hex[2], 16) / 255;
|
|
1278
|
+
b = parseInt(hex[3] + hex[3], 16) / 255;
|
|
1279
|
+
} else {
|
|
1280
|
+
r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
1281
|
+
g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
1282
|
+
b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
1283
|
+
}
|
|
1284
|
+
return { r, g, b };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
hexToRgbCode(hex) {
|
|
1288
|
+
// Support both #fff and #ffffff formats
|
|
1289
|
+
let r, g, b;
|
|
1290
|
+
if (hex.length === 4) {
|
|
1291
|
+
// #rgb -> #rrggbb
|
|
1292
|
+
r = parseInt(hex[1] + hex[1], 16) / 255;
|
|
1293
|
+
g = parseInt(hex[2] + hex[2], 16) / 255;
|
|
1294
|
+
b = parseInt(hex[3] + hex[3], 16) / 255;
|
|
1295
|
+
} else {
|
|
1296
|
+
// #rrggbb
|
|
1297
|
+
r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
1298
|
+
g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
1299
|
+
b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
1300
|
+
}
|
|
1301
|
+
return `{r:${r},g:${g},b:${b}}`;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Check if a value is a variable reference (var:name)
|
|
1306
|
+
*/
|
|
1307
|
+
isVarRef(value) {
|
|
1308
|
+
return typeof value === 'string' && value.startsWith('var:');
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Extract variable name from var:name syntax
|
|
1313
|
+
*/
|
|
1314
|
+
getVarName(value) {
|
|
1315
|
+
return value.slice(4); // Remove 'var:' prefix
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Generate fill code - either hex color or bound variable
|
|
1320
|
+
* Returns { code: string, usesVars: boolean }
|
|
1321
|
+
*/
|
|
1322
|
+
generateFillCode(value, elementVar, property = 'fills') {
|
|
1323
|
+
if (this.isVarRef(value)) {
|
|
1324
|
+
const varName = this.getVarName(value);
|
|
1325
|
+
return {
|
|
1326
|
+
code: `${elementVar}.${property} = [boundFill(vars['${varName}'])];`,
|
|
1327
|
+
usesVars: true
|
|
1328
|
+
};
|
|
1329
|
+
} else {
|
|
1330
|
+
return {
|
|
1331
|
+
code: `${elementVar}.${property} = [{type:'SOLID',color:${this.hexToRgbCode(value)}}];`,
|
|
1332
|
+
usesVars: false
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Generate stroke code - either hex color or bound variable
|
|
1339
|
+
*/
|
|
1340
|
+
generateStrokeCode(value, elementVar, strokeWidth = 1, strokeAlign = null) {
|
|
1341
|
+
const alignCode = strokeAlign ? ` ${elementVar}.strokeAlign = '${strokeAlign.toUpperCase()}';` : '';
|
|
1342
|
+
if (this.isVarRef(value)) {
|
|
1343
|
+
const varName = this.getVarName(value);
|
|
1344
|
+
return {
|
|
1345
|
+
code: `${elementVar}.strokes = [boundFill(vars['${varName}'])]; ${elementVar}.strokeWeight = ${strokeWidth};${alignCode}`,
|
|
1346
|
+
usesVars: true
|
|
1347
|
+
};
|
|
1348
|
+
} else {
|
|
1349
|
+
return {
|
|
1350
|
+
code: `${elementVar}.strokes = [{type:'SOLID',color:${this.hexToRgbCode(value)}}]; ${elementVar}.strokeWeight = ${strokeWidth};${alignCode}`,
|
|
1351
|
+
usesVars: false
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ============ Node Operations ============
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Get a node by ID
|
|
1360
|
+
*/
|
|
1361
|
+
async getNode(nodeId) {
|
|
1362
|
+
return await this.eval(`
|
|
1363
|
+
(function() {
|
|
1364
|
+
const n = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1365
|
+
if (!n) return null;
|
|
1366
|
+
return {
|
|
1367
|
+
id: n.id,
|
|
1368
|
+
type: n.type,
|
|
1369
|
+
name: n.name || '',
|
|
1370
|
+
x: n.x,
|
|
1371
|
+
y: n.y,
|
|
1372
|
+
width: n.width,
|
|
1373
|
+
height: n.height,
|
|
1374
|
+
visible: n.visible,
|
|
1375
|
+
opacity: n.opacity
|
|
1376
|
+
};
|
|
1377
|
+
})()
|
|
1378
|
+
`);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Delete a node by ID
|
|
1383
|
+
*/
|
|
1384
|
+
async deleteNode(nodeId) {
|
|
1385
|
+
return await this.eval(`
|
|
1386
|
+
(function() {
|
|
1387
|
+
const n = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1388
|
+
if (!n) return { success: false, error: 'Node not found' };
|
|
1389
|
+
n.remove();
|
|
1390
|
+
return { success: true };
|
|
1391
|
+
})()
|
|
1392
|
+
`);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Move a node to new position
|
|
1397
|
+
*/
|
|
1398
|
+
async moveNode(nodeId, x, y) {
|
|
1399
|
+
return await this.eval(`
|
|
1400
|
+
(function() {
|
|
1401
|
+
const n = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1402
|
+
if (!n) return { success: false, error: 'Node not found' };
|
|
1403
|
+
n.x = ${x};
|
|
1404
|
+
n.y = ${y};
|
|
1405
|
+
return { success: true, x: n.x, y: n.y };
|
|
1406
|
+
})()
|
|
1407
|
+
`);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Resize a node
|
|
1412
|
+
*/
|
|
1413
|
+
async resizeNode(nodeId, width, height) {
|
|
1414
|
+
return await this.eval(`
|
|
1415
|
+
(function() {
|
|
1416
|
+
const n = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1417
|
+
if (!n) return { success: false, error: 'Node not found' };
|
|
1418
|
+
if (n.resize) n.resize(${width}, ${height});
|
|
1419
|
+
return { success: true, width: n.width, height: n.height };
|
|
1420
|
+
})()
|
|
1421
|
+
`);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Get current selection
|
|
1426
|
+
*/
|
|
1427
|
+
async getSelection() {
|
|
1428
|
+
return await this.eval(`
|
|
1429
|
+
figma.currentPage.selection.map(n => ({
|
|
1430
|
+
id: n.id,
|
|
1431
|
+
type: n.type,
|
|
1432
|
+
name: n.name || ''
|
|
1433
|
+
}))
|
|
1434
|
+
`);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Set selection by node IDs
|
|
1439
|
+
*/
|
|
1440
|
+
async setSelection(nodeIds) {
|
|
1441
|
+
const ids = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
|
|
1442
|
+
return await this.eval(`
|
|
1443
|
+
(function() {
|
|
1444
|
+
const nodes = ${JSON.stringify(ids)}.map(id => figma.getNodeById(id)).filter(n => n);
|
|
1445
|
+
figma.currentPage.selection = nodes;
|
|
1446
|
+
return nodes.map(n => n.id);
|
|
1447
|
+
})()
|
|
1448
|
+
`);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Get node tree (recursive structure)
|
|
1453
|
+
*/
|
|
1454
|
+
async getNodeTree(nodeId, maxDepth = 10) {
|
|
1455
|
+
return await this.eval(`
|
|
1456
|
+
(function() {
|
|
1457
|
+
function buildTree(node, depth) {
|
|
1458
|
+
if (depth > ${maxDepth}) return null;
|
|
1459
|
+
const result = {
|
|
1460
|
+
id: node.id,
|
|
1461
|
+
type: node.type,
|
|
1462
|
+
name: node.name || '',
|
|
1463
|
+
x: Math.round(node.x || 0),
|
|
1464
|
+
y: Math.round(node.y || 0),
|
|
1465
|
+
width: Math.round(node.width || 0),
|
|
1466
|
+
height: Math.round(node.height || 0)
|
|
1467
|
+
};
|
|
1468
|
+
if (node.children) {
|
|
1469
|
+
result.children = node.children.map(c => buildTree(c, depth + 1)).filter(c => c);
|
|
1470
|
+
}
|
|
1471
|
+
return result;
|
|
1472
|
+
}
|
|
1473
|
+
const node = ${nodeId ? `figma.getNodeById(${JSON.stringify(nodeId)})` : 'figma.currentPage'};
|
|
1474
|
+
if (!node) return null;
|
|
1475
|
+
return buildTree(node, 0);
|
|
1476
|
+
})()
|
|
1477
|
+
`);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Convert nodes to components
|
|
1482
|
+
*/
|
|
1483
|
+
async toComponent(nodeIds) {
|
|
1484
|
+
const ids = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
|
|
1485
|
+
return await this.eval(`
|
|
1486
|
+
(function() {
|
|
1487
|
+
const results = [];
|
|
1488
|
+
${JSON.stringify(ids)}.forEach(id => {
|
|
1489
|
+
const node = figma.getNodeById(id);
|
|
1490
|
+
if (node && node.type === 'FRAME') {
|
|
1491
|
+
const component = figma.createComponentFromNode(node);
|
|
1492
|
+
results.push({ id: component.id, name: component.name });
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
return results;
|
|
1496
|
+
})()
|
|
1497
|
+
`);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Duplicate a node
|
|
1502
|
+
*/
|
|
1503
|
+
async duplicateNode(nodeId, offsetX = 50, offsetY = 0) {
|
|
1504
|
+
return await this.eval(`
|
|
1505
|
+
(function() {
|
|
1506
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1507
|
+
if (!node) return null;
|
|
1508
|
+
const clone = node.clone();
|
|
1509
|
+
clone.x = node.x + ${offsetX};
|
|
1510
|
+
clone.y = node.y + ${offsetY};
|
|
1511
|
+
return { id: clone.id, name: clone.name, x: clone.x, y: clone.y };
|
|
1512
|
+
})()
|
|
1513
|
+
`);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Rename a node
|
|
1518
|
+
*/
|
|
1519
|
+
async renameNode(nodeId, newName) {
|
|
1520
|
+
return await this.eval(`
|
|
1521
|
+
(function() {
|
|
1522
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1523
|
+
if (!node) return { success: false, error: 'Node not found' };
|
|
1524
|
+
node.name = ${JSON.stringify(newName)};
|
|
1525
|
+
return { success: true, name: node.name };
|
|
1526
|
+
})()
|
|
1527
|
+
`);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Set node fill color
|
|
1532
|
+
*/
|
|
1533
|
+
async setFill(nodeId, hexColor) {
|
|
1534
|
+
return await this.eval(`
|
|
1535
|
+
(function() {
|
|
1536
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1537
|
+
if (!node) return { success: false, error: 'Node not found' };
|
|
1538
|
+
const rgb = ${this.hexToRgbCode(hexColor)};
|
|
1539
|
+
node.fills = [{type: 'SOLID', color: rgb}];
|
|
1540
|
+
return { success: true };
|
|
1541
|
+
})()
|
|
1542
|
+
`);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Set node corner radius
|
|
1547
|
+
*/
|
|
1548
|
+
async setRadius(nodeId, radius) {
|
|
1549
|
+
return await this.eval(`
|
|
1550
|
+
(function() {
|
|
1551
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1552
|
+
if (!node) return { success: false, error: 'Node not found' };
|
|
1553
|
+
if ('cornerRadius' in node) node.cornerRadius = ${radius};
|
|
1554
|
+
return { success: true };
|
|
1555
|
+
})()
|
|
1556
|
+
`);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Get file key from current file
|
|
1561
|
+
*/
|
|
1562
|
+
async getFileKey() {
|
|
1563
|
+
return await this.eval('figma.fileKey');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Arrange nodes on canvas
|
|
1568
|
+
*/
|
|
1569
|
+
async arrangeNodes(gap = 100, columns = null) {
|
|
1570
|
+
return await this.eval(`
|
|
1571
|
+
(function() {
|
|
1572
|
+
const nodes = figma.currentPage.children.filter(n => n.type === 'FRAME' || n.type === 'COMPONENT');
|
|
1573
|
+
if (nodes.length === 0) return { arranged: 0 };
|
|
1574
|
+
|
|
1575
|
+
const cols = ${columns || 'null'} || nodes.length;
|
|
1576
|
+
let x = 0, y = 0, rowHeight = 0, col = 0;
|
|
1577
|
+
|
|
1578
|
+
nodes.forEach(n => {
|
|
1579
|
+
n.x = x;
|
|
1580
|
+
n.y = y;
|
|
1581
|
+
rowHeight = Math.max(rowHeight, n.height);
|
|
1582
|
+
col++;
|
|
1583
|
+
if (col >= cols) {
|
|
1584
|
+
col = 0;
|
|
1585
|
+
x = 0;
|
|
1586
|
+
y += rowHeight + ${gap};
|
|
1587
|
+
rowHeight = 0;
|
|
1588
|
+
} else {
|
|
1589
|
+
x += n.width + ${gap};
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
return { arranged: nodes.length };
|
|
1594
|
+
})()
|
|
1595
|
+
`);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ============ Create Primitives ============
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Create a frame
|
|
1602
|
+
*/
|
|
1603
|
+
async createFrame(options = {}) {
|
|
1604
|
+
const { name = 'Frame', width = 100, height = 100, x, y, fill = '#ffffff', radius = 0 } = options;
|
|
1605
|
+
return await this.eval(`
|
|
1606
|
+
(function() {
|
|
1607
|
+
const frame = figma.createFrame();
|
|
1608
|
+
frame.name = ${JSON.stringify(name)};
|
|
1609
|
+
frame.resize(${width}, ${height});
|
|
1610
|
+
${x !== undefined ? `frame.x = ${x};` : ''}
|
|
1611
|
+
${y !== undefined ? `frame.y = ${y};` : ''}
|
|
1612
|
+
frame.cornerRadius = ${radius};
|
|
1613
|
+
frame.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
|
|
1614
|
+
return { id: frame.id, name: frame.name, x: frame.x, y: frame.y };
|
|
1615
|
+
})()
|
|
1616
|
+
`);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Create a rectangle
|
|
1621
|
+
*/
|
|
1622
|
+
async createRectangle(options = {}) {
|
|
1623
|
+
const { name = 'Rectangle', width = 100, height = 100, x, y, fill = '#d9d9d9', radius = 0 } = options;
|
|
1624
|
+
return await this.eval(`
|
|
1625
|
+
(function() {
|
|
1626
|
+
const rect = figma.createRectangle();
|
|
1627
|
+
rect.name = ${JSON.stringify(name)};
|
|
1628
|
+
rect.resize(${width}, ${height});
|
|
1629
|
+
${x !== undefined ? `rect.x = ${x};` : ''}
|
|
1630
|
+
${y !== undefined ? `rect.y = ${y};` : ''}
|
|
1631
|
+
rect.cornerRadius = ${radius};
|
|
1632
|
+
rect.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
|
|
1633
|
+
return { id: rect.id, name: rect.name };
|
|
1634
|
+
})()
|
|
1635
|
+
`);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* Create an ellipse/circle
|
|
1640
|
+
*/
|
|
1641
|
+
async createEllipse(options = {}) {
|
|
1642
|
+
const { name = 'Ellipse', width = 100, height = 100, x, y, fill = '#d9d9d9' } = options;
|
|
1643
|
+
return await this.eval(`
|
|
1644
|
+
(function() {
|
|
1645
|
+
const ellipse = figma.createEllipse();
|
|
1646
|
+
ellipse.name = ${JSON.stringify(name)};
|
|
1647
|
+
ellipse.resize(${width}, ${height || width});
|
|
1648
|
+
${x !== undefined ? `ellipse.x = ${x};` : ''}
|
|
1649
|
+
${y !== undefined ? `ellipse.y = ${y};` : ''}
|
|
1650
|
+
ellipse.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
|
|
1651
|
+
return { id: ellipse.id, name: ellipse.name };
|
|
1652
|
+
})()
|
|
1653
|
+
`);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Create a text node
|
|
1658
|
+
*/
|
|
1659
|
+
async createText(options = {}) {
|
|
1660
|
+
const { content = 'Text', x, y, size = 14, color = '#000000', weight = 'Regular' } = options;
|
|
1661
|
+
const style = weight === 'bold' ? 'Bold' : weight === 'medium' ? 'Medium' : 'Regular';
|
|
1662
|
+
return await this.eval(`
|
|
1663
|
+
(async function() {
|
|
1664
|
+
await figma.loadFontAsync({family:'Inter',style:'${style}'});
|
|
1665
|
+
const text = figma.createText();
|
|
1666
|
+
text.fontName = {family:'Inter',style:'${style}'};
|
|
1667
|
+
text.fontSize = ${size};
|
|
1668
|
+
text.characters = ${JSON.stringify(content)};
|
|
1669
|
+
text.fills = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
|
|
1670
|
+
${x !== undefined ? `text.x = ${x};` : ''}
|
|
1671
|
+
${y !== undefined ? `text.y = ${y};` : ''}
|
|
1672
|
+
return { id: text.id, characters: text.characters };
|
|
1673
|
+
})()
|
|
1674
|
+
`);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Create a line
|
|
1679
|
+
*/
|
|
1680
|
+
async createLine(options = {}) {
|
|
1681
|
+
const { length = 100, x, y, color = '#000000', strokeWeight = 1 } = options;
|
|
1682
|
+
return await this.eval(`
|
|
1683
|
+
(function() {
|
|
1684
|
+
const line = figma.createLine();
|
|
1685
|
+
line.resize(${length}, 0);
|
|
1686
|
+
${x !== undefined ? `line.x = ${x};` : ''}
|
|
1687
|
+
${y !== undefined ? `line.y = ${y};` : ''}
|
|
1688
|
+
line.strokes = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
|
|
1689
|
+
line.strokeWeight = ${strokeWeight};
|
|
1690
|
+
return { id: line.id };
|
|
1691
|
+
})()
|
|
1692
|
+
`);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Create an auto-layout frame
|
|
1697
|
+
*/
|
|
1698
|
+
async createAutoLayout(options = {}) {
|
|
1699
|
+
const {
|
|
1700
|
+
name = 'AutoLayout',
|
|
1701
|
+
direction = 'VERTICAL',
|
|
1702
|
+
gap = 8,
|
|
1703
|
+
padding = 16,
|
|
1704
|
+
width, height, x, y,
|
|
1705
|
+
fill = '#ffffff',
|
|
1706
|
+
radius = 0
|
|
1707
|
+
} = options;
|
|
1708
|
+
return await this.eval(`
|
|
1709
|
+
(function() {
|
|
1710
|
+
const frame = figma.createFrame();
|
|
1711
|
+
frame.name = ${JSON.stringify(name)};
|
|
1712
|
+
frame.layoutMode = '${direction === 'row' || direction === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
1713
|
+
frame.itemSpacing = ${gap};
|
|
1714
|
+
frame.paddingTop = frame.paddingBottom = frame.paddingLeft = frame.paddingRight = ${padding};
|
|
1715
|
+
frame.primaryAxisSizingMode = 'AUTO';
|
|
1716
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
1717
|
+
${width ? `frame.resize(${width}, ${height || width}); frame.primaryAxisSizingMode = 'FIXED'; frame.counterAxisSizingMode = 'FIXED';` : ''}
|
|
1718
|
+
${x !== undefined ? `frame.x = ${x};` : ''}
|
|
1719
|
+
${y !== undefined ? `frame.y = ${y};` : ''}
|
|
1720
|
+
frame.cornerRadius = ${radius};
|
|
1721
|
+
frame.fills = [{type:'SOLID',color:${this.hexToRgbCode(fill)}}];
|
|
1722
|
+
return { id: frame.id, name: frame.name };
|
|
1723
|
+
})()
|
|
1724
|
+
`);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ============ Query & Find ============
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Find nodes by name (partial match)
|
|
1731
|
+
*/
|
|
1732
|
+
async findByName(name, type = null) {
|
|
1733
|
+
return await this.eval(`
|
|
1734
|
+
(function() {
|
|
1735
|
+
const results = [];
|
|
1736
|
+
function search(node) {
|
|
1737
|
+
if (node.name && node.name.includes(${JSON.stringify(name)})) {
|
|
1738
|
+
${type ? `if (node.type === '${type}')` : ''} {
|
|
1739
|
+
results.push({ id: node.id, type: node.type, name: node.name });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if (node.children) node.children.forEach(search);
|
|
1743
|
+
}
|
|
1744
|
+
search(figma.currentPage);
|
|
1745
|
+
return results.slice(0, 100);
|
|
1746
|
+
})()
|
|
1747
|
+
`);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Find nodes by type
|
|
1752
|
+
*/
|
|
1753
|
+
async findByType(type) {
|
|
1754
|
+
return await this.eval(`
|
|
1755
|
+
figma.currentPage.findAll(n => n.type === '${type}').slice(0, 100).map(n => ({
|
|
1756
|
+
id: n.id, name: n.name, x: Math.round(n.x), y: Math.round(n.y)
|
|
1757
|
+
}))
|
|
1758
|
+
`);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// ============ Variables ============
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Create a variable
|
|
1765
|
+
*/
|
|
1766
|
+
async createVariable(options = {}) {
|
|
1767
|
+
const { name, collectionId, type = 'COLOR', value } = options;
|
|
1768
|
+
return await this.eval(`
|
|
1769
|
+
(function() {
|
|
1770
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
1771
|
+
if (!col) return { error: 'Collection not found' };
|
|
1772
|
+
const variable = figma.variables.createVariable(${JSON.stringify(name)}, col, '${type}');
|
|
1773
|
+
${value ? `variable.setValueForMode(col.defaultModeId, ${type === 'COLOR' ? this.hexToRgbCode(value) : JSON.stringify(value)});` : ''}
|
|
1774
|
+
return { id: variable.id, name: variable.name };
|
|
1775
|
+
})()
|
|
1776
|
+
`);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Create a variable collection
|
|
1781
|
+
*/
|
|
1782
|
+
async createCollection(name) {
|
|
1783
|
+
return await this.eval(`
|
|
1784
|
+
(function() {
|
|
1785
|
+
const col = figma.variables.createVariableCollection(${JSON.stringify(name)});
|
|
1786
|
+
return { id: col.id, name: col.name, defaultModeId: col.defaultModeId };
|
|
1787
|
+
})()
|
|
1788
|
+
`);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Bind a variable to a node property
|
|
1793
|
+
*/
|
|
1794
|
+
async bindVariable(nodeId, property, variableName) {
|
|
1795
|
+
return await this.eval(`
|
|
1796
|
+
(function() {
|
|
1797
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1798
|
+
if (!node) return { error: 'Node not found' };
|
|
1799
|
+
|
|
1800
|
+
const allVars = figma.variables.getLocalVariables();
|
|
1801
|
+
const variable = allVars.find(v => v.name === ${JSON.stringify(variableName)});
|
|
1802
|
+
if (!variable) return { error: 'Variable not found: ' + ${JSON.stringify(variableName)} };
|
|
1803
|
+
|
|
1804
|
+
const prop = ${JSON.stringify(property)};
|
|
1805
|
+
if (prop === 'fill' || prop === 'fills') {
|
|
1806
|
+
node.fills = [figma.variables.setBoundVariableForPaint(
|
|
1807
|
+
{type:'SOLID',color:{r:1,g:1,b:1}}, 'color', variable
|
|
1808
|
+
)];
|
|
1809
|
+
} else if (prop === 'stroke' || prop === 'strokes') {
|
|
1810
|
+
node.strokes = [figma.variables.setBoundVariableForPaint(
|
|
1811
|
+
{type:'SOLID',color:{r:0,g:0,b:0}}, 'color', variable
|
|
1812
|
+
)];
|
|
1813
|
+
} else {
|
|
1814
|
+
node.setBoundVariable(prop, variable);
|
|
1815
|
+
}
|
|
1816
|
+
return { success: true, nodeId: node.id, property: prop, variable: variable.name };
|
|
1817
|
+
})()
|
|
1818
|
+
`);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// ============ Components ============
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Create a component from a frame
|
|
1825
|
+
*/
|
|
1826
|
+
async createComponent(nodeId) {
|
|
1827
|
+
return await this.eval(`
|
|
1828
|
+
(function() {
|
|
1829
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1830
|
+
if (!node) return { error: 'Node not found' };
|
|
1831
|
+
const component = figma.createComponentFromNode(node);
|
|
1832
|
+
return { id: component.id, name: component.name };
|
|
1833
|
+
})()
|
|
1834
|
+
`);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Create an instance of a component
|
|
1839
|
+
*/
|
|
1840
|
+
async createInstance(componentId, x, y) {
|
|
1841
|
+
return await this.eval(`
|
|
1842
|
+
(function() {
|
|
1843
|
+
const comp = figma.getNodeById(${JSON.stringify(componentId)});
|
|
1844
|
+
if (!comp || comp.type !== 'COMPONENT') return { error: 'Component not found' };
|
|
1845
|
+
const instance = comp.createInstance();
|
|
1846
|
+
${x !== undefined ? `instance.x = ${x};` : ''}
|
|
1847
|
+
${y !== undefined ? `instance.y = ${y};` : ''}
|
|
1848
|
+
return { id: instance.id, name: instance.name, x: instance.x, y: instance.y };
|
|
1849
|
+
})()
|
|
1850
|
+
`);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Get all local components
|
|
1855
|
+
*/
|
|
1856
|
+
async getComponents() {
|
|
1857
|
+
return await this.eval(`
|
|
1858
|
+
figma.root.findAll(n => n.type === 'COMPONENT').map(c => ({
|
|
1859
|
+
id: c.id, name: c.name, page: c.parent?.parent?.name
|
|
1860
|
+
}))
|
|
1861
|
+
`);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ============ Export ============
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Export a node as PNG (returns base64)
|
|
1868
|
+
*/
|
|
1869
|
+
async exportPNG(nodeId, scale = 2) {
|
|
1870
|
+
return await this.eval(`
|
|
1871
|
+
(async function() {
|
|
1872
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1873
|
+
if (!node) return { error: 'Node not found' };
|
|
1874
|
+
const bytes = await node.exportAsync({ format: 'PNG', scale: ${scale} });
|
|
1875
|
+
// Convert to base64
|
|
1876
|
+
let binary = '';
|
|
1877
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1878
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1879
|
+
}
|
|
1880
|
+
return { base64: btoa(binary), width: node.width * ${scale}, height: node.height * ${scale} };
|
|
1881
|
+
})()
|
|
1882
|
+
`);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Export a node as SVG
|
|
1887
|
+
*/
|
|
1888
|
+
async exportSVG(nodeId) {
|
|
1889
|
+
return await this.eval(`
|
|
1890
|
+
(async function() {
|
|
1891
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1892
|
+
if (!node) return { error: 'Node not found' };
|
|
1893
|
+
const bytes = await node.exportAsync({ format: 'SVG' });
|
|
1894
|
+
return { svg: String.fromCharCode.apply(null, bytes) };
|
|
1895
|
+
})()
|
|
1896
|
+
`);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ============ Layout ============
|
|
1900
|
+
|
|
1901
|
+
/**
|
|
1902
|
+
* Set auto-layout on a frame
|
|
1903
|
+
*/
|
|
1904
|
+
async setAutoLayout(nodeId, options = {}) {
|
|
1905
|
+
const { direction = 'VERTICAL', gap = 8, padding = 0 } = options;
|
|
1906
|
+
return await this.eval(`
|
|
1907
|
+
(function() {
|
|
1908
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1909
|
+
if (!node || node.type !== 'FRAME') return { error: 'Frame not found' };
|
|
1910
|
+
node.layoutMode = '${direction === 'row' || direction === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL'}';
|
|
1911
|
+
node.itemSpacing = ${gap};
|
|
1912
|
+
node.paddingTop = node.paddingBottom = node.paddingLeft = node.paddingRight = ${padding};
|
|
1913
|
+
return { success: true };
|
|
1914
|
+
})()
|
|
1915
|
+
`);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Set sizing mode (hug/fill/fixed)
|
|
1920
|
+
*/
|
|
1921
|
+
async setSizing(nodeId, horizontal = 'FIXED', vertical = 'FIXED') {
|
|
1922
|
+
return await this.eval(`
|
|
1923
|
+
(function() {
|
|
1924
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
1925
|
+
if (!node) return { error: 'Node not found' };
|
|
1926
|
+
if (node.layoutSizingHorizontal !== undefined) {
|
|
1927
|
+
node.layoutSizingHorizontal = '${horizontal}';
|
|
1928
|
+
node.layoutSizingVertical = '${vertical}';
|
|
1929
|
+
}
|
|
1930
|
+
return { success: true };
|
|
1931
|
+
})()
|
|
1932
|
+
`);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// ============ Icon (Iconify) ============
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Create an icon from Iconify
|
|
1939
|
+
* @param {string} iconName - e.g., "lucide:star", "mdi:home"
|
|
1940
|
+
*/
|
|
1941
|
+
async createIcon(iconName, options = {}) {
|
|
1942
|
+
const { size = 24, color = '#000000', x, y } = options;
|
|
1943
|
+
const [prefix, name] = iconName.split(':');
|
|
1944
|
+
|
|
1945
|
+
// Fetch SVG from Iconify API
|
|
1946
|
+
const response = await fetch(`https://api.iconify.design/${prefix}/${name}.svg?width=${size}&height=${size}`);
|
|
1947
|
+
const svg = await response.text();
|
|
1948
|
+
|
|
1949
|
+
return await this.eval(`
|
|
1950
|
+
(function() {
|
|
1951
|
+
const svgString = ${JSON.stringify(svg)};
|
|
1952
|
+
const node = figma.createNodeFromSvg(svgString);
|
|
1953
|
+
node.name = ${JSON.stringify(iconName)};
|
|
1954
|
+
${x !== undefined ? `node.x = ${x};` : ''}
|
|
1955
|
+
${y !== undefined ? `node.y = ${y};` : ''}
|
|
1956
|
+
// Apply color
|
|
1957
|
+
function colorize(n) {
|
|
1958
|
+
if (n.fills && n.fills.length > 0) {
|
|
1959
|
+
n.fills = [{type:'SOLID',color:${this.hexToRgbCode(color)}}];
|
|
1960
|
+
}
|
|
1961
|
+
if (n.children) n.children.forEach(colorize);
|
|
1962
|
+
}
|
|
1963
|
+
colorize(node);
|
|
1964
|
+
return { id: node.id, name: node.name };
|
|
1965
|
+
})()
|
|
1966
|
+
`);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// ============ Delete All ============
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Delete all nodes on current page
|
|
1973
|
+
*/
|
|
1974
|
+
async deleteAll() {
|
|
1975
|
+
return await this.eval(`
|
|
1976
|
+
(function() {
|
|
1977
|
+
const count = figma.currentPage.children.length;
|
|
1978
|
+
figma.currentPage.children.forEach(n => n.remove());
|
|
1979
|
+
return { deleted: count };
|
|
1980
|
+
})()
|
|
1981
|
+
`);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Zoom to fit all content
|
|
1986
|
+
*/
|
|
1987
|
+
async zoomToFit() {
|
|
1988
|
+
return await this.eval(`
|
|
1989
|
+
(function() {
|
|
1990
|
+
figma.viewport.scrollAndZoomIntoView(figma.currentPage.children);
|
|
1991
|
+
return { success: true };
|
|
1992
|
+
})()
|
|
1993
|
+
`);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Group nodes
|
|
1998
|
+
*/
|
|
1999
|
+
async groupNodes(nodeIds, name = 'Group') {
|
|
2000
|
+
return await this.eval(`
|
|
2001
|
+
(function() {
|
|
2002
|
+
const nodes = ${JSON.stringify(nodeIds)}.map(id => figma.getNodeById(id)).filter(n => n);
|
|
2003
|
+
if (nodes.length === 0) return { error: 'No nodes found' };
|
|
2004
|
+
const group = figma.group(nodes, figma.currentPage);
|
|
2005
|
+
group.name = ${JSON.stringify(name)};
|
|
2006
|
+
return { id: group.id, name: group.name, childCount: nodes.length };
|
|
2007
|
+
})()
|
|
2008
|
+
`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// ============ Team Libraries ============
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Get available library variable collections
|
|
2015
|
+
*/
|
|
2016
|
+
async getLibraryCollections() {
|
|
2017
|
+
return await this.eval(`
|
|
2018
|
+
(async function() {
|
|
2019
|
+
const collections = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
|
|
2020
|
+
return collections.map(c => ({
|
|
2021
|
+
key: c.key,
|
|
2022
|
+
name: c.name,
|
|
2023
|
+
libraryName: c.libraryName
|
|
2024
|
+
}));
|
|
2025
|
+
})()
|
|
2026
|
+
`);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* Get variables from a library collection
|
|
2031
|
+
*/
|
|
2032
|
+
async getLibraryVariables(collectionKey) {
|
|
2033
|
+
return await this.eval(`
|
|
2034
|
+
(async function() {
|
|
2035
|
+
const variables = await figma.teamLibrary.getVariablesInLibraryCollectionAsync(${JSON.stringify(collectionKey)});
|
|
2036
|
+
return variables.map(v => ({
|
|
2037
|
+
key: v.key,
|
|
2038
|
+
name: v.name,
|
|
2039
|
+
resolvedType: v.resolvedType
|
|
2040
|
+
}));
|
|
2041
|
+
})()
|
|
2042
|
+
`);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Import a variable from a library by key
|
|
2047
|
+
*/
|
|
2048
|
+
async importLibraryVariable(variableKey) {
|
|
2049
|
+
return await this.eval(`
|
|
2050
|
+
(async function() {
|
|
2051
|
+
const variable = await figma.variables.importVariableByKeyAsync(${JSON.stringify(variableKey)});
|
|
2052
|
+
return { id: variable.id, name: variable.name, resolvedType: variable.resolvedType };
|
|
2053
|
+
})()
|
|
2054
|
+
`);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/**
|
|
2058
|
+
* Get available library components
|
|
2059
|
+
*/
|
|
2060
|
+
async getLibraryComponents() {
|
|
2061
|
+
return await this.eval(`
|
|
2062
|
+
(async function() {
|
|
2063
|
+
// Get all component sets and components from enabled libraries
|
|
2064
|
+
const components = [];
|
|
2065
|
+
|
|
2066
|
+
// Search through all pages for component instances to find library components
|
|
2067
|
+
const instances = figma.root.findAll(n => n.type === 'INSTANCE');
|
|
2068
|
+
const seen = new Set();
|
|
2069
|
+
|
|
2070
|
+
for (const instance of instances) {
|
|
2071
|
+
const mainComponent = await instance.getMainComponentAsync();
|
|
2072
|
+
if (mainComponent && mainComponent.remote && !seen.has(mainComponent.key)) {
|
|
2073
|
+
seen.add(mainComponent.key);
|
|
2074
|
+
components.push({
|
|
2075
|
+
key: mainComponent.key,
|
|
2076
|
+
name: mainComponent.name,
|
|
2077
|
+
description: mainComponent.description || ''
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
return components;
|
|
2083
|
+
})()
|
|
2084
|
+
`);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Import a component from a library by key
|
|
2089
|
+
*/
|
|
2090
|
+
async importLibraryComponent(componentKey) {
|
|
2091
|
+
return await this.eval(`
|
|
2092
|
+
(async function() {
|
|
2093
|
+
const component = await figma.importComponentByKeyAsync(${JSON.stringify(componentKey)});
|
|
2094
|
+
return { id: component.id, name: component.name, key: component.key };
|
|
2095
|
+
})()
|
|
2096
|
+
`);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* Create an instance of a library component
|
|
2101
|
+
*/
|
|
2102
|
+
async createLibraryInstance(componentKey, x, y) {
|
|
2103
|
+
return await this.eval(`
|
|
2104
|
+
(async function() {
|
|
2105
|
+
const component = await figma.importComponentByKeyAsync(${JSON.stringify(componentKey)});
|
|
2106
|
+
const instance = component.createInstance();
|
|
2107
|
+
${x !== undefined ? `instance.x = ${x};` : ''}
|
|
2108
|
+
${y !== undefined ? `instance.y = ${y};` : ''}
|
|
2109
|
+
return { id: instance.id, name: instance.name, x: instance.x, y: instance.y };
|
|
2110
|
+
})()
|
|
2111
|
+
`);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* Get available library styles (color, text, effect)
|
|
2116
|
+
*/
|
|
2117
|
+
async getLibraryStyles() {
|
|
2118
|
+
return await this.eval(`
|
|
2119
|
+
(async function() {
|
|
2120
|
+
const styles = {
|
|
2121
|
+
paint: [],
|
|
2122
|
+
text: [],
|
|
2123
|
+
effect: [],
|
|
2124
|
+
grid: []
|
|
2125
|
+
};
|
|
2126
|
+
|
|
2127
|
+
// Get local styles that reference library
|
|
2128
|
+
const paintStyles = figma.getLocalPaintStyles();
|
|
2129
|
+
const textStyles = figma.getLocalTextStyles();
|
|
2130
|
+
const effectStyles = figma.getLocalEffectStyles();
|
|
2131
|
+
const gridStyles = figma.getLocalGridStyles();
|
|
2132
|
+
|
|
2133
|
+
paintStyles.forEach(s => {
|
|
2134
|
+
styles.paint.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
|
|
2135
|
+
});
|
|
2136
|
+
textStyles.forEach(s => {
|
|
2137
|
+
styles.text.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
|
|
2138
|
+
});
|
|
2139
|
+
effectStyles.forEach(s => {
|
|
2140
|
+
styles.effect.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
|
|
2141
|
+
});
|
|
2142
|
+
gridStyles.forEach(s => {
|
|
2143
|
+
styles.grid.push({ id: s.id, name: s.name, key: s.key, remote: s.remote });
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
return styles;
|
|
2147
|
+
})()
|
|
2148
|
+
`);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* Import a style from a library by key
|
|
2153
|
+
*/
|
|
2154
|
+
async importLibraryStyle(styleKey) {
|
|
2155
|
+
return await this.eval(`
|
|
2156
|
+
(async function() {
|
|
2157
|
+
const style = await figma.importStyleByKeyAsync(${JSON.stringify(styleKey)});
|
|
2158
|
+
return { id: style.id, name: style.name, type: style.type };
|
|
2159
|
+
})()
|
|
2160
|
+
`);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* Apply a library style to a node
|
|
2165
|
+
*/
|
|
2166
|
+
async applyLibraryStyle(nodeId, styleKey, styleType = 'fill') {
|
|
2167
|
+
return await this.eval(`
|
|
2168
|
+
(async function() {
|
|
2169
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2170
|
+
if (!node) return { error: 'Node not found' };
|
|
2171
|
+
|
|
2172
|
+
const style = await figma.importStyleByKeyAsync(${JSON.stringify(styleKey)});
|
|
2173
|
+
const type = ${JSON.stringify(styleType)};
|
|
2174
|
+
|
|
2175
|
+
if (type === 'fill' && 'fillStyleId' in node) {
|
|
2176
|
+
node.fillStyleId = style.id;
|
|
2177
|
+
} else if (type === 'stroke' && 'strokeStyleId' in node) {
|
|
2178
|
+
node.strokeStyleId = style.id;
|
|
2179
|
+
} else if (type === 'text' && 'textStyleId' in node) {
|
|
2180
|
+
node.textStyleId = style.id;
|
|
2181
|
+
} else if (type === 'effect' && 'effectStyleId' in node) {
|
|
2182
|
+
node.effectStyleId = style.id;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
return { success: true, styleId: style.id, styleName: style.name };
|
|
2186
|
+
})()
|
|
2187
|
+
`);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Bind a library variable to a node
|
|
2192
|
+
*/
|
|
2193
|
+
async bindLibraryVariable(nodeId, property, variableKey) {
|
|
2194
|
+
return await this.eval(`
|
|
2195
|
+
(async function() {
|
|
2196
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2197
|
+
if (!node) return { error: 'Node not found' };
|
|
2198
|
+
|
|
2199
|
+
const variable = await figma.variables.importVariableByKeyAsync(${JSON.stringify(variableKey)});
|
|
2200
|
+
const prop = ${JSON.stringify(property)};
|
|
2201
|
+
|
|
2202
|
+
if (prop === 'fill' || prop === 'fills') {
|
|
2203
|
+
node.fills = [figma.variables.setBoundVariableForPaint(
|
|
2204
|
+
{type:'SOLID',color:{r:1,g:1,b:1}}, 'color', variable
|
|
2205
|
+
)];
|
|
2206
|
+
} else if (prop === 'stroke' || prop === 'strokes') {
|
|
2207
|
+
node.strokes = [figma.variables.setBoundVariableForPaint(
|
|
2208
|
+
{type:'SOLID',color:{r:0,g:0,b:0}}, 'color', variable
|
|
2209
|
+
)];
|
|
2210
|
+
} else {
|
|
2211
|
+
node.setBoundVariable(prop, variable);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
return { success: true, variableId: variable.id, variableName: variable.name };
|
|
2215
|
+
})()
|
|
2216
|
+
`);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* List all enabled libraries
|
|
2221
|
+
*/
|
|
2222
|
+
async getEnabledLibraries() {
|
|
2223
|
+
return await this.eval(`
|
|
2224
|
+
(async function() {
|
|
2225
|
+
const collections = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
|
|
2226
|
+
const libraries = new Map();
|
|
2227
|
+
|
|
2228
|
+
collections.forEach(c => {
|
|
2229
|
+
if (!libraries.has(c.libraryName)) {
|
|
2230
|
+
libraries.set(c.libraryName, { name: c.libraryName, collections: [] });
|
|
2231
|
+
}
|
|
2232
|
+
libraries.get(c.libraryName).collections.push({ key: c.key, name: c.name });
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
return Array.from(libraries.values());
|
|
2236
|
+
})()
|
|
2237
|
+
`);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Swap a component instance to another library component
|
|
2242
|
+
*/
|
|
2243
|
+
async swapComponent(instanceId, newComponentKey) {
|
|
2244
|
+
return await this.eval(`
|
|
2245
|
+
(async function() {
|
|
2246
|
+
const instance = figma.getNodeById(${JSON.stringify(instanceId)});
|
|
2247
|
+
if (!instance || instance.type !== 'INSTANCE') return { error: 'Instance not found' };
|
|
2248
|
+
|
|
2249
|
+
const newComponent = await figma.importComponentByKeyAsync(${JSON.stringify(newComponentKey)});
|
|
2250
|
+
instance.swapComponent(newComponent);
|
|
2251
|
+
|
|
2252
|
+
return { success: true, newComponentName: newComponent.name };
|
|
2253
|
+
})()
|
|
2254
|
+
`);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// ============ Designer Utilities ============
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* Batch rename layers with pattern
|
|
2261
|
+
* Patterns: {n} = number, {name} = original name, {type} = node type
|
|
2262
|
+
*/
|
|
2263
|
+
async batchRename(nodeIds, pattern, options = {}) {
|
|
2264
|
+
const { startNumber = 1, case: textCase = null } = options;
|
|
2265
|
+
return await this.eval(`
|
|
2266
|
+
(function() {
|
|
2267
|
+
const ids = ${JSON.stringify(nodeIds)};
|
|
2268
|
+
const pattern = ${JSON.stringify(pattern)};
|
|
2269
|
+
let num = ${startNumber};
|
|
2270
|
+
const results = [];
|
|
2271
|
+
|
|
2272
|
+
ids.forEach(id => {
|
|
2273
|
+
const node = figma.getNodeById(id);
|
|
2274
|
+
if (!node) return;
|
|
2275
|
+
|
|
2276
|
+
let newName = pattern
|
|
2277
|
+
.replace(/{n}/g, num)
|
|
2278
|
+
.replace(/{name}/g, node.name)
|
|
2279
|
+
.replace(/{type}/g, node.type.toLowerCase());
|
|
2280
|
+
|
|
2281
|
+
${textCase === 'camel' ? "newName = newName.replace(/[-_\\s]+(\\w)/g, (_, c) => c.toUpperCase()).replace(/^\\w/, c => c.toLowerCase());" : ''}
|
|
2282
|
+
${textCase === 'pascal' ? "newName = newName.replace(/[-_\\s]+(\\w)/g, (_, c) => c.toUpperCase()).replace(/^\\w/, c => c.toUpperCase());" : ''}
|
|
2283
|
+
${textCase === 'snake' ? "newName = newName.replace(/[\\s-]+/g, '_').toLowerCase();" : ''}
|
|
2284
|
+
${textCase === 'kebab' ? "newName = newName.replace(/[\\s_]+/g, '-').toLowerCase();" : ''}
|
|
2285
|
+
|
|
2286
|
+
node.name = newName;
|
|
2287
|
+
results.push({ id: node.id, name: newName });
|
|
2288
|
+
num++;
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
return results;
|
|
2292
|
+
})()
|
|
2293
|
+
`);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* Rename all children of a node
|
|
2298
|
+
*/
|
|
2299
|
+
async batchRenameChildren(parentId, pattern, options = {}) {
|
|
2300
|
+
return await this.eval(`
|
|
2301
|
+
(function() {
|
|
2302
|
+
const parent = figma.getNodeById(${JSON.stringify(parentId)});
|
|
2303
|
+
if (!parent || !parent.children) return { error: 'Parent not found or has no children' };
|
|
2304
|
+
|
|
2305
|
+
const ids = parent.children.map(c => c.id);
|
|
2306
|
+
return ids;
|
|
2307
|
+
})()
|
|
2308
|
+
`).then(ids => this.batchRename(ids, pattern, options));
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* Generate lorem ipsum text
|
|
2313
|
+
*/
|
|
2314
|
+
async loremIpsum(options = {}) {
|
|
2315
|
+
const { type = 'paragraph', count = 1 } = options;
|
|
2316
|
+
const lorem = {
|
|
2317
|
+
words: ['lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua', 'enim', 'ad', 'minim', 'veniam', 'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris', 'nisi', 'aliquip', 'ex', 'ea', 'commodo', 'consequat'],
|
|
2318
|
+
paragraph: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
if (type === 'words') {
|
|
2322
|
+
const words = [];
|
|
2323
|
+
for (let i = 0; i < count; i++) {
|
|
2324
|
+
words.push(lorem.words[Math.floor(Math.random() * lorem.words.length)]);
|
|
2325
|
+
}
|
|
2326
|
+
return words.join(' ');
|
|
2327
|
+
} else if (type === 'sentences') {
|
|
2328
|
+
const sentences = [];
|
|
2329
|
+
for (let i = 0; i < count; i++) {
|
|
2330
|
+
const wordCount = 8 + Math.floor(Math.random() * 8);
|
|
2331
|
+
const words = [];
|
|
2332
|
+
for (let j = 0; j < wordCount; j++) {
|
|
2333
|
+
words.push(lorem.words[Math.floor(Math.random() * lorem.words.length)]);
|
|
2334
|
+
}
|
|
2335
|
+
words[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);
|
|
2336
|
+
sentences.push(words.join(' ') + '.');
|
|
2337
|
+
}
|
|
2338
|
+
return sentences.join(' ');
|
|
2339
|
+
} else {
|
|
2340
|
+
return Array(count).fill(lorem.paragraph).join('\n\n');
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Fill text layer with lorem ipsum
|
|
2346
|
+
*/
|
|
2347
|
+
async fillWithLorem(nodeId, options = {}) {
|
|
2348
|
+
const text = await this.loremIpsum(options);
|
|
2349
|
+
return await this.eval(`
|
|
2350
|
+
(async function() {
|
|
2351
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2352
|
+
if (!node || node.type !== 'TEXT') return { error: 'Text node not found' };
|
|
2353
|
+
|
|
2354
|
+
await figma.loadFontAsync(node.fontName);
|
|
2355
|
+
node.characters = ${JSON.stringify(text)};
|
|
2356
|
+
return { success: true, text: node.characters };
|
|
2357
|
+
})()
|
|
2358
|
+
`);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Insert image from URL (Unsplash, etc.)
|
|
2363
|
+
*/
|
|
2364
|
+
async insertImage(imageUrl, options = {}) {
|
|
2365
|
+
const { x = 0, y = 0, width = 400, height = 300, name = 'Image' } = options;
|
|
2366
|
+
|
|
2367
|
+
// Fetch image and convert to base64
|
|
2368
|
+
const response = await fetch(imageUrl);
|
|
2369
|
+
const buffer = await response.arrayBuffer();
|
|
2370
|
+
const base64 = Buffer.from(buffer).toString('base64');
|
|
2371
|
+
|
|
2372
|
+
return await this.eval(`
|
|
2373
|
+
(async function() {
|
|
2374
|
+
const imageData = Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0));
|
|
2375
|
+
const image = figma.createImage(imageData);
|
|
2376
|
+
|
|
2377
|
+
const rect = figma.createRectangle();
|
|
2378
|
+
rect.name = ${JSON.stringify(name)};
|
|
2379
|
+
rect.x = ${x};
|
|
2380
|
+
rect.y = ${y};
|
|
2381
|
+
rect.resize(${width}, ${height});
|
|
2382
|
+
rect.fills = [{
|
|
2383
|
+
type: 'IMAGE',
|
|
2384
|
+
scaleMode: 'FILL',
|
|
2385
|
+
imageHash: image.hash
|
|
2386
|
+
}];
|
|
2387
|
+
|
|
2388
|
+
return { id: rect.id, name: rect.name, imageHash: image.hash };
|
|
2389
|
+
})()
|
|
2390
|
+
`);
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Insert random Unsplash image
|
|
2395
|
+
*/
|
|
2396
|
+
async insertUnsplash(query, options = {}) {
|
|
2397
|
+
const { width = 800, height = 600 } = options;
|
|
2398
|
+
const imageUrl = `https://source.unsplash.com/random/${width}x${height}/?${encodeURIComponent(query)}`;
|
|
2399
|
+
return await this.insertImage(imageUrl, { ...options, width, height, name: `Unsplash: ${query}` });
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Export node in multiple sizes (@1x, @2x, @3x)
|
|
2404
|
+
*/
|
|
2405
|
+
async exportMultipleSizes(nodeId, options = {}) {
|
|
2406
|
+
const { scales = [1, 2, 3], format = 'PNG' } = options;
|
|
2407
|
+
const results = [];
|
|
2408
|
+
|
|
2409
|
+
for (const scale of scales) {
|
|
2410
|
+
const result = await this.eval(`
|
|
2411
|
+
(async function() {
|
|
2412
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2413
|
+
if (!node) return { error: 'Node not found' };
|
|
2414
|
+
|
|
2415
|
+
const bytes = await node.exportAsync({ format: '${format}', scale: ${scale} });
|
|
2416
|
+
let binary = '';
|
|
2417
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2418
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2419
|
+
}
|
|
2420
|
+
return {
|
|
2421
|
+
scale: ${scale},
|
|
2422
|
+
suffix: '@${scale}x',
|
|
2423
|
+
base64: btoa(binary),
|
|
2424
|
+
width: Math.round(node.width * ${scale}),
|
|
2425
|
+
height: Math.round(node.height * ${scale})
|
|
2426
|
+
};
|
|
2427
|
+
})()
|
|
2428
|
+
`);
|
|
2429
|
+
results.push(result);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
return results;
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/**
|
|
2436
|
+
* Check contrast ratio between two colors (WCAG)
|
|
2437
|
+
*/
|
|
2438
|
+
checkContrast(color1, color2) {
|
|
2439
|
+
const getLuminance = (hex) => {
|
|
2440
|
+
const rgb = [
|
|
2441
|
+
parseInt(hex.slice(1, 3), 16) / 255,
|
|
2442
|
+
parseInt(hex.slice(3, 5), 16) / 255,
|
|
2443
|
+
parseInt(hex.slice(5, 7), 16) / 255
|
|
2444
|
+
].map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
2445
|
+
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
|
2446
|
+
};
|
|
2447
|
+
|
|
2448
|
+
const l1 = getLuminance(color1);
|
|
2449
|
+
const l2 = getLuminance(color2);
|
|
2450
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
2451
|
+
|
|
2452
|
+
return {
|
|
2453
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
2454
|
+
AA: ratio >= 4.5,
|
|
2455
|
+
AALarge: ratio >= 3,
|
|
2456
|
+
AAA: ratio >= 7,
|
|
2457
|
+
AAALarge: ratio >= 4.5
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/**
|
|
2462
|
+
* Check contrast of text node against background
|
|
2463
|
+
*/
|
|
2464
|
+
async checkNodeContrast(textNodeId) {
|
|
2465
|
+
return await this.eval(`
|
|
2466
|
+
(function() {
|
|
2467
|
+
const textNode = figma.getNodeById(${JSON.stringify(textNodeId)});
|
|
2468
|
+
if (!textNode || textNode.type !== 'TEXT') return { error: 'Text node not found' };
|
|
2469
|
+
|
|
2470
|
+
// Get text color
|
|
2471
|
+
const textFill = textNode.fills[0];
|
|
2472
|
+
if (!textFill || textFill.type !== 'SOLID') return { error: 'Text has no solid fill' };
|
|
2473
|
+
const textColor = textFill.color;
|
|
2474
|
+
|
|
2475
|
+
// Find background (parent frame)
|
|
2476
|
+
let parent = textNode.parent;
|
|
2477
|
+
let bgColor = null;
|
|
2478
|
+
while (parent && !bgColor) {
|
|
2479
|
+
if (parent.fills && parent.fills.length > 0) {
|
|
2480
|
+
const fill = parent.fills.find(f => f.type === 'SOLID' && f.visible !== false);
|
|
2481
|
+
if (fill) bgColor = fill.color;
|
|
2482
|
+
}
|
|
2483
|
+
parent = parent.parent;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
if (!bgColor) bgColor = { r: 1, g: 1, b: 1 }; // Default white
|
|
2487
|
+
|
|
2488
|
+
const toHex = (c) => '#' +
|
|
2489
|
+
Math.round(c.r * 255).toString(16).padStart(2, '0') +
|
|
2490
|
+
Math.round(c.g * 255).toString(16).padStart(2, '0') +
|
|
2491
|
+
Math.round(c.b * 255).toString(16).padStart(2, '0');
|
|
2492
|
+
|
|
2493
|
+
return {
|
|
2494
|
+
textColor: toHex(textColor),
|
|
2495
|
+
bgColor: toHex(bgColor),
|
|
2496
|
+
nodeId: textNode.id,
|
|
2497
|
+
nodeName: textNode.name
|
|
2498
|
+
};
|
|
2499
|
+
})()
|
|
2500
|
+
`).then(result => {
|
|
2501
|
+
if (result.error) return result;
|
|
2502
|
+
const contrast = this.checkContrast(result.textColor, result.bgColor);
|
|
2503
|
+
return { ...result, ...contrast };
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/**
|
|
2508
|
+
* Find and replace text in all text nodes
|
|
2509
|
+
*/
|
|
2510
|
+
async findReplaceText(find, replace, options = {}) {
|
|
2511
|
+
const { caseSensitive = false, wholeWord = false } = options;
|
|
2512
|
+
return await this.eval(`
|
|
2513
|
+
(async function() {
|
|
2514
|
+
const textNodes = figma.currentPage.findAll(n => n.type === 'TEXT');
|
|
2515
|
+
const results = [];
|
|
2516
|
+
const findStr = ${JSON.stringify(find)};
|
|
2517
|
+
const replaceStr = ${JSON.stringify(replace)};
|
|
2518
|
+
const caseSensitive = ${caseSensitive};
|
|
2519
|
+
const wholeWord = ${wholeWord};
|
|
2520
|
+
|
|
2521
|
+
for (const node of textNodes) {
|
|
2522
|
+
let text = node.characters;
|
|
2523
|
+
let pattern = caseSensitive ? findStr : findStr.toLowerCase();
|
|
2524
|
+
let searchText = caseSensitive ? text : text.toLowerCase();
|
|
2525
|
+
|
|
2526
|
+
if (wholeWord) {
|
|
2527
|
+
pattern = '\\\\b' + pattern + '\\\\b';
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
|
|
2531
|
+
if (regex.test(searchText)) {
|
|
2532
|
+
await figma.loadFontAsync(node.fontName);
|
|
2533
|
+
node.characters = text.replace(new RegExp(findStr, caseSensitive ? 'g' : 'gi'), replaceStr);
|
|
2534
|
+
results.push({ id: node.id, name: node.name, newText: node.characters });
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
return { replaced: results.length, nodes: results };
|
|
2539
|
+
})()
|
|
2540
|
+
`);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/**
|
|
2544
|
+
* Select all nodes with same fill color
|
|
2545
|
+
*/
|
|
2546
|
+
async selectSameFill(nodeId) {
|
|
2547
|
+
return await this.eval(`
|
|
2548
|
+
(function() {
|
|
2549
|
+
const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2550
|
+
if (!refNode || !refNode.fills || refNode.fills.length === 0) return { error: 'Node has no fill' };
|
|
2551
|
+
|
|
2552
|
+
const refFill = refNode.fills[0];
|
|
2553
|
+
if (refFill.type !== 'SOLID') return { error: 'Reference fill is not solid' };
|
|
2554
|
+
|
|
2555
|
+
const matches = figma.currentPage.findAll(n => {
|
|
2556
|
+
if (!n.fills || n.fills.length === 0) return false;
|
|
2557
|
+
const fill = n.fills[0];
|
|
2558
|
+
if (fill.type !== 'SOLID') return false;
|
|
2559
|
+
return Math.abs(fill.color.r - refFill.color.r) < 0.01 &&
|
|
2560
|
+
Math.abs(fill.color.g - refFill.color.g) < 0.01 &&
|
|
2561
|
+
Math.abs(fill.color.b - refFill.color.b) < 0.01;
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
figma.currentPage.selection = matches;
|
|
2565
|
+
return { selected: matches.length, ids: matches.map(n => n.id) };
|
|
2566
|
+
})()
|
|
2567
|
+
`);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Select all nodes with same stroke color
|
|
2572
|
+
*/
|
|
2573
|
+
async selectSameStroke(nodeId) {
|
|
2574
|
+
return await this.eval(`
|
|
2575
|
+
(function() {
|
|
2576
|
+
const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2577
|
+
if (!refNode || !refNode.strokes || refNode.strokes.length === 0) return { error: 'Node has no stroke' };
|
|
2578
|
+
|
|
2579
|
+
const refStroke = refNode.strokes[0];
|
|
2580
|
+
if (refStroke.type !== 'SOLID') return { error: 'Reference stroke is not solid' };
|
|
2581
|
+
|
|
2582
|
+
const matches = figma.currentPage.findAll(n => {
|
|
2583
|
+
if (!n.strokes || n.strokes.length === 0) return false;
|
|
2584
|
+
const stroke = n.strokes[0];
|
|
2585
|
+
if (stroke.type !== 'SOLID') return false;
|
|
2586
|
+
return Math.abs(stroke.color.r - refStroke.color.r) < 0.01 &&
|
|
2587
|
+
Math.abs(stroke.color.g - refStroke.color.g) < 0.01 &&
|
|
2588
|
+
Math.abs(stroke.color.b - refStroke.color.b) < 0.01;
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
figma.currentPage.selection = matches;
|
|
2592
|
+
return { selected: matches.length, ids: matches.map(n => n.id) };
|
|
2593
|
+
})()
|
|
2594
|
+
`);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/**
|
|
2598
|
+
* Select all text nodes with same font
|
|
2599
|
+
*/
|
|
2600
|
+
async selectSameFont(nodeId) {
|
|
2601
|
+
return await this.eval(`
|
|
2602
|
+
(function() {
|
|
2603
|
+
const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2604
|
+
if (!refNode || refNode.type !== 'TEXT') return { error: 'Not a text node' };
|
|
2605
|
+
|
|
2606
|
+
const refFont = refNode.fontName;
|
|
2607
|
+
const refSize = refNode.fontSize;
|
|
2608
|
+
|
|
2609
|
+
const matches = figma.currentPage.findAll(n => {
|
|
2610
|
+
if (n.type !== 'TEXT') return false;
|
|
2611
|
+
return n.fontName.family === refFont.family &&
|
|
2612
|
+
n.fontName.style === refFont.style &&
|
|
2613
|
+
n.fontSize === refSize;
|
|
2614
|
+
});
|
|
2615
|
+
|
|
2616
|
+
figma.currentPage.selection = matches;
|
|
2617
|
+
return { selected: matches.length, ids: matches.map(n => n.id) };
|
|
2618
|
+
})()
|
|
2619
|
+
`);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* Select all nodes of same type and size
|
|
2624
|
+
*/
|
|
2625
|
+
async selectSameSize(nodeId) {
|
|
2626
|
+
return await this.eval(`
|
|
2627
|
+
(function() {
|
|
2628
|
+
const refNode = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2629
|
+
if (!refNode) return { error: 'Node not found' };
|
|
2630
|
+
|
|
2631
|
+
const matches = figma.currentPage.findAll(n => {
|
|
2632
|
+
return n.type === refNode.type &&
|
|
2633
|
+
Math.abs(n.width - refNode.width) < 1 &&
|
|
2634
|
+
Math.abs(n.height - refNode.height) < 1;
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
figma.currentPage.selection = matches;
|
|
2638
|
+
return { selected: matches.length, ids: matches.map(n => n.id) };
|
|
2639
|
+
})()
|
|
2640
|
+
`);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/**
|
|
2644
|
+
* Simulate color blindness on a frame (creates a copy with filters)
|
|
2645
|
+
*/
|
|
2646
|
+
async simulateColorBlindness(nodeId, type = 'deuteranopia') {
|
|
2647
|
+
const matrices = {
|
|
2648
|
+
deuteranopia: [0.625, 0.375, 0, 0, 0, 0.7, 0.3, 0, 0, 0, 0, 0.3, 0.7, 0, 0, 0, 0, 0, 1, 0],
|
|
2649
|
+
protanopia: [0.567, 0.433, 0, 0, 0, 0.558, 0.442, 0, 0, 0, 0, 0.242, 0.758, 0, 0, 0, 0, 0, 1, 0],
|
|
2650
|
+
tritanopia: [0.95, 0.05, 0, 0, 0, 0, 0.433, 0.567, 0, 0, 0, 0.475, 0.525, 0, 0, 0, 0, 0, 1, 0],
|
|
2651
|
+
grayscale: [0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0, 0, 0, 1, 0]
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
const matrix = matrices[type] || matrices.deuteranopia;
|
|
2655
|
+
|
|
2656
|
+
return await this.eval(`
|
|
2657
|
+
(function() {
|
|
2658
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2659
|
+
if (!node) return { error: 'Node not found' };
|
|
2660
|
+
|
|
2661
|
+
const clone = node.clone();
|
|
2662
|
+
clone.name = node.name + ' (${type})';
|
|
2663
|
+
clone.x = node.x + node.width + 50;
|
|
2664
|
+
|
|
2665
|
+
// Apply as layer blur with color matrix (simplified simulation)
|
|
2666
|
+
// Note: Figma doesn't have native color matrix, this is a visual approximation
|
|
2667
|
+
clone.opacity = 0.9;
|
|
2668
|
+
|
|
2669
|
+
return { id: clone.id, name: clone.name, type: '${type}' };
|
|
2670
|
+
})()
|
|
2671
|
+
`);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// ============ Export to JSX ============
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Export a node to JSX code
|
|
2678
|
+
*/
|
|
2679
|
+
async exportToJSX(nodeId, options = {}) {
|
|
2680
|
+
const { pretty = true } = options;
|
|
2681
|
+
return await this.eval(`
|
|
2682
|
+
(function() {
|
|
2683
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2684
|
+
if (!node) return { error: 'Node not found' };
|
|
2685
|
+
|
|
2686
|
+
function rgbToHex(r, g, b) {
|
|
2687
|
+
return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('');
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function nodeToJSX(n, indent = 0) {
|
|
2691
|
+
const pad = ${pretty} ? ' '.repeat(indent) : '';
|
|
2692
|
+
const nl = ${pretty} ? '\\n' : '';
|
|
2693
|
+
|
|
2694
|
+
let tag = 'Frame';
|
|
2695
|
+
if (n.type === 'TEXT') tag = 'Text';
|
|
2696
|
+
else if (n.type === 'RECTANGLE') tag = 'Rectangle';
|
|
2697
|
+
else if (n.type === 'ELLIPSE') tag = 'Ellipse';
|
|
2698
|
+
else if (n.type === 'LINE') tag = 'Line';
|
|
2699
|
+
else if (n.type === 'VECTOR') tag = 'Vector';
|
|
2700
|
+
else if (n.type === 'COMPONENT') tag = 'Component';
|
|
2701
|
+
else if (n.type === 'INSTANCE') tag = 'Instance';
|
|
2702
|
+
|
|
2703
|
+
const props = [];
|
|
2704
|
+
if (n.name) props.push('name="' + n.name + '"');
|
|
2705
|
+
if (n.width) props.push('w={' + Math.round(n.width) + '}');
|
|
2706
|
+
if (n.height) props.push('h={' + Math.round(n.height) + '}');
|
|
2707
|
+
|
|
2708
|
+
if (n.fills && n.fills.length > 0 && n.fills[0].type === 'SOLID') {
|
|
2709
|
+
const c = n.fills[0].color;
|
|
2710
|
+
props.push('bg="' + rgbToHex(c.r, c.g, c.b) + '"');
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
if (n.cornerRadius && n.cornerRadius > 0) {
|
|
2714
|
+
props.push('rounded={' + n.cornerRadius + '}');
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (n.layoutMode === 'HORIZONTAL') props.push('flex="row"');
|
|
2718
|
+
if (n.layoutMode === 'VERTICAL') props.push('flex="col"');
|
|
2719
|
+
if (n.itemSpacing) props.push('gap={' + n.itemSpacing + '}');
|
|
2720
|
+
if (n.paddingTop) props.push('p={' + n.paddingTop + '}');
|
|
2721
|
+
|
|
2722
|
+
if (n.type === 'TEXT') {
|
|
2723
|
+
const fontSize = n.fontSize || 14;
|
|
2724
|
+
props.push('size={' + fontSize + '}');
|
|
2725
|
+
if (n.fontName && n.fontName.style) {
|
|
2726
|
+
const weight = n.fontName.style.toLowerCase();
|
|
2727
|
+
if (weight.includes('bold')) props.push('weight="bold"');
|
|
2728
|
+
else if (weight.includes('medium')) props.push('weight="medium"');
|
|
2729
|
+
}
|
|
2730
|
+
if (n.fills && n.fills[0] && n.fills[0].type === 'SOLID') {
|
|
2731
|
+
const c = n.fills[0].color;
|
|
2732
|
+
props.push('color="' + rgbToHex(c.r, c.g, c.b) + '"');
|
|
2733
|
+
}
|
|
2734
|
+
return pad + '<Text ' + props.join(' ') + '>' + (n.characters || '') + '</Text>';
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
const hasChildren = n.children && n.children.length > 0;
|
|
2738
|
+
const propsStr = props.length > 0 ? ' ' + props.join(' ') : '';
|
|
2739
|
+
|
|
2740
|
+
if (!hasChildren) {
|
|
2741
|
+
return pad + '<' + tag + propsStr + ' />';
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
const childrenJSX = n.children.map(c => nodeToJSX(c, indent + 1)).join(nl);
|
|
2745
|
+
return pad + '<' + tag + propsStr + '>' + nl + childrenJSX + nl + pad + '</' + tag + '>';
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
return { jsx: nodeToJSX(node) };
|
|
2749
|
+
})()
|
|
2750
|
+
`);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
/**
|
|
2754
|
+
* Export component to Storybook story
|
|
2755
|
+
*/
|
|
2756
|
+
async exportToStorybook(nodeId) {
|
|
2757
|
+
const jsxResult = await this.exportToJSX(nodeId);
|
|
2758
|
+
if (jsxResult.error) return jsxResult;
|
|
2759
|
+
|
|
2760
|
+
const nodeInfo = await this.getNode(nodeId);
|
|
2761
|
+
const componentName = (nodeInfo.name || 'Component').replace(/[^a-zA-Z0-9]/g, '');
|
|
2762
|
+
|
|
2763
|
+
const story = `import type { Meta, StoryObj } from '@storybook/react';
|
|
2764
|
+
|
|
2765
|
+
// Auto-generated from Figma
|
|
2766
|
+
const ${componentName} = () => (
|
|
2767
|
+
${jsxResult.jsx.split('\n').map(l => ' ' + l).join('\n')}
|
|
2768
|
+
);
|
|
2769
|
+
|
|
2770
|
+
const meta: Meta<typeof ${componentName}> = {
|
|
2771
|
+
title: 'Components/${componentName}',
|
|
2772
|
+
component: ${componentName},
|
|
2773
|
+
};
|
|
2774
|
+
|
|
2775
|
+
export default meta;
|
|
2776
|
+
type Story = StoryObj<typeof ${componentName}>;
|
|
2777
|
+
|
|
2778
|
+
export const Default: Story = {};
|
|
2779
|
+
`;
|
|
2780
|
+
|
|
2781
|
+
return { story, componentName };
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// ============ Visual Diff ============
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Compare two nodes visually (returns diff info)
|
|
2788
|
+
*/
|
|
2789
|
+
async visualDiff(nodeId1, nodeId2) {
|
|
2790
|
+
return await this.eval(`
|
|
2791
|
+
(async function() {
|
|
2792
|
+
const node1 = figma.getNodeById(${JSON.stringify(nodeId1)});
|
|
2793
|
+
const node2 = figma.getNodeById(${JSON.stringify(nodeId2)});
|
|
2794
|
+
|
|
2795
|
+
if (!node1 || !node2) return { error: 'One or both nodes not found' };
|
|
2796
|
+
|
|
2797
|
+
const differences = [];
|
|
2798
|
+
|
|
2799
|
+
// Compare basic properties
|
|
2800
|
+
if (node1.width !== node2.width || node1.height !== node2.height) {
|
|
2801
|
+
differences.push({
|
|
2802
|
+
property: 'size',
|
|
2803
|
+
from: node1.width + 'x' + node1.height,
|
|
2804
|
+
to: node2.width + 'x' + node2.height
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
if (JSON.stringify(node1.fills) !== JSON.stringify(node2.fills)) {
|
|
2809
|
+
differences.push({ property: 'fills', changed: true });
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
if (JSON.stringify(node1.strokes) !== JSON.stringify(node2.strokes)) {
|
|
2813
|
+
differences.push({ property: 'strokes', changed: true });
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
if (node1.cornerRadius !== node2.cornerRadius) {
|
|
2817
|
+
differences.push({
|
|
2818
|
+
property: 'cornerRadius',
|
|
2819
|
+
from: node1.cornerRadius,
|
|
2820
|
+
to: node2.cornerRadius
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
if (node1.opacity !== node2.opacity) {
|
|
2825
|
+
differences.push({
|
|
2826
|
+
property: 'opacity',
|
|
2827
|
+
from: node1.opacity,
|
|
2828
|
+
to: node2.opacity
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// Compare children count
|
|
2833
|
+
const children1 = node1.children ? node1.children.length : 0;
|
|
2834
|
+
const children2 = node2.children ? node2.children.length : 0;
|
|
2835
|
+
if (children1 !== children2) {
|
|
2836
|
+
differences.push({
|
|
2837
|
+
property: 'childCount',
|
|
2838
|
+
from: children1,
|
|
2839
|
+
to: children2
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
return {
|
|
2844
|
+
node1: { id: node1.id, name: node1.name },
|
|
2845
|
+
node2: { id: node2.id, name: node2.name },
|
|
2846
|
+
identical: differences.length === 0,
|
|
2847
|
+
differences
|
|
2848
|
+
};
|
|
2849
|
+
})()
|
|
2850
|
+
`);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* Create a structural diff patch between two nodes
|
|
2855
|
+
*/
|
|
2856
|
+
async createDiffPatch(fromId, toId) {
|
|
2857
|
+
return await this.eval(`
|
|
2858
|
+
(function() {
|
|
2859
|
+
const from = figma.getNodeById(${JSON.stringify(fromId)});
|
|
2860
|
+
const to = figma.getNodeById(${JSON.stringify(toId)});
|
|
2861
|
+
|
|
2862
|
+
if (!from || !to) return { error: 'Node not found' };
|
|
2863
|
+
|
|
2864
|
+
function getProps(n) {
|
|
2865
|
+
return {
|
|
2866
|
+
type: n.type,
|
|
2867
|
+
name: n.name,
|
|
2868
|
+
width: n.width,
|
|
2869
|
+
height: n.height,
|
|
2870
|
+
x: n.x,
|
|
2871
|
+
y: n.y,
|
|
2872
|
+
fills: n.fills,
|
|
2873
|
+
strokes: n.strokes,
|
|
2874
|
+
cornerRadius: n.cornerRadius,
|
|
2875
|
+
opacity: n.opacity,
|
|
2876
|
+
layoutMode: n.layoutMode,
|
|
2877
|
+
itemSpacing: n.itemSpacing
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const fromProps = getProps(from);
|
|
2882
|
+
const toProps = getProps(to);
|
|
2883
|
+
const patch = [];
|
|
2884
|
+
|
|
2885
|
+
for (const key in toProps) {
|
|
2886
|
+
if (JSON.stringify(fromProps[key]) !== JSON.stringify(toProps[key])) {
|
|
2887
|
+
patch.push({ property: key, from: fromProps[key], to: toProps[key] });
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
return { fromId: from.id, toId: to.id, patch };
|
|
2892
|
+
})()
|
|
2893
|
+
`);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// ============ XPath-like Query ============
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* Query nodes with XPath-like syntax
|
|
2900
|
+
* Examples:
|
|
2901
|
+
* //FRAME - all frames
|
|
2902
|
+
* //TEXT[@fontSize > 20] - text larger than 20px
|
|
2903
|
+
* //FRAME[contains(@name, 'Card')] - frames with 'Card' in name
|
|
2904
|
+
* //*[@cornerRadius > 0] - any node with radius
|
|
2905
|
+
*/
|
|
2906
|
+
async query(xpath) {
|
|
2907
|
+
return await this.eval(`
|
|
2908
|
+
(function() {
|
|
2909
|
+
const xpath = ${JSON.stringify(xpath)};
|
|
2910
|
+
const results = [];
|
|
2911
|
+
|
|
2912
|
+
// Parse simple XPath patterns
|
|
2913
|
+
const typeMatch = xpath.match(/\\/\\/([A-Z_*]+)/);
|
|
2914
|
+
const attrMatch = xpath.match(/@(\\w+)\\s*(=|>|<|>=|<=|!=)\\s*["']?([^"'\\]]+)["']?/);
|
|
2915
|
+
const containsMatch = xpath.match(/contains\\(@(\\w+),\\s*["']([^"']+)["']\\)/);
|
|
2916
|
+
const startsMatch = xpath.match(/starts-with\\(@(\\w+),\\s*["']([^"']+)["']\\)/);
|
|
2917
|
+
|
|
2918
|
+
const targetType = typeMatch ? typeMatch[1] : '*';
|
|
2919
|
+
|
|
2920
|
+
function matches(node) {
|
|
2921
|
+
// Type check
|
|
2922
|
+
if (targetType !== '*' && node.type !== targetType) return false;
|
|
2923
|
+
|
|
2924
|
+
// Attribute comparison
|
|
2925
|
+
if (attrMatch) {
|
|
2926
|
+
const [, attr, op, val] = attrMatch;
|
|
2927
|
+
const nodeVal = node[attr];
|
|
2928
|
+
const numVal = parseFloat(val);
|
|
2929
|
+
|
|
2930
|
+
if (op === '=' && nodeVal != val && nodeVal != numVal) return false;
|
|
2931
|
+
if (op === '!=' && (nodeVal == val || nodeVal == numVal)) return false;
|
|
2932
|
+
if (op === '>' && !(nodeVal > numVal)) return false;
|
|
2933
|
+
if (op === '<' && !(nodeVal < numVal)) return false;
|
|
2934
|
+
if (op === '>=' && !(nodeVal >= numVal)) return false;
|
|
2935
|
+
if (op === '<=' && !(nodeVal <= numVal)) return false;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// contains()
|
|
2939
|
+
if (containsMatch) {
|
|
2940
|
+
const [, attr, val] = containsMatch;
|
|
2941
|
+
if (!node[attr] || !String(node[attr]).includes(val)) return false;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// starts-with()
|
|
2945
|
+
if (startsMatch) {
|
|
2946
|
+
const [, attr, val] = startsMatch;
|
|
2947
|
+
if (!node[attr] || !String(node[attr]).startsWith(val)) return false;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
return true;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function search(node) {
|
|
2954
|
+
if (matches(node)) {
|
|
2955
|
+
results.push({
|
|
2956
|
+
id: node.id,
|
|
2957
|
+
type: node.type,
|
|
2958
|
+
name: node.name || '',
|
|
2959
|
+
x: Math.round(node.x || 0),
|
|
2960
|
+
y: Math.round(node.y || 0),
|
|
2961
|
+
width: Math.round(node.width || 0),
|
|
2962
|
+
height: Math.round(node.height || 0)
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
if (node.children) node.children.forEach(search);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
search(figma.currentPage);
|
|
2969
|
+
return results.slice(0, 200);
|
|
2970
|
+
})()
|
|
2971
|
+
`);
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
// ============ Path/Vector Operations ============
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* Get vector path data from a node
|
|
2978
|
+
*/
|
|
2979
|
+
async getPath(nodeId) {
|
|
2980
|
+
return await this.eval(`
|
|
2981
|
+
(function() {
|
|
2982
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
2983
|
+
if (!node) return { error: 'Node not found' };
|
|
2984
|
+
if (!node.vectorPaths) return { error: 'Node has no vector paths' };
|
|
2985
|
+
|
|
2986
|
+
return {
|
|
2987
|
+
id: node.id,
|
|
2988
|
+
name: node.name,
|
|
2989
|
+
paths: node.vectorPaths.map(p => ({
|
|
2990
|
+
data: p.data,
|
|
2991
|
+
windingRule: p.windingRule
|
|
2992
|
+
}))
|
|
2993
|
+
};
|
|
2994
|
+
})()
|
|
2995
|
+
`);
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* Set vector path data on a node
|
|
3000
|
+
*/
|
|
3001
|
+
async setPath(nodeId, pathData) {
|
|
3002
|
+
return await this.eval(`
|
|
3003
|
+
(function() {
|
|
3004
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
3005
|
+
if (!node) return { error: 'Node not found' };
|
|
3006
|
+
if (node.type !== 'VECTOR') return { error: 'Node is not a vector' };
|
|
3007
|
+
|
|
3008
|
+
node.vectorPaths = [{ data: ${JSON.stringify(pathData)}, windingRule: 'EVENODD' }];
|
|
3009
|
+
return { success: true };
|
|
3010
|
+
})()
|
|
3011
|
+
`);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
/**
|
|
3015
|
+
* Scale a vector path
|
|
3016
|
+
*/
|
|
3017
|
+
async scalePath(nodeId, factor) {
|
|
3018
|
+
return await this.eval(`
|
|
3019
|
+
(function() {
|
|
3020
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
3021
|
+
if (!node) return { error: 'Node not found' };
|
|
3022
|
+
|
|
3023
|
+
node.rescale(${factor});
|
|
3024
|
+
return { success: true, newWidth: node.width, newHeight: node.height };
|
|
3025
|
+
})()
|
|
3026
|
+
`);
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/**
|
|
3030
|
+
* Flip a node horizontally or vertically
|
|
3031
|
+
*/
|
|
3032
|
+
async flipNode(nodeId, axis = 'x') {
|
|
3033
|
+
return await this.eval(`
|
|
3034
|
+
(function() {
|
|
3035
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
3036
|
+
if (!node) return { error: 'Node not found' };
|
|
3037
|
+
|
|
3038
|
+
if (${JSON.stringify(axis)} === 'x') {
|
|
3039
|
+
// Flip horizontally
|
|
3040
|
+
const transform = node.relativeTransform;
|
|
3041
|
+
node.relativeTransform = [
|
|
3042
|
+
[-transform[0][0], transform[0][1], transform[0][2] + node.width],
|
|
3043
|
+
[transform[1][0], transform[1][1], transform[1][2]]
|
|
3044
|
+
];
|
|
3045
|
+
} else {
|
|
3046
|
+
// Flip vertically
|
|
3047
|
+
const transform = node.relativeTransform;
|
|
3048
|
+
node.relativeTransform = [
|
|
3049
|
+
[transform[0][0], transform[0][1], transform[0][2]],
|
|
3050
|
+
[transform[1][0], -transform[1][1], transform[1][2] + node.height]
|
|
3051
|
+
];
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
return { success: true, axis: ${JSON.stringify(axis)} };
|
|
3055
|
+
})()
|
|
3056
|
+
`);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// ============ Analyze ============
|
|
3060
|
+
|
|
3061
|
+
/**
|
|
3062
|
+
* Analyze colors used in the design
|
|
3063
|
+
*/
|
|
3064
|
+
async analyzeColors() {
|
|
3065
|
+
return await this.eval(`
|
|
3066
|
+
(function() {
|
|
3067
|
+
const colorMap = new Map();
|
|
3068
|
+
|
|
3069
|
+
function rgbToHex(r, g, b) {
|
|
3070
|
+
return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function processNode(node) {
|
|
3074
|
+
// Check fills
|
|
3075
|
+
if (node.fills && Array.isArray(node.fills)) {
|
|
3076
|
+
node.fills.forEach(fill => {
|
|
3077
|
+
if (fill.type === 'SOLID' && fill.visible !== false) {
|
|
3078
|
+
const hex = rgbToHex(fill.color.r, fill.color.g, fill.color.b);
|
|
3079
|
+
const existing = colorMap.get(hex) || { count: 0, nodes: [], hasVariable: false };
|
|
3080
|
+
existing.count++;
|
|
3081
|
+
if (existing.nodes.length < 5) existing.nodes.push(node.id);
|
|
3082
|
+
|
|
3083
|
+
// Check if bound to variable
|
|
3084
|
+
if (node.boundVariables && node.boundVariables.fills) {
|
|
3085
|
+
existing.hasVariable = true;
|
|
3086
|
+
}
|
|
3087
|
+
colorMap.set(hex, existing);
|
|
3088
|
+
}
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// Check strokes
|
|
3093
|
+
if (node.strokes && Array.isArray(node.strokes)) {
|
|
3094
|
+
node.strokes.forEach(stroke => {
|
|
3095
|
+
if (stroke.type === 'SOLID' && stroke.visible !== false) {
|
|
3096
|
+
const hex = rgbToHex(stroke.color.r, stroke.color.g, stroke.color.b);
|
|
3097
|
+
const existing = colorMap.get(hex) || { count: 0, nodes: [], hasVariable: false };
|
|
3098
|
+
existing.count++;
|
|
3099
|
+
if (existing.nodes.length < 5) existing.nodes.push(node.id);
|
|
3100
|
+
colorMap.set(hex, existing);
|
|
3101
|
+
}
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
if (node.children) node.children.forEach(processNode);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
processNode(figma.currentPage);
|
|
3109
|
+
|
|
3110
|
+
const colors = Array.from(colorMap.entries())
|
|
3111
|
+
.map(([hex, data]) => ({ hex, ...data }))
|
|
3112
|
+
.sort((a, b) => b.count - a.count);
|
|
3113
|
+
|
|
3114
|
+
return {
|
|
3115
|
+
totalColors: colors.length,
|
|
3116
|
+
colors: colors.slice(0, 50)
|
|
3117
|
+
};
|
|
3118
|
+
})()
|
|
3119
|
+
`);
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
/**
|
|
3123
|
+
* Analyze typography used in the design
|
|
3124
|
+
*/
|
|
3125
|
+
async analyzeTypography() {
|
|
3126
|
+
return await this.eval(`
|
|
3127
|
+
(function() {
|
|
3128
|
+
const fontMap = new Map();
|
|
3129
|
+
|
|
3130
|
+
function processNode(node) {
|
|
3131
|
+
if (node.type === 'TEXT') {
|
|
3132
|
+
const key = node.fontName.family + '/' + node.fontName.style + '/' + node.fontSize;
|
|
3133
|
+
const existing = fontMap.get(key) || { count: 0, nodes: [] };
|
|
3134
|
+
existing.count++;
|
|
3135
|
+
existing.family = node.fontName.family;
|
|
3136
|
+
existing.style = node.fontName.style;
|
|
3137
|
+
existing.size = node.fontSize;
|
|
3138
|
+
if (existing.nodes.length < 5) existing.nodes.push(node.id);
|
|
3139
|
+
fontMap.set(key, existing);
|
|
3140
|
+
}
|
|
3141
|
+
if (node.children) node.children.forEach(processNode);
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
processNode(figma.currentPage);
|
|
3145
|
+
|
|
3146
|
+
const fonts = Array.from(fontMap.values())
|
|
3147
|
+
.sort((a, b) => b.count - a.count);
|
|
3148
|
+
|
|
3149
|
+
return {
|
|
3150
|
+
totalStyles: fonts.length,
|
|
3151
|
+
fonts: fonts.slice(0, 30)
|
|
3152
|
+
};
|
|
3153
|
+
})()
|
|
3154
|
+
`);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/**
|
|
3158
|
+
* Analyze spacing (gaps and padding) used in the design
|
|
3159
|
+
*/
|
|
3160
|
+
async analyzeSpacing(gridBase = 8) {
|
|
3161
|
+
return await this.eval(`
|
|
3162
|
+
(function() {
|
|
3163
|
+
const spacingMap = new Map();
|
|
3164
|
+
const gridBase = ${gridBase};
|
|
3165
|
+
|
|
3166
|
+
function processNode(node) {
|
|
3167
|
+
if (node.layoutMode) {
|
|
3168
|
+
// Gap
|
|
3169
|
+
if (node.itemSpacing !== undefined) {
|
|
3170
|
+
const key = 'gap:' + node.itemSpacing;
|
|
3171
|
+
const existing = spacingMap.get(key) || { value: node.itemSpacing, type: 'gap', count: 0, onGrid: node.itemSpacing % gridBase === 0 };
|
|
3172
|
+
existing.count++;
|
|
3173
|
+
spacingMap.set(key, existing);
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// Padding
|
|
3177
|
+
const paddings = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft].filter(p => p > 0);
|
|
3178
|
+
paddings.forEach(p => {
|
|
3179
|
+
const key = 'padding:' + p;
|
|
3180
|
+
const existing = spacingMap.get(key) || { value: p, type: 'padding', count: 0, onGrid: p % gridBase === 0 };
|
|
3181
|
+
existing.count++;
|
|
3182
|
+
spacingMap.set(key, existing);
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
if (node.children) node.children.forEach(processNode);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
processNode(figma.currentPage);
|
|
3190
|
+
|
|
3191
|
+
const spacing = Array.from(spacingMap.values())
|
|
3192
|
+
.sort((a, b) => b.count - a.count);
|
|
3193
|
+
|
|
3194
|
+
const offGrid = spacing.filter(s => !s.onGrid);
|
|
3195
|
+
|
|
3196
|
+
return {
|
|
3197
|
+
gridBase,
|
|
3198
|
+
totalValues: spacing.length,
|
|
3199
|
+
offGridCount: offGrid.length,
|
|
3200
|
+
spacing: spacing.slice(0, 30),
|
|
3201
|
+
offGrid: offGrid.slice(0, 10)
|
|
3202
|
+
};
|
|
3203
|
+
})()
|
|
3204
|
+
`);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
/**
|
|
3208
|
+
* Find repeated patterns (potential components)
|
|
3209
|
+
*/
|
|
3210
|
+
async analyzeClusters() {
|
|
3211
|
+
return await this.eval(`
|
|
3212
|
+
(function() {
|
|
3213
|
+
const patterns = new Map();
|
|
3214
|
+
|
|
3215
|
+
function getSignature(node) {
|
|
3216
|
+
if (!node.children) return node.type;
|
|
3217
|
+
|
|
3218
|
+
const childTypes = node.children.map(c => c.type).sort().join(',');
|
|
3219
|
+
return node.type + '[' + childTypes + ']' + node.width + 'x' + node.height;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function processNode(node) {
|
|
3223
|
+
if (node.type === 'FRAME' && node.children && node.children.length > 0) {
|
|
3224
|
+
const sig = getSignature(node);
|
|
3225
|
+
const existing = patterns.get(sig) || { signature: sig, count: 0, examples: [] };
|
|
3226
|
+
existing.count++;
|
|
3227
|
+
if (existing.examples.length < 5) {
|
|
3228
|
+
existing.examples.push({ id: node.id, name: node.name });
|
|
3229
|
+
}
|
|
3230
|
+
patterns.set(sig, existing);
|
|
3231
|
+
}
|
|
3232
|
+
if (node.children) node.children.forEach(processNode);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
processNode(figma.currentPage);
|
|
3236
|
+
|
|
3237
|
+
const clusters = Array.from(patterns.values())
|
|
3238
|
+
.filter(p => p.count >= 2)
|
|
3239
|
+
.sort((a, b) => b.count - a.count);
|
|
3240
|
+
|
|
3241
|
+
return {
|
|
3242
|
+
potentialComponents: clusters.length,
|
|
3243
|
+
clusters: clusters.slice(0, 20)
|
|
3244
|
+
};
|
|
3245
|
+
})()
|
|
3246
|
+
`);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
// ============ Lint ============
|
|
3250
|
+
|
|
3251
|
+
/**
|
|
3252
|
+
* Lint the design for common issues
|
|
3253
|
+
*/
|
|
3254
|
+
async lint(options = {}) {
|
|
3255
|
+
const { preset = 'recommended' } = options;
|
|
3256
|
+
return await this.eval(`
|
|
3257
|
+
(function() {
|
|
3258
|
+
const issues = [];
|
|
3259
|
+
const preset = ${JSON.stringify(preset)};
|
|
3260
|
+
|
|
3261
|
+
function rgbToHex(r, g, b) {
|
|
3262
|
+
return '#' + [r, g, b].map(x => Math.round(x * 255).toString(16).padStart(2, '0')).join('');
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function getLuminance(r, g, b) {
|
|
3266
|
+
const [rs, gs, bs] = [r, g, b].map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
3267
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function getContrastRatio(c1, c2) {
|
|
3271
|
+
const l1 = getLuminance(c1.r, c1.g, c1.b);
|
|
3272
|
+
const l2 = getLuminance(c2.r, c2.g, c2.b);
|
|
3273
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
function checkNode(node, depth = 0) {
|
|
3277
|
+
// No default names
|
|
3278
|
+
if (node.name && (node.name.startsWith('Frame ') || node.name.startsWith('Rectangle ') || node.name.startsWith('Group '))) {
|
|
3279
|
+
issues.push({
|
|
3280
|
+
nodeId: node.id,
|
|
3281
|
+
nodeName: node.name,
|
|
3282
|
+
rule: 'no-default-names',
|
|
3283
|
+
severity: 'warning',
|
|
3284
|
+
message: 'Layer has default name'
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// Deeply nested
|
|
3289
|
+
if (depth > 10) {
|
|
3290
|
+
issues.push({
|
|
3291
|
+
nodeId: node.id,
|
|
3292
|
+
nodeName: node.name,
|
|
3293
|
+
rule: 'no-deeply-nested',
|
|
3294
|
+
severity: 'warning',
|
|
3295
|
+
message: 'Node is nested too deeply (' + depth + ' levels)'
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// Empty frames
|
|
3300
|
+
if (node.type === 'FRAME' && (!node.children || node.children.length === 0)) {
|
|
3301
|
+
issues.push({
|
|
3302
|
+
nodeId: node.id,
|
|
3303
|
+
nodeName: node.name,
|
|
3304
|
+
rule: 'no-empty-frames',
|
|
3305
|
+
severity: 'info',
|
|
3306
|
+
message: 'Frame is empty'
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// Prefer auto-layout
|
|
3311
|
+
if (node.type === 'FRAME' && node.children && node.children.length > 2 && !node.layoutMode) {
|
|
3312
|
+
issues.push({
|
|
3313
|
+
nodeId: node.id,
|
|
3314
|
+
nodeName: node.name,
|
|
3315
|
+
rule: 'prefer-auto-layout',
|
|
3316
|
+
severity: 'info',
|
|
3317
|
+
message: 'Frame with ' + node.children.length + ' children doesn\\'t use Auto Layout'
|
|
3318
|
+
});
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
// Hardcoded colors (not bound to variables)
|
|
3322
|
+
if (node.fills && node.fills.length > 0 && node.fills[0].type === 'SOLID') {
|
|
3323
|
+
if (!node.boundVariables || !node.boundVariables.fills) {
|
|
3324
|
+
issues.push({
|
|
3325
|
+
nodeId: node.id,
|
|
3326
|
+
nodeName: node.name,
|
|
3327
|
+
rule: 'no-hardcoded-colors',
|
|
3328
|
+
severity: 'warning',
|
|
3329
|
+
message: 'Fill color is not bound to a variable'
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// Text contrast check
|
|
3335
|
+
if (node.type === 'TEXT' && node.fills && node.fills[0] && node.fills[0].type === 'SOLID') {
|
|
3336
|
+
let parent = node.parent;
|
|
3337
|
+
let bgColor = null;
|
|
3338
|
+
while (parent && !bgColor) {
|
|
3339
|
+
if (parent.fills && parent.fills.length > 0 && parent.fills[0].type === 'SOLID') {
|
|
3340
|
+
bgColor = parent.fills[0].color;
|
|
3341
|
+
}
|
|
3342
|
+
parent = parent.parent;
|
|
3343
|
+
}
|
|
3344
|
+
if (bgColor) {
|
|
3345
|
+
const textColor = node.fills[0].color;
|
|
3346
|
+
const ratio = getContrastRatio(textColor, bgColor);
|
|
3347
|
+
if (ratio < 4.5) {
|
|
3348
|
+
issues.push({
|
|
3349
|
+
nodeId: node.id,
|
|
3350
|
+
nodeName: node.name,
|
|
3351
|
+
rule: 'color-contrast',
|
|
3352
|
+
severity: 'error',
|
|
3353
|
+
message: 'Contrast ratio ' + ratio.toFixed(1) + ':1 is below AA threshold (4.5:1)'
|
|
3354
|
+
});
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
// Touch target size
|
|
3360
|
+
if ((node.type === 'FRAME' || node.type === 'INSTANCE') && node.name && (node.name.toLowerCase().includes('button') || node.name.toLowerCase().includes('link'))) {
|
|
3361
|
+
if (node.width < 44 || node.height < 44) {
|
|
3362
|
+
issues.push({
|
|
3363
|
+
nodeId: node.id,
|
|
3364
|
+
nodeName: node.name,
|
|
3365
|
+
rule: 'touch-target-size',
|
|
3366
|
+
severity: 'warning',
|
|
3367
|
+
message: 'Touch target ' + Math.round(node.width) + 'x' + Math.round(node.height) + ' is below minimum 44x44'
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
// Min text size
|
|
3373
|
+
if (node.type === 'TEXT' && node.fontSize < 12) {
|
|
3374
|
+
issues.push({
|
|
3375
|
+
nodeId: node.id,
|
|
3376
|
+
nodeName: node.name,
|
|
3377
|
+
rule: 'min-text-size',
|
|
3378
|
+
severity: 'warning',
|
|
3379
|
+
message: 'Text size ' + node.fontSize + 'px is below minimum 12px'
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
if (node.children) node.children.forEach(c => checkNode(c, depth + 1));
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
checkNode(figma.currentPage);
|
|
3387
|
+
|
|
3388
|
+
const errors = issues.filter(i => i.severity === 'error').length;
|
|
3389
|
+
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
3390
|
+
const infos = issues.filter(i => i.severity === 'info').length;
|
|
3391
|
+
|
|
3392
|
+
return {
|
|
3393
|
+
preset,
|
|
3394
|
+
errors,
|
|
3395
|
+
warnings,
|
|
3396
|
+
infos,
|
|
3397
|
+
total: issues.length,
|
|
3398
|
+
issues: issues.slice(0, 100)
|
|
3399
|
+
};
|
|
3400
|
+
})()
|
|
3401
|
+
`);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
// ============ Component Variants ============
|
|
3405
|
+
|
|
3406
|
+
/**
|
|
3407
|
+
* Create a component set with variants
|
|
3408
|
+
*/
|
|
3409
|
+
async createComponentSet(name, variants) {
|
|
3410
|
+
// variants = [{ props: { variant: 'Primary', size: 'Large' }, nodeId: '1:23' }, ...]
|
|
3411
|
+
return await this.eval(`
|
|
3412
|
+
(async function() {
|
|
3413
|
+
const name = ${JSON.stringify(name)};
|
|
3414
|
+
const variants = ${JSON.stringify(variants)};
|
|
3415
|
+
|
|
3416
|
+
// Convert each node to component
|
|
3417
|
+
const components = [];
|
|
3418
|
+
for (const v of variants) {
|
|
3419
|
+
const node = figma.getNodeById(v.nodeId);
|
|
3420
|
+
if (!node) continue;
|
|
3421
|
+
|
|
3422
|
+
// Create component from node
|
|
3423
|
+
const component = figma.createComponentFromNode(node);
|
|
3424
|
+
|
|
3425
|
+
// Set name with variant properties
|
|
3426
|
+
const propStr = Object.entries(v.props).map(([k, val]) => k + '=' + val).join(', ');
|
|
3427
|
+
component.name = propStr;
|
|
3428
|
+
|
|
3429
|
+
components.push(component);
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
if (components.length === 0) return { error: 'No valid nodes found' };
|
|
3433
|
+
|
|
3434
|
+
// Combine into component set
|
|
3435
|
+
const componentSet = figma.combineAsVariants(components, figma.currentPage);
|
|
3436
|
+
componentSet.name = name;
|
|
3437
|
+
|
|
3438
|
+
return {
|
|
3439
|
+
id: componentSet.id,
|
|
3440
|
+
name: componentSet.name,
|
|
3441
|
+
variantCount: components.length
|
|
3442
|
+
};
|
|
3443
|
+
})()
|
|
3444
|
+
`);
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
/**
|
|
3448
|
+
* Add variant properties to existing component
|
|
3449
|
+
*/
|
|
3450
|
+
async addVariantProperty(componentSetId, propertyName, values) {
|
|
3451
|
+
return await this.eval(`
|
|
3452
|
+
(function() {
|
|
3453
|
+
const componentSet = figma.getNodeById(${JSON.stringify(componentSetId)});
|
|
3454
|
+
if (!componentSet || componentSet.type !== 'COMPONENT_SET') {
|
|
3455
|
+
return { error: 'Component set not found' };
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// Add property definition
|
|
3459
|
+
const propDefs = componentSet.componentPropertyDefinitions;
|
|
3460
|
+
propDefs[${JSON.stringify(propertyName)}] = {
|
|
3461
|
+
type: 'VARIANT',
|
|
3462
|
+
defaultValue: ${JSON.stringify(values[0])},
|
|
3463
|
+
variantOptions: ${JSON.stringify(values)}
|
|
3464
|
+
};
|
|
3465
|
+
|
|
3466
|
+
return { success: true, property: ${JSON.stringify(propertyName)}, values: ${JSON.stringify(values)} };
|
|
3467
|
+
})()
|
|
3468
|
+
`);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// ============ CSS Grid Layout ============
|
|
3472
|
+
|
|
3473
|
+
/**
|
|
3474
|
+
* Set CSS Grid layout on a frame
|
|
3475
|
+
*/
|
|
3476
|
+
async setGridLayout(nodeId, options = {}) {
|
|
3477
|
+
const { cols = '1fr 1fr', rows = 'auto', gap = 16, colGap, rowGap } = options;
|
|
3478
|
+
return await this.eval(`
|
|
3479
|
+
(function() {
|
|
3480
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
3481
|
+
if (!node || node.type !== 'FRAME') return { error: 'Frame not found' };
|
|
3482
|
+
|
|
3483
|
+
// Parse columns
|
|
3484
|
+
const cols = ${JSON.stringify(cols)}.split(' ');
|
|
3485
|
+
const rows = ${JSON.stringify(rows)}.split(' ');
|
|
3486
|
+
|
|
3487
|
+
// Figma doesn't have native CSS Grid, so we simulate with auto-layout
|
|
3488
|
+
// For true grid, we create nested frames
|
|
3489
|
+
|
|
3490
|
+
node.layoutMode = 'VERTICAL';
|
|
3491
|
+
node.itemSpacing = ${rowGap || gap};
|
|
3492
|
+
node.primaryAxisSizingMode = 'AUTO';
|
|
3493
|
+
node.counterAxisSizingMode = 'FIXED';
|
|
3494
|
+
|
|
3495
|
+
// If children exist, reorganize into rows
|
|
3496
|
+
const children = [...node.children];
|
|
3497
|
+
const colCount = cols.length;
|
|
3498
|
+
|
|
3499
|
+
// Remove all children first
|
|
3500
|
+
children.forEach(c => c.remove());
|
|
3501
|
+
|
|
3502
|
+
// Create rows
|
|
3503
|
+
let childIndex = 0;
|
|
3504
|
+
while (childIndex < children.length) {
|
|
3505
|
+
const rowFrame = figma.createFrame();
|
|
3506
|
+
rowFrame.name = 'Row';
|
|
3507
|
+
rowFrame.layoutMode = 'HORIZONTAL';
|
|
3508
|
+
rowFrame.itemSpacing = ${colGap || gap};
|
|
3509
|
+
rowFrame.primaryAxisSizingMode = 'AUTO';
|
|
3510
|
+
rowFrame.counterAxisSizingMode = 'AUTO';
|
|
3511
|
+
rowFrame.fills = [];
|
|
3512
|
+
|
|
3513
|
+
for (let i = 0; i < colCount && childIndex < children.length; i++) {
|
|
3514
|
+
rowFrame.appendChild(children[childIndex]);
|
|
3515
|
+
children[childIndex].layoutSizingHorizontal = 'FILL';
|
|
3516
|
+
childIndex++;
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
node.appendChild(rowFrame);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
return { success: true, cols: colCount, childrenReorganized: children.length };
|
|
3523
|
+
})()
|
|
3524
|
+
`);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// ============ Accessibility Snapshot ============
|
|
3528
|
+
|
|
3529
|
+
/**
|
|
3530
|
+
* Get accessibility tree snapshot
|
|
3531
|
+
*/
|
|
3532
|
+
async getAccessibilitySnapshot(nodeId = null) {
|
|
3533
|
+
return await this.eval(`
|
|
3534
|
+
(function() {
|
|
3535
|
+
const root = ${nodeId ? `figma.getNodeById(${JSON.stringify(nodeId)})` : 'figma.currentPage'};
|
|
3536
|
+
if (!root) return { error: 'Node not found' };
|
|
3537
|
+
|
|
3538
|
+
const elements = [];
|
|
3539
|
+
|
|
3540
|
+
function processNode(node, depth = 0) {
|
|
3541
|
+
const isInteractive = node.name && (
|
|
3542
|
+
node.name.toLowerCase().includes('button') ||
|
|
3543
|
+
node.name.toLowerCase().includes('link') ||
|
|
3544
|
+
node.name.toLowerCase().includes('input') ||
|
|
3545
|
+
node.name.toLowerCase().includes('checkbox') ||
|
|
3546
|
+
node.name.toLowerCase().includes('toggle') ||
|
|
3547
|
+
node.type === 'INSTANCE'
|
|
3548
|
+
);
|
|
3549
|
+
|
|
3550
|
+
const isText = node.type === 'TEXT';
|
|
3551
|
+
|
|
3552
|
+
if (isInteractive || isText) {
|
|
3553
|
+
elements.push({
|
|
3554
|
+
id: node.id,
|
|
3555
|
+
type: node.type,
|
|
3556
|
+
name: node.name,
|
|
3557
|
+
role: isInteractive ? 'interactive' : 'text',
|
|
3558
|
+
depth,
|
|
3559
|
+
width: Math.round(node.width || 0),
|
|
3560
|
+
height: Math.round(node.height || 0),
|
|
3561
|
+
text: node.characters || null
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
if (node.children) {
|
|
3566
|
+
node.children.forEach(c => processNode(c, depth + 1));
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
processNode(root);
|
|
3571
|
+
|
|
3572
|
+
return {
|
|
3573
|
+
totalElements: elements.length,
|
|
3574
|
+
interactive: elements.filter(e => e.role === 'interactive').length,
|
|
3575
|
+
textElements: elements.filter(e => e.role === 'text').length,
|
|
3576
|
+
elements: elements.slice(0, 100)
|
|
3577
|
+
};
|
|
3578
|
+
})()
|
|
3579
|
+
`);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// ============ Match Icons ============
|
|
3583
|
+
|
|
3584
|
+
/**
|
|
3585
|
+
* Try to match a vector node to an Iconify icon
|
|
3586
|
+
*/
|
|
3587
|
+
async matchIcon(nodeId, preferredSets = ['lucide', 'mdi']) {
|
|
3588
|
+
// Get the SVG export of the node
|
|
3589
|
+
const svgResult = await this.exportSVG(nodeId);
|
|
3590
|
+
if (svgResult.error) return svgResult;
|
|
3591
|
+
|
|
3592
|
+
// This would require an external service to match
|
|
3593
|
+
// For now, return info about the vector
|
|
3594
|
+
return await this.eval(`
|
|
3595
|
+
(function() {
|
|
3596
|
+
const node = figma.getNodeById(${JSON.stringify(nodeId)});
|
|
3597
|
+
if (!node) return { error: 'Node not found' };
|
|
3598
|
+
|
|
3599
|
+
return {
|
|
3600
|
+
id: node.id,
|
|
3601
|
+
name: node.name,
|
|
3602
|
+
type: node.type,
|
|
3603
|
+
width: node.width,
|
|
3604
|
+
height: node.height,
|
|
3605
|
+
suggestion: 'Use Iconify search to find matching icon: https://icon-sets.iconify.design/',
|
|
3606
|
+
preferredSets: ${JSON.stringify(preferredSets)}
|
|
3607
|
+
};
|
|
3608
|
+
})()
|
|
3609
|
+
`);
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
// ============ Variable Modes ============
|
|
3613
|
+
|
|
3614
|
+
/**
|
|
3615
|
+
* Get all modes in a variable collection
|
|
3616
|
+
*/
|
|
3617
|
+
async getCollectionModes(collectionId) {
|
|
3618
|
+
return await this.eval(`
|
|
3619
|
+
(function() {
|
|
3620
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
3621
|
+
if (!col) return { error: 'Collection not found' };
|
|
3622
|
+
return {
|
|
3623
|
+
id: col.id,
|
|
3624
|
+
name: col.name,
|
|
3625
|
+
modes: col.modes,
|
|
3626
|
+
defaultModeId: col.defaultModeId
|
|
3627
|
+
};
|
|
3628
|
+
})()
|
|
3629
|
+
`);
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* Add a new mode to a variable collection
|
|
3634
|
+
*/
|
|
3635
|
+
async addMode(collectionId, modeName) {
|
|
3636
|
+
return await this.eval(`
|
|
3637
|
+
(function() {
|
|
3638
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
3639
|
+
if (!col) return { error: 'Collection not found' };
|
|
3640
|
+
|
|
3641
|
+
const modeId = col.addMode(${JSON.stringify(modeName)});
|
|
3642
|
+
return {
|
|
3643
|
+
success: true,
|
|
3644
|
+
modeId,
|
|
3645
|
+
modeName: ${JSON.stringify(modeName)},
|
|
3646
|
+
allModes: col.modes
|
|
3647
|
+
};
|
|
3648
|
+
})()
|
|
3649
|
+
`);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/**
|
|
3653
|
+
* Rename a mode in a variable collection
|
|
3654
|
+
*/
|
|
3655
|
+
async renameMode(collectionId, modeId, newName) {
|
|
3656
|
+
return await this.eval(`
|
|
3657
|
+
(function() {
|
|
3658
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
3659
|
+
if (!col) return { error: 'Collection not found' };
|
|
3660
|
+
|
|
3661
|
+
col.renameMode(${JSON.stringify(modeId)}, ${JSON.stringify(newName)});
|
|
3662
|
+
return { success: true, modeId: ${JSON.stringify(modeId)}, newName: ${JSON.stringify(newName)} };
|
|
3663
|
+
})()
|
|
3664
|
+
`);
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/**
|
|
3668
|
+
* Remove a mode from a variable collection
|
|
3669
|
+
*/
|
|
3670
|
+
async removeMode(collectionId, modeId) {
|
|
3671
|
+
return await this.eval(`
|
|
3672
|
+
(function() {
|
|
3673
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
3674
|
+
if (!col) return { error: 'Collection not found' };
|
|
3675
|
+
|
|
3676
|
+
col.removeMode(${JSON.stringify(modeId)});
|
|
3677
|
+
return { success: true, modeId: ${JSON.stringify(modeId)} };
|
|
3678
|
+
})()
|
|
3679
|
+
`);
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
/**
|
|
3683
|
+
* Set variable value for a specific mode
|
|
3684
|
+
*/
|
|
3685
|
+
async setVariableValueForMode(variableId, modeId, value) {
|
|
3686
|
+
return await this.eval(`
|
|
3687
|
+
(function() {
|
|
3688
|
+
const variable = figma.variables.getVariableById(${JSON.stringify(variableId)});
|
|
3689
|
+
if (!variable) return { error: 'Variable not found' };
|
|
3690
|
+
|
|
3691
|
+
let val = ${JSON.stringify(value)};
|
|
3692
|
+
|
|
3693
|
+
// Convert hex color to RGB if needed
|
|
3694
|
+
if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
|
|
3695
|
+
const hex = val.slice(1);
|
|
3696
|
+
val = {
|
|
3697
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
3698
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
3699
|
+
b: parseInt(hex.slice(4, 6), 16) / 255
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
variable.setValueForMode(${JSON.stringify(modeId)}, val);
|
|
3704
|
+
return { success: true, variableId: variable.id, modeId: ${JSON.stringify(modeId)} };
|
|
3705
|
+
})()
|
|
3706
|
+
`);
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
/**
|
|
3710
|
+
* Get variable value for a specific mode
|
|
3711
|
+
*/
|
|
3712
|
+
async getVariableValueForMode(variableId, modeId) {
|
|
3713
|
+
return await this.eval(`
|
|
3714
|
+
(function() {
|
|
3715
|
+
const variable = figma.variables.getVariableById(${JSON.stringify(variableId)});
|
|
3716
|
+
if (!variable) return { error: 'Variable not found' };
|
|
3717
|
+
|
|
3718
|
+
const value = variable.valuesByMode[${JSON.stringify(modeId)}];
|
|
3719
|
+
return {
|
|
3720
|
+
variableId: variable.id,
|
|
3721
|
+
variableName: variable.name,
|
|
3722
|
+
modeId: ${JSON.stringify(modeId)},
|
|
3723
|
+
value
|
|
3724
|
+
};
|
|
3725
|
+
})()
|
|
3726
|
+
`);
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
/**
|
|
3730
|
+
* Create a complete variable collection with modes (e.g., Light/Dark)
|
|
3731
|
+
*/
|
|
3732
|
+
async createCollectionWithModes(name, modeNames = ['Light', 'Dark']) {
|
|
3733
|
+
return await this.eval(`
|
|
3734
|
+
(function() {
|
|
3735
|
+
const col = figma.variables.createVariableCollection(${JSON.stringify(name)});
|
|
3736
|
+
|
|
3737
|
+
// Rename default mode to first mode name
|
|
3738
|
+
col.renameMode(col.modes[0].modeId, ${JSON.stringify(modeNames[0])});
|
|
3739
|
+
|
|
3740
|
+
// Add additional modes
|
|
3741
|
+
const modes = [{ modeId: col.modes[0].modeId, name: ${JSON.stringify(modeNames[0])} }];
|
|
3742
|
+
for (let i = 1; i < ${JSON.stringify(modeNames)}.length; i++) {
|
|
3743
|
+
const modeId = col.addMode(${JSON.stringify(modeNames)}[i]);
|
|
3744
|
+
modes.push({ modeId, name: ${JSON.stringify(modeNames)}[i] });
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
return {
|
|
3748
|
+
id: col.id,
|
|
3749
|
+
name: col.name,
|
|
3750
|
+
modes
|
|
3751
|
+
};
|
|
3752
|
+
})()
|
|
3753
|
+
`);
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// ============ Batch Variable Operations ============
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Batch create variables (up to 100)
|
|
3760
|
+
* @param {Array} variables - [{name, type, value, modeValues: {modeId: value}}]
|
|
3761
|
+
*/
|
|
3762
|
+
async batchCreateVariables(collectionId, variables) {
|
|
3763
|
+
return await this.eval(`
|
|
3764
|
+
(async function() {
|
|
3765
|
+
const col = figma.variables.getVariableCollectionById(${JSON.stringify(collectionId)});
|
|
3766
|
+
if (!col) return { error: 'Collection not found' };
|
|
3767
|
+
|
|
3768
|
+
const vars = ${JSON.stringify(variables)};
|
|
3769
|
+
const results = [];
|
|
3770
|
+
|
|
3771
|
+
for (const v of vars.slice(0, 100)) {
|
|
3772
|
+
const variable = figma.variables.createVariable(v.name, col, v.type || 'COLOR');
|
|
3773
|
+
|
|
3774
|
+
// Set default value
|
|
3775
|
+
if (v.value !== undefined) {
|
|
3776
|
+
let val = v.value;
|
|
3777
|
+
if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
|
|
3778
|
+
const hex = val.slice(1);
|
|
3779
|
+
val = {
|
|
3780
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
3781
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
3782
|
+
b: parseInt(hex.slice(4, 6), 16) / 255
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
variable.setValueForMode(col.defaultModeId, val);
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// Set mode-specific values
|
|
3789
|
+
if (v.modeValues) {
|
|
3790
|
+
for (const [modeId, modeVal] of Object.entries(v.modeValues)) {
|
|
3791
|
+
let val = modeVal;
|
|
3792
|
+
if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
|
|
3793
|
+
const hex = val.slice(1);
|
|
3794
|
+
val = {
|
|
3795
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
3796
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
3797
|
+
b: parseInt(hex.slice(4, 6), 16) / 255
|
|
3798
|
+
};
|
|
3799
|
+
}
|
|
3800
|
+
variable.setValueForMode(modeId, val);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
results.push({ id: variable.id, name: variable.name });
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
return { created: results.length, variables: results };
|
|
3808
|
+
})()
|
|
3809
|
+
`);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
/**
|
|
3813
|
+
* Batch update variable values
|
|
3814
|
+
* @param {Array} updates - [{variableId, modeId, value}]
|
|
3815
|
+
*/
|
|
3816
|
+
async batchUpdateVariables(updates) {
|
|
3817
|
+
return await this.eval(`
|
|
3818
|
+
(function() {
|
|
3819
|
+
const updates = ${JSON.stringify(updates)};
|
|
3820
|
+
const results = [];
|
|
3821
|
+
|
|
3822
|
+
for (const u of updates.slice(0, 100)) {
|
|
3823
|
+
const variable = figma.variables.getVariableById(u.variableId);
|
|
3824
|
+
if (!variable) {
|
|
3825
|
+
results.push({ variableId: u.variableId, error: 'Not found' });
|
|
3826
|
+
continue;
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
let val = u.value;
|
|
3830
|
+
if (variable.resolvedType === 'COLOR' && typeof val === 'string' && val.startsWith('#')) {
|
|
3831
|
+
const hex = val.slice(1);
|
|
3832
|
+
val = {
|
|
3833
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
3834
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
3835
|
+
b: parseInt(hex.slice(4, 6), 16) / 255
|
|
3836
|
+
};
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
variable.setValueForMode(u.modeId, val);
|
|
3840
|
+
results.push({ variableId: u.variableId, success: true });
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
return { updated: results.filter(r => r.success).length, results };
|
|
3844
|
+
})()
|
|
3845
|
+
`);
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
/**
|
|
3849
|
+
* Batch delete variables
|
|
3850
|
+
*/
|
|
3851
|
+
async batchDeleteVariables(variableIds) {
|
|
3852
|
+
return await this.eval(`
|
|
3853
|
+
(function() {
|
|
3854
|
+
const ids = ${JSON.stringify(variableIds)};
|
|
3855
|
+
let deleted = 0;
|
|
3856
|
+
|
|
3857
|
+
for (const id of ids.slice(0, 100)) {
|
|
3858
|
+
const variable = figma.variables.getVariableById(id);
|
|
3859
|
+
if (variable) {
|
|
3860
|
+
variable.remove();
|
|
3861
|
+
deleted++;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
return { deleted };
|
|
3866
|
+
})()
|
|
3867
|
+
`);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
// ============ Component Descriptions ============
|
|
3871
|
+
|
|
3872
|
+
/**
|
|
3873
|
+
* Set description on a component (supports markdown)
|
|
3874
|
+
*/
|
|
3875
|
+
async setComponentDescription(componentId, description) {
|
|
3876
|
+
return await this.eval(`
|
|
3877
|
+
(function() {
|
|
3878
|
+
const node = figma.getNodeById(${JSON.stringify(componentId)});
|
|
3879
|
+
if (!node) return { error: 'Node not found' };
|
|
3880
|
+
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') {
|
|
3881
|
+
return { error: 'Node is not a component' };
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
node.description = ${JSON.stringify(description)};
|
|
3885
|
+
return { success: true, id: node.id, description: node.description };
|
|
3886
|
+
})()
|
|
3887
|
+
`);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
/**
|
|
3891
|
+
* Get description from a component
|
|
3892
|
+
*/
|
|
3893
|
+
async getComponentDescription(componentId) {
|
|
3894
|
+
return await this.eval(`
|
|
3895
|
+
(function() {
|
|
3896
|
+
const node = figma.getNodeById(${JSON.stringify(componentId)});
|
|
3897
|
+
if (!node) return { error: 'Node not found' };
|
|
3898
|
+
|
|
3899
|
+
return {
|
|
3900
|
+
id: node.id,
|
|
3901
|
+
name: node.name,
|
|
3902
|
+
type: node.type,
|
|
3903
|
+
description: node.description || ''
|
|
3904
|
+
};
|
|
3905
|
+
})()
|
|
3906
|
+
`);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* Set description with documentation template
|
|
3911
|
+
*/
|
|
3912
|
+
async documentComponent(componentId, options = {}) {
|
|
3913
|
+
const { usage = '', props = [], notes = '' } = options;
|
|
3914
|
+
|
|
3915
|
+
let description = '';
|
|
3916
|
+
if (usage) description += `## Usage\n${usage}\n\n`;
|
|
3917
|
+
if (props.length > 0) {
|
|
3918
|
+
description += `## Properties\n`;
|
|
3919
|
+
props.forEach(p => {
|
|
3920
|
+
description += `- **${p.name}**: ${p.description}\n`;
|
|
3921
|
+
});
|
|
3922
|
+
description += '\n';
|
|
3923
|
+
}
|
|
3924
|
+
if (notes) description += `## Notes\n${notes}`;
|
|
3925
|
+
|
|
3926
|
+
return await this.setComponentDescription(componentId, description.trim());
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
// ============ Console & Debugging ============
|
|
3930
|
+
|
|
3931
|
+
/**
|
|
3932
|
+
* Get console logs from Figma
|
|
3933
|
+
*/
|
|
3934
|
+
async getConsoleLogs(limit = 50) {
|
|
3935
|
+
// Enable console tracking if not already
|
|
3936
|
+
await this.send('Runtime.enable');
|
|
3937
|
+
|
|
3938
|
+
return await this.eval(`
|
|
3939
|
+
(function() {
|
|
3940
|
+
// Note: We can't access past console logs directly
|
|
3941
|
+
// But we can return info about current state
|
|
3942
|
+
return {
|
|
3943
|
+
message: 'Console log streaming enabled. Use captureConsoleLogs() to start capturing.',
|
|
3944
|
+
tip: 'Run your plugin code and logs will be captured.'
|
|
3945
|
+
};
|
|
3946
|
+
})()
|
|
3947
|
+
`);
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3950
|
+
/**
|
|
3951
|
+
* Start capturing console logs
|
|
3952
|
+
* Returns logs via callback
|
|
3953
|
+
*/
|
|
3954
|
+
async startConsoleCapture(callback) {
|
|
3955
|
+
await this.send('Runtime.enable');
|
|
3956
|
+
|
|
3957
|
+
// Listen for console messages
|
|
3958
|
+
this.ws.on('message', (data) => {
|
|
3959
|
+
const msg = JSON.parse(data);
|
|
3960
|
+
if (msg.method === 'Runtime.consoleAPICalled') {
|
|
3961
|
+
const args = msg.params.args.map(arg => arg.value || arg.description || '');
|
|
3962
|
+
callback({
|
|
3963
|
+
type: msg.params.type,
|
|
3964
|
+
message: args.join(' '),
|
|
3965
|
+
timestamp: msg.params.timestamp
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
});
|
|
3969
|
+
|
|
3970
|
+
return { capturing: true };
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/**
|
|
3974
|
+
* Execute code and capture its console output
|
|
3975
|
+
*/
|
|
3976
|
+
async evalWithLogs(expression) {
|
|
3977
|
+
const logs = [];
|
|
3978
|
+
|
|
3979
|
+
// Wrap expression to capture console
|
|
3980
|
+
const wrappedCode = `
|
|
3981
|
+
(function() {
|
|
3982
|
+
const _logs = [];
|
|
3983
|
+
const _origLog = console.log;
|
|
3984
|
+
const _origWarn = console.warn;
|
|
3985
|
+
const _origError = console.error;
|
|
3986
|
+
|
|
3987
|
+
console.log = (...args) => { _logs.push({ type: 'log', args }); _origLog(...args); };
|
|
3988
|
+
console.warn = (...args) => { _logs.push({ type: 'warn', args }); _origWarn(...args); };
|
|
3989
|
+
console.error = (...args) => { _logs.push({ type: 'error', args }); _origError(...args); };
|
|
3990
|
+
|
|
3991
|
+
try {
|
|
3992
|
+
const result = (function() { ${expression} })();
|
|
3993
|
+
return { result, logs: _logs };
|
|
3994
|
+
} finally {
|
|
3995
|
+
console.log = _origLog;
|
|
3996
|
+
console.warn = _origWarn;
|
|
3997
|
+
console.error = _origError;
|
|
3998
|
+
}
|
|
3999
|
+
})()
|
|
4000
|
+
`;
|
|
4001
|
+
|
|
4002
|
+
return await this.eval(wrappedCode);
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
// ============ Page & Plugin Reload ============
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* Reload the current page
|
|
4009
|
+
*/
|
|
4010
|
+
async reloadPage() {
|
|
4011
|
+
return await this.send('Page.reload');
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
/**
|
|
4015
|
+
* Navigate to a different Figma file
|
|
4016
|
+
*/
|
|
4017
|
+
async navigateToFile(fileUrl) {
|
|
4018
|
+
return await this.send('Page.navigate', { url: fileUrl });
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
/**
|
|
4022
|
+
* Get current page URL
|
|
4023
|
+
*/
|
|
4024
|
+
async getCurrentUrl() {
|
|
4025
|
+
const result = await this.eval('window.location.href');
|
|
4026
|
+
return { url: result };
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
/**
|
|
4030
|
+
* Reload/refresh plugins
|
|
4031
|
+
*/
|
|
4032
|
+
async refreshPlugins() {
|
|
4033
|
+
return await this.eval(`
|
|
4034
|
+
(function() {
|
|
4035
|
+
// Trigger a plugin refresh by accessing the plugin API
|
|
4036
|
+
// This doesn't actually reload plugins but refreshes the state
|
|
4037
|
+
const pluginData = figma.root.getPluginData('__refresh__');
|
|
4038
|
+
figma.root.setPluginData('__refresh__', Date.now().toString());
|
|
4039
|
+
return { refreshed: true, timestamp: Date.now() };
|
|
4040
|
+
})()
|
|
4041
|
+
`);
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
// ============ Organize Variants ============
|
|
4045
|
+
|
|
4046
|
+
/**
|
|
4047
|
+
* Organize component variants into a grid with labels
|
|
4048
|
+
*/
|
|
4049
|
+
async organizeVariants(componentSetId, options = {}) {
|
|
4050
|
+
const { gap = 40, labelGap = 20, showLabels = true } = options;
|
|
4051
|
+
|
|
4052
|
+
return await this.eval(`
|
|
4053
|
+
(async function() {
|
|
4054
|
+
const componentSet = figma.getNodeById(${JSON.stringify(componentSetId)});
|
|
4055
|
+
if (!componentSet || componentSet.type !== 'COMPONENT_SET') {
|
|
4056
|
+
return { error: 'Component set not found' };
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
const variants = componentSet.children.filter(c => c.type === 'COMPONENT');
|
|
4060
|
+
if (variants.length === 0) return { error: 'No variants found' };
|
|
4061
|
+
|
|
4062
|
+
// Parse variant properties
|
|
4063
|
+
const propValues = {};
|
|
4064
|
+
variants.forEach(v => {
|
|
4065
|
+
const props = v.name.split(', ');
|
|
4066
|
+
props.forEach(p => {
|
|
4067
|
+
const [key, val] = p.split('=');
|
|
4068
|
+
if (!propValues[key]) propValues[key] = new Set();
|
|
4069
|
+
propValues[key].add(val);
|
|
4070
|
+
});
|
|
4071
|
+
});
|
|
4072
|
+
|
|
4073
|
+
const propNames = Object.keys(propValues);
|
|
4074
|
+
if (propNames.length === 0) return { organized: 0 };
|
|
4075
|
+
|
|
4076
|
+
// Use first two properties for grid (rows/cols)
|
|
4077
|
+
const rowProp = propNames[0];
|
|
4078
|
+
const colProp = propNames[1] || null;
|
|
4079
|
+
|
|
4080
|
+
const rowValues = Array.from(propValues[rowProp]);
|
|
4081
|
+
const colValues = colProp ? Array.from(propValues[colProp]) : [''];
|
|
4082
|
+
|
|
4083
|
+
const gap = ${gap};
|
|
4084
|
+
const labelGap = ${labelGap};
|
|
4085
|
+
const showLabels = ${showLabels};
|
|
4086
|
+
|
|
4087
|
+
// Get max dimensions
|
|
4088
|
+
let maxW = 0, maxH = 0;
|
|
4089
|
+
variants.forEach(v => {
|
|
4090
|
+
maxW = Math.max(maxW, v.width);
|
|
4091
|
+
maxH = Math.max(maxH, v.height);
|
|
4092
|
+
});
|
|
4093
|
+
|
|
4094
|
+
// Position variants in grid
|
|
4095
|
+
let organized = 0;
|
|
4096
|
+
rowValues.forEach((rowVal, rowIdx) => {
|
|
4097
|
+
colValues.forEach((colVal, colIdx) => {
|
|
4098
|
+
const variant = variants.find(v => {
|
|
4099
|
+
const hasRow = v.name.includes(rowProp + '=' + rowVal);
|
|
4100
|
+
const hasCol = !colProp || v.name.includes(colProp + '=' + colVal);
|
|
4101
|
+
return hasRow && hasCol;
|
|
4102
|
+
});
|
|
4103
|
+
|
|
4104
|
+
if (variant) {
|
|
4105
|
+
const xOffset = showLabels ? 100 : 0;
|
|
4106
|
+
const yOffset = showLabels ? 40 : 0;
|
|
4107
|
+
|
|
4108
|
+
variant.x = xOffset + colIdx * (maxW + gap);
|
|
4109
|
+
variant.y = yOffset + rowIdx * (maxH + gap);
|
|
4110
|
+
organized++;
|
|
4111
|
+
}
|
|
4112
|
+
});
|
|
4113
|
+
});
|
|
4114
|
+
|
|
4115
|
+
// Add labels if requested
|
|
4116
|
+
if (showLabels && organized > 0) {
|
|
4117
|
+
await figma.loadFontAsync({ family: 'Inter', style: 'Medium' });
|
|
4118
|
+
|
|
4119
|
+
// Row labels
|
|
4120
|
+
rowValues.forEach((val, idx) => {
|
|
4121
|
+
const label = figma.createText();
|
|
4122
|
+
label.characters = val;
|
|
4123
|
+
label.fontSize = 12;
|
|
4124
|
+
label.x = 0;
|
|
4125
|
+
label.y = 40 + idx * (maxH + gap) + maxH / 2 - 6;
|
|
4126
|
+
componentSet.parent.appendChild(label);
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
// Column labels
|
|
4130
|
+
if (colProp) {
|
|
4131
|
+
colValues.forEach((val, idx) => {
|
|
4132
|
+
const label = figma.createText();
|
|
4133
|
+
label.characters = val;
|
|
4134
|
+
label.fontSize = 12;
|
|
4135
|
+
label.x = 100 + idx * (maxW + gap) + maxW / 2;
|
|
4136
|
+
label.y = 10;
|
|
4137
|
+
componentSet.parent.appendChild(label);
|
|
4138
|
+
});
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
// Resize component set to fit
|
|
4143
|
+
componentSet.resizeWithoutConstraints(
|
|
4144
|
+
(showLabels ? 100 : 0) + colValues.length * (maxW + gap) - gap,
|
|
4145
|
+
(showLabels ? 40 : 0) + rowValues.length * (maxH + gap) - gap
|
|
4146
|
+
);
|
|
4147
|
+
|
|
4148
|
+
return {
|
|
4149
|
+
organized,
|
|
4150
|
+
rows: rowValues.length,
|
|
4151
|
+
cols: colValues.length,
|
|
4152
|
+
rowProperty: rowProp,
|
|
4153
|
+
colProperty: colProp
|
|
4154
|
+
};
|
|
4155
|
+
})()
|
|
4156
|
+
`);
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
/**
|
|
4160
|
+
* Auto-generate component set from similar frames
|
|
4161
|
+
*/
|
|
4162
|
+
async createComponentSetFromFrames(frameIds, name, variantProperty = 'variant') {
|
|
4163
|
+
return await this.eval(`
|
|
4164
|
+
(async function() {
|
|
4165
|
+
const ids = ${JSON.stringify(frameIds)};
|
|
4166
|
+
const frames = ids.map(id => figma.getNodeById(id)).filter(n => n && n.type === 'FRAME');
|
|
4167
|
+
|
|
4168
|
+
if (frames.length < 2) return { error: 'Need at least 2 frames' };
|
|
4169
|
+
|
|
4170
|
+
// Convert frames to components
|
|
4171
|
+
const components = frames.map((frame, idx) => {
|
|
4172
|
+
const component = figma.createComponentFromNode(frame);
|
|
4173
|
+
component.name = ${JSON.stringify(variantProperty)} + '=' + (frame.name || 'Variant' + (idx + 1));
|
|
4174
|
+
return component;
|
|
4175
|
+
});
|
|
4176
|
+
|
|
4177
|
+
// Combine into component set
|
|
4178
|
+
const componentSet = figma.combineAsVariants(components, figma.currentPage);
|
|
4179
|
+
componentSet.name = ${JSON.stringify(name)};
|
|
4180
|
+
|
|
4181
|
+
return {
|
|
4182
|
+
id: componentSet.id,
|
|
4183
|
+
name: componentSet.name,
|
|
4184
|
+
variantCount: components.length
|
|
4185
|
+
};
|
|
4186
|
+
})()
|
|
4187
|
+
`);
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
close() {
|
|
4191
|
+
if (this.ws) {
|
|
4192
|
+
this.ws.close();
|
|
4193
|
+
this.ws = null;
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
export default FigmaClient;
|