@biolab/talk-to-figma 0.3.3 → 0.4.1

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.
@@ -0,0 +1,4340 @@
1
+ // This is the main code file for the Cursor MCP Figma plugin
2
+ // It handles Figma API commands
3
+
4
+ // Plugin state
5
+ const state = {
6
+ serverPort: 3055, // Default port
7
+ };
8
+
9
+
10
+ // Helper function for progress updates
11
+ function sendProgressUpdate(
12
+ commandId,
13
+ commandType,
14
+ status,
15
+ progress,
16
+ totalItems,
17
+ processedItems,
18
+ message,
19
+ payload = null
20
+ ) {
21
+ const update = {
22
+ type: "command_progress",
23
+ commandId,
24
+ commandType,
25
+ status,
26
+ progress,
27
+ totalItems,
28
+ processedItems,
29
+ message,
30
+ timestamp: Date.now(),
31
+ };
32
+
33
+ // Add optional chunk information if present
34
+ if (payload) {
35
+ if (
36
+ payload.currentChunk !== undefined &&
37
+ payload.totalChunks !== undefined
38
+ ) {
39
+ update.currentChunk = payload.currentChunk;
40
+ update.totalChunks = payload.totalChunks;
41
+ update.chunkSize = payload.chunkSize;
42
+ }
43
+ update.payload = payload;
44
+ }
45
+
46
+ // Send to UI
47
+ figma.ui.postMessage(update);
48
+ console.log(`Progress update: ${status} - ${progress}% - ${message}`);
49
+
50
+ return update;
51
+ }
52
+
53
+ // Show UI
54
+ figma.showUI(__html__, { width: 350, height: 600 });
55
+
56
+ // Plugin commands from UI
57
+ figma.ui.onmessage = async (msg) => {
58
+ switch (msg.type) {
59
+ case "update-settings":
60
+ updateSettings(msg);
61
+ break;
62
+ case "notify":
63
+ figma.notify(msg.message);
64
+ break;
65
+ case "close-plugin":
66
+ figma.closePlugin();
67
+ break;
68
+ case "execute-command":
69
+ // Execute commands received from UI (which gets them from WebSocket)
70
+ try {
71
+ const result = await handleCommand(msg.command, msg.params);
72
+ // Send result back to UI
73
+ figma.ui.postMessage({
74
+ type: "command-result",
75
+ id: msg.id,
76
+ result,
77
+ });
78
+ } catch (error) {
79
+ figma.ui.postMessage({
80
+ type: "command-error",
81
+ id: msg.id,
82
+ error: error.message || "Error executing command",
83
+ });
84
+ }
85
+ break;
86
+ }
87
+ };
88
+
89
+ // Listen for plugin commands from menu
90
+ figma.on("run", ({ command }) => {
91
+ figma.ui.postMessage({ type: "auto-connect" });
92
+ });
93
+
94
+ // Update plugin settings
95
+ function updateSettings(settings) {
96
+ if (settings.serverPort) {
97
+ state.serverPort = settings.serverPort;
98
+ }
99
+
100
+ figma.clientStorage.setAsync("settings", {
101
+ serverPort: state.serverPort,
102
+ });
103
+ }
104
+
105
+ // Handle commands from UI
106
+ async function handleCommand(command, params) {
107
+ switch (command) {
108
+ case "get_document_info":
109
+ return await getDocumentInfo();
110
+ case "get_selection":
111
+ return await getSelection();
112
+ case "get_node_info":
113
+ if (!params || !params.nodeId) {
114
+ throw new Error("Missing nodeId parameter");
115
+ }
116
+ return await getNodeInfo(params.nodeId);
117
+ case "get_nodes_info":
118
+ if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) {
119
+ throw new Error("Missing or invalid nodeIds parameter");
120
+ }
121
+ return await getNodesInfo(params.nodeIds);
122
+ case "read_my_design":
123
+ return await readMyDesign();
124
+ case "create_rectangle":
125
+ return await createRectangle(params);
126
+ case "create_frame":
127
+ return await createFrame(params);
128
+ case "create_text":
129
+ return await createText(params);
130
+ case "set_fill_color":
131
+ return await setFillColor(params);
132
+ case "set_stroke_color":
133
+ return await setStrokeColor(params);
134
+ case "move_node":
135
+ return await moveNode(params);
136
+ case "resize_node":
137
+ return await resizeNode(params);
138
+ case "delete_node":
139
+ return await deleteNode(params);
140
+ case "delete_multiple_nodes":
141
+ return await deleteMultipleNodes(params);
142
+ case "get_styles":
143
+ return await getStyles();
144
+ case "get_local_components":
145
+ return await getLocalComponents();
146
+ case "get_team_library_components":
147
+ return await getTeamComponents();
148
+ case "get_team_library_variables":
149
+ return await getTeamLibraryVariables();
150
+ case "import_variable_by_key":
151
+ return await importVariableByKey(params);
152
+ case "create_component_instance":
153
+ return await createComponentInstance(params);
154
+ case "export_node_as_image":
155
+ return await exportNodeAsImage(params);
156
+ case "set_corner_radius":
157
+ return await setCornerRadius(params);
158
+ case "set_text_content":
159
+ return await setTextContent(params);
160
+ case "clone_node":
161
+ return await cloneNode(params);
162
+ case "scan_text_nodes":
163
+ return await scanTextNodes(params);
164
+ case "set_multiple_text_contents":
165
+ return await setMultipleTextContents(params);
166
+ case "get_annotations":
167
+ return await getAnnotations(params);
168
+ case "set_annotation":
169
+ return await setAnnotation(params);
170
+ case "scan_nodes_by_types":
171
+ return await scanNodesByTypes(params);
172
+ case "set_multiple_annotations":
173
+ return await setMultipleAnnotations(params);
174
+ case "get_instance_overrides":
175
+ // Check if instanceNode parameter is provided
176
+ if (params && params.instanceNodeId) {
177
+ // Get the instance node by ID
178
+ const instanceNode = await figma.getNodeByIdAsync(params.instanceNodeId);
179
+ if (!instanceNode) {
180
+ throw new Error(`Instance node not found with ID: ${params.instanceNodeId}`);
181
+ }
182
+ return await getInstanceOverrides(instanceNode);
183
+ }
184
+ // Call without instance node if not provided
185
+ return await getInstanceOverrides();
186
+
187
+ case "set_instance_overrides":
188
+ // Check if instanceNodeIds parameter is provided
189
+ if (params && params.targetNodeIds) {
190
+ // Validate that targetNodeIds is an array
191
+ if (!Array.isArray(params.targetNodeIds)) {
192
+ throw new Error("targetNodeIds must be an array");
193
+ }
194
+
195
+ // Get the instance nodes by IDs
196
+ const targetNodes = await getValidTargetInstances(params.targetNodeIds);
197
+ if (!targetNodes.success) {
198
+ figma.notify(targetNodes.message);
199
+ return { success: false, message: targetNodes.message };
200
+ }
201
+
202
+ if (params.sourceInstanceId) {
203
+
204
+ // get source instance data
205
+ let sourceInstanceData = null;
206
+ sourceInstanceData = await getSourceInstanceData(params.sourceInstanceId);
207
+
208
+ if (!sourceInstanceData.success) {
209
+ figma.notify(sourceInstanceData.message);
210
+ return { success: false, message: sourceInstanceData.message };
211
+ }
212
+ return await setInstanceOverrides(targetNodes.targetInstances, sourceInstanceData);
213
+ } else {
214
+ throw new Error("Missing sourceInstanceId parameter");
215
+ }
216
+ }
217
+ case "set_layout_mode":
218
+ return await setLayoutMode(params);
219
+ case "set_padding":
220
+ return await setPadding(params);
221
+ case "set_axis_align":
222
+ return await setAxisAlign(params);
223
+ case "set_layout_sizing":
224
+ return await setLayoutSizing(params);
225
+ case "set_item_spacing":
226
+ return await setItemSpacing(params);
227
+ case "get_reactions":
228
+ if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) {
229
+ throw new Error("Missing or invalid nodeIds parameter");
230
+ }
231
+ return await getReactions(params.nodeIds);
232
+ case "set_default_connector":
233
+ return await setDefaultConnector(params);
234
+ case "create_connections":
235
+ return await createConnections(params);
236
+ case "set_focus":
237
+ return await setFocus(params);
238
+ case "set_selections":
239
+ return await setSelections(params);
240
+ case "get_local_variables":
241
+ return await getLocalVariables();
242
+ case "get_local_variable_collections":
243
+ return await getLocalVariableCollections();
244
+ case "get_variable_by_id":
245
+ return await getVariableById(params);
246
+ case "create_variable_collection":
247
+ return await createVariableCollection(params);
248
+ case "create_variable":
249
+ return await createVariable(params);
250
+ case "set_variable_value":
251
+ return await setVariableValue(params);
252
+ case "set_variable_mode_name":
253
+ return await setVariableModeName(params);
254
+ case "set_variable_binding":
255
+ return await setVariableBinding(params);
256
+ default:
257
+ throw new Error(`Unknown command: ${command}`);
258
+ }
259
+ }
260
+
261
+ // Command implementations
262
+
263
+ async function getDocumentInfo() {
264
+ await figma.currentPage.loadAsync();
265
+ const page = figma.currentPage;
266
+ return {
267
+ name: page.name,
268
+ id: page.id,
269
+ type: page.type,
270
+ children: page.children.map((node) => ({
271
+ id: node.id,
272
+ name: node.name,
273
+ type: node.type,
274
+ })),
275
+ currentPage: {
276
+ id: page.id,
277
+ name: page.name,
278
+ childCount: page.children.length,
279
+ },
280
+ pages: [
281
+ {
282
+ id: page.id,
283
+ name: page.name,
284
+ childCount: page.children.length,
285
+ },
286
+ ],
287
+ };
288
+ }
289
+
290
+ async function getSelection() {
291
+ return {
292
+ selectionCount: figma.currentPage.selection.length,
293
+ selection: figma.currentPage.selection.map((node) => ({
294
+ id: node.id,
295
+ name: node.name,
296
+ type: node.type,
297
+ visible: node.visible,
298
+ })),
299
+ };
300
+ }
301
+
302
+ function rgbaToHex(color) {
303
+ var r = Math.round(color.r * 255);
304
+ var g = Math.round(color.g * 255);
305
+ var b = Math.round(color.b * 255);
306
+ var a = color.a !== undefined ? Math.round(color.a * 255) : 255;
307
+
308
+ if (a === 255) {
309
+ return (
310
+ "#" +
311
+ [r, g, b]
312
+ .map((x) => {
313
+ return x.toString(16).padStart(2, "0");
314
+ })
315
+ .join("")
316
+ );
317
+ }
318
+
319
+ return (
320
+ "#" +
321
+ [r, g, b, a]
322
+ .map((x) => {
323
+ return x.toString(16).padStart(2, "0");
324
+ })
325
+ .join("")
326
+ );
327
+ }
328
+
329
+ async function resolveVariableBinding(boundVars) {
330
+ if (!boundVars) return undefined;
331
+ var resolved = {};
332
+ for (var field in boundVars) {
333
+ var binding = boundVars[field];
334
+ try {
335
+ var variable = await figma.variables.getVariableByIdAsync(binding.id);
336
+ if (variable) {
337
+ var collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
338
+ resolved[field] = {
339
+ id: binding.id,
340
+ name: variable.name,
341
+ collectionName: collection ? collection.name : undefined,
342
+ };
343
+ } else {
344
+ resolved[field] = { id: binding.id };
345
+ }
346
+ } catch (e) {
347
+ resolved[field] = { id: binding.id };
348
+ }
349
+ }
350
+ return resolved;
351
+ }
352
+
353
+ async function filterFigmaNode(node) {
354
+ if (node.type === "VECTOR") {
355
+ return null;
356
+ }
357
+
358
+ var filtered = {
359
+ id: node.id,
360
+ name: node.name,
361
+ type: node.type,
362
+ };
363
+
364
+ if (node.fills && node.fills.length > 0) {
365
+ filtered.fills = await Promise.all(node.fills.map(async (fill) => {
366
+ var processedFill = Object.assign({}, fill);
367
+ processedFill.boundVariables = await resolveVariableBinding(processedFill.boundVariables);
368
+ delete processedFill.imageRef;
369
+
370
+ if (processedFill.gradientStops) {
371
+ processedFill.gradientStops = await Promise.all(processedFill.gradientStops.map(
372
+ async (stop) => {
373
+ var processedStop = Object.assign({}, stop);
374
+ if (processedStop.color) {
375
+ processedStop.color = rgbaToHex(processedStop.color);
376
+ }
377
+ processedStop.boundVariables = await resolveVariableBinding(processedStop.boundVariables);
378
+ return processedStop;
379
+ }
380
+ ));
381
+ }
382
+
383
+ if (processedFill.color) {
384
+ processedFill.color = rgbaToHex(processedFill.color);
385
+ }
386
+
387
+ return processedFill;
388
+ }));
389
+ }
390
+
391
+ if (node.strokes && node.strokes.length > 0) {
392
+ filtered.strokes = await Promise.all(node.strokes.map(async (stroke) => {
393
+ var processedStroke = Object.assign({}, stroke);
394
+ processedStroke.boundVariables = await resolveVariableBinding(processedStroke.boundVariables);
395
+ if (processedStroke.color) {
396
+ processedStroke.color = rgbaToHex(processedStroke.color);
397
+ }
398
+ return processedStroke;
399
+ }));
400
+ }
401
+
402
+ if (node.cornerRadius !== undefined) {
403
+ filtered.cornerRadius = node.cornerRadius;
404
+ }
405
+
406
+ if (node.absoluteBoundingBox) {
407
+ filtered.absoluteBoundingBox = node.absoluteBoundingBox;
408
+ }
409
+
410
+ if (node.characters) {
411
+ filtered.characters = node.characters;
412
+ }
413
+
414
+ if (node.style) {
415
+ filtered.style = {
416
+ fontFamily: node.style.fontFamily,
417
+ fontStyle: node.style.fontStyle,
418
+ fontWeight: node.style.fontWeight,
419
+ fontSize: node.style.fontSize,
420
+ textAlignHorizontal: node.style.textAlignHorizontal,
421
+ letterSpacing: node.style.letterSpacing,
422
+ lineHeightPx: node.style.lineHeightPx,
423
+ };
424
+ }
425
+
426
+ if (node.children) {
427
+ filtered.children = (await Promise.all(node.children
428
+ .map((child) => {
429
+ return filterFigmaNode(child);
430
+ })
431
+ )).filter((child) => {
432
+ return child !== null;
433
+ });
434
+ }
435
+
436
+ return filtered;
437
+ }
438
+
439
+ async function getNodeInfo(nodeId) {
440
+ const node = await figma.getNodeByIdAsync(nodeId);
441
+
442
+ if (!node) {
443
+ throw new Error(`Node not found with ID: ${nodeId}`);
444
+ }
445
+
446
+ const response = await node.exportAsync({
447
+ format: "JSON_REST_V1",
448
+ });
449
+
450
+ return await filterFigmaNode(response.document);
451
+ }
452
+
453
+ async function getNodesInfo(nodeIds) {
454
+ try {
455
+ // Load all nodes in parallel
456
+ const nodes = await Promise.all(
457
+ nodeIds.map((id) => figma.getNodeByIdAsync(id))
458
+ );
459
+
460
+ // Filter out any null values (nodes that weren't found)
461
+ const validNodes = nodes.filter((node) => node !== null);
462
+
463
+ // Export all valid nodes in parallel
464
+ const responses = await Promise.all(
465
+ validNodes.map(async (node) => {
466
+ const response = await node.exportAsync({
467
+ format: "JSON_REST_V1",
468
+ });
469
+ return {
470
+ nodeId: node.id,
471
+ document: await filterFigmaNode(response.document),
472
+ };
473
+ })
474
+ );
475
+
476
+ return responses;
477
+ } catch (error) {
478
+ throw new Error(`Error getting nodes info: ${error.message}`);
479
+ }
480
+ }
481
+
482
+ async function getReactions(nodeIds) {
483
+ try {
484
+ const commandId = generateCommandId();
485
+ sendProgressUpdate(
486
+ commandId,
487
+ "get_reactions",
488
+ "started",
489
+ 0,
490
+ nodeIds.length,
491
+ 0,
492
+ `Starting deep search for reactions in ${nodeIds.length} nodes and their children`
493
+ );
494
+
495
+ // Function to find nodes with reactions from the node and all its children
496
+ async function findNodesWithReactions(node, processedNodes = new Set(), depth = 0, results = []) {
497
+ // Skip already processed nodes (prevent circular references)
498
+ if (processedNodes.has(node.id)) {
499
+ return results;
500
+ }
501
+
502
+ processedNodes.add(node.id);
503
+
504
+ // Check if the current node has reactions
505
+ let filteredReactions = [];
506
+ if (node.reactions && node.reactions.length > 0) {
507
+ // Filter out reactions with navigation === 'CHANGE_TO'
508
+ filteredReactions = node.reactions.filter(r => {
509
+ // Some reactions may have action or actions array
510
+ if (r.action && r.action.navigation === 'CHANGE_TO') return false;
511
+ if (Array.isArray(r.actions)) {
512
+ // If any action in actions array is CHANGE_TO, exclude
513
+ return !r.actions.some(a => a.navigation === 'CHANGE_TO');
514
+ }
515
+ return true;
516
+ });
517
+ }
518
+ const hasFilteredReactions = filteredReactions.length > 0;
519
+
520
+ // If the node has filtered reactions, add it to results and apply highlight effect
521
+ if (hasFilteredReactions) {
522
+ results.push({
523
+ id: node.id,
524
+ name: node.name,
525
+ type: node.type,
526
+ depth: depth,
527
+ hasReactions: true,
528
+ reactions: filteredReactions,
529
+ path: getNodePath(node)
530
+ });
531
+ // Apply highlight effect (orange border)
532
+ await highlightNodeWithAnimation(node);
533
+ }
534
+
535
+ // If node has children, recursively search them
536
+ if (node.children) {
537
+ for (const child of node.children) {
538
+ await findNodesWithReactions(child, processedNodes, depth + 1, results);
539
+ }
540
+ }
541
+
542
+ return results;
543
+ }
544
+
545
+ // Function to apply animated highlight effect to a node
546
+ async function highlightNodeWithAnimation(node) {
547
+ // Save original stroke properties
548
+ const originalStrokeWeight = node.strokeWeight;
549
+ const originalStrokes = node.strokes ? [...node.strokes] : [];
550
+
551
+ try {
552
+ // Apply orange border stroke
553
+ node.strokeWeight = 4;
554
+ node.strokes = [{
555
+ type: 'SOLID',
556
+ color: { r: 1, g: 0.5, b: 0 }, // Orange color
557
+ opacity: 0.8
558
+ }];
559
+
560
+ // Set timeout for animation effect (restore to original after 1.5 seconds)
561
+ setTimeout(() => {
562
+ try {
563
+ // Restore original stroke properties
564
+ node.strokeWeight = originalStrokeWeight;
565
+ node.strokes = originalStrokes;
566
+ } catch (restoreError) {
567
+ console.error(`Error restoring node stroke: ${restoreError.message}`);
568
+ }
569
+ }, 1500);
570
+ } catch (highlightError) {
571
+ console.error(`Error highlighting node: ${highlightError.message}`);
572
+ // Continue even if highlighting fails
573
+ }
574
+ }
575
+
576
+ // Get node hierarchy path as a string
577
+ function getNodePath(node) {
578
+ const path = [];
579
+ let current = node;
580
+
581
+ while (current && current.parent) {
582
+ path.unshift(current.name);
583
+ current = current.parent;
584
+ }
585
+
586
+ return path.join(' > ');
587
+ }
588
+
589
+ // Array to store all results
590
+ let allResults = [];
591
+ let processedCount = 0;
592
+ const totalCount = nodeIds.length;
593
+
594
+ // Iterate through each node and its children to search for reactions
595
+ for (let i = 0; i < nodeIds.length; i++) {
596
+ try {
597
+ const nodeId = nodeIds[i];
598
+ const node = await figma.getNodeByIdAsync(nodeId);
599
+
600
+ if (!node) {
601
+ processedCount++;
602
+ sendProgressUpdate(
603
+ commandId,
604
+ "get_reactions",
605
+ "in_progress",
606
+ processedCount / totalCount,
607
+ totalCount,
608
+ processedCount,
609
+ `Node not found: ${nodeId}`
610
+ );
611
+ continue;
612
+ }
613
+
614
+ // Search for reactions in the node and its children
615
+ const processedNodes = new Set();
616
+ const nodeResults = await findNodesWithReactions(node, processedNodes);
617
+
618
+ // Add results
619
+ allResults = allResults.concat(nodeResults);
620
+
621
+ // Update progress
622
+ processedCount++;
623
+ sendProgressUpdate(
624
+ commandId,
625
+ "get_reactions",
626
+ "in_progress",
627
+ processedCount / totalCount,
628
+ totalCount,
629
+ processedCount,
630
+ `Processed node ${processedCount}/${totalCount}, found ${nodeResults.length} nodes with reactions`
631
+ );
632
+ } catch (error) {
633
+ processedCount++;
634
+ sendProgressUpdate(
635
+ commandId,
636
+ "get_reactions",
637
+ "in_progress",
638
+ processedCount / totalCount,
639
+ totalCount,
640
+ processedCount,
641
+ `Error processing node: ${error.message}`
642
+ );
643
+ }
644
+ }
645
+
646
+ // Completion update
647
+ sendProgressUpdate(
648
+ commandId,
649
+ "get_reactions",
650
+ "completed",
651
+ 1,
652
+ totalCount,
653
+ totalCount,
654
+ `Completed deep search: found ${allResults.length} nodes with reactions.`
655
+ );
656
+
657
+ return {
658
+ nodesCount: nodeIds.length,
659
+ nodesWithReactions: allResults.length,
660
+ nodes: allResults
661
+ };
662
+ } catch (error) {
663
+ throw new Error(`Failed to get reactions: ${error.message}`);
664
+ }
665
+ }
666
+
667
+ async function readMyDesign() {
668
+ try {
669
+ // Load all selected nodes in parallel
670
+ const nodes = await Promise.all(
671
+ figma.currentPage.selection.map((node) => figma.getNodeByIdAsync(node.id))
672
+ );
673
+
674
+ // Filter out any null values (nodes that weren't found)
675
+ const validNodes = nodes.filter((node) => node !== null);
676
+
677
+ // Export all valid nodes in parallel
678
+ const responses = await Promise.all(
679
+ validNodes.map(async (node) => {
680
+ const response = await node.exportAsync({
681
+ format: "JSON_REST_V1",
682
+ });
683
+ return {
684
+ nodeId: node.id,
685
+ document: await filterFigmaNode(response.document),
686
+ };
687
+ })
688
+ );
689
+
690
+ return responses;
691
+ } catch (error) {
692
+ throw new Error(`Error getting nodes info: ${error.message}`);
693
+ }
694
+ }
695
+
696
+ async function createRectangle(params) {
697
+ const {
698
+ x = 0,
699
+ y = 0,
700
+ width = 100,
701
+ height = 100,
702
+ name = "Rectangle",
703
+ parentId,
704
+ } = params || {};
705
+
706
+ const rect = figma.createRectangle();
707
+ rect.x = x;
708
+ rect.y = y;
709
+ rect.resize(width, height);
710
+ rect.name = name;
711
+
712
+ // If parentId is provided, append to that node, otherwise append to current page
713
+ if (parentId) {
714
+ const parentNode = await figma.getNodeByIdAsync(parentId);
715
+ if (!parentNode) {
716
+ throw new Error(`Parent node not found with ID: ${parentId}`);
717
+ }
718
+ if (!("appendChild" in parentNode)) {
719
+ throw new Error(`Parent node does not support children: ${parentId}`);
720
+ }
721
+ parentNode.appendChild(rect);
722
+ } else {
723
+ figma.currentPage.appendChild(rect);
724
+ }
725
+
726
+ return {
727
+ id: rect.id,
728
+ name: rect.name,
729
+ x: rect.x,
730
+ y: rect.y,
731
+ width: rect.width,
732
+ height: rect.height,
733
+ parentId: rect.parent ? rect.parent.id : undefined,
734
+ };
735
+ }
736
+
737
+ async function createFrame(params) {
738
+ const {
739
+ x = 0,
740
+ y = 0,
741
+ width = 100,
742
+ height = 100,
743
+ name = "Frame",
744
+ parentId,
745
+ fillColor,
746
+ strokeColor,
747
+ strokeWeight,
748
+ layoutMode = "NONE",
749
+ layoutWrap = "NO_WRAP",
750
+ paddingTop = 10,
751
+ paddingRight = 10,
752
+ paddingBottom = 10,
753
+ paddingLeft = 10,
754
+ primaryAxisAlignItems = "MIN",
755
+ counterAxisAlignItems = "MIN",
756
+ layoutSizingHorizontal = "FIXED",
757
+ layoutSizingVertical = "FIXED",
758
+ itemSpacing = 0,
759
+ } = params || {};
760
+
761
+ const frame = figma.createFrame();
762
+ frame.x = x;
763
+ frame.y = y;
764
+ frame.resize(width, height);
765
+ frame.name = name;
766
+
767
+ // Set layout mode if provided
768
+ if (layoutMode !== "NONE") {
769
+ frame.layoutMode = layoutMode;
770
+ frame.layoutWrap = layoutWrap;
771
+
772
+ // Set padding values only when layoutMode is not NONE
773
+ frame.paddingTop = paddingTop;
774
+ frame.paddingRight = paddingRight;
775
+ frame.paddingBottom = paddingBottom;
776
+ frame.paddingLeft = paddingLeft;
777
+
778
+ // Set axis alignment only when layoutMode is not NONE
779
+ frame.primaryAxisAlignItems = primaryAxisAlignItems;
780
+ frame.counterAxisAlignItems = counterAxisAlignItems;
781
+
782
+ // Set layout sizing only when layoutMode is not NONE
783
+ frame.layoutSizingHorizontal = layoutSizingHorizontal;
784
+ frame.layoutSizingVertical = layoutSizingVertical;
785
+
786
+ // Set item spacing only when layoutMode is not NONE
787
+ frame.itemSpacing = itemSpacing;
788
+ }
789
+
790
+ // Set fill color if provided
791
+ if (fillColor) {
792
+ const paintStyle = {
793
+ type: "SOLID",
794
+ color: {
795
+ r: parseFloat(fillColor.r) || 0,
796
+ g: parseFloat(fillColor.g) || 0,
797
+ b: parseFloat(fillColor.b) || 0,
798
+ },
799
+ opacity: parseFloat(fillColor.a) || 1,
800
+ };
801
+ frame.fills = [paintStyle];
802
+ }
803
+
804
+ // Set stroke color and weight if provided
805
+ if (strokeColor) {
806
+ const strokeStyle = {
807
+ type: "SOLID",
808
+ color: {
809
+ r: parseFloat(strokeColor.r) || 0,
810
+ g: parseFloat(strokeColor.g) || 0,
811
+ b: parseFloat(strokeColor.b) || 0,
812
+ },
813
+ opacity: parseFloat(strokeColor.a) || 1,
814
+ };
815
+ frame.strokes = [strokeStyle];
816
+ }
817
+
818
+ // Set stroke weight if provided
819
+ if (strokeWeight !== undefined) {
820
+ frame.strokeWeight = strokeWeight;
821
+ }
822
+
823
+ // If parentId is provided, append to that node, otherwise append to current page
824
+ if (parentId) {
825
+ const parentNode = await figma.getNodeByIdAsync(parentId);
826
+ if (!parentNode) {
827
+ throw new Error(`Parent node not found with ID: ${parentId}`);
828
+ }
829
+ if (!("appendChild" in parentNode)) {
830
+ throw new Error(`Parent node does not support children: ${parentId}`);
831
+ }
832
+ parentNode.appendChild(frame);
833
+ } else {
834
+ figma.currentPage.appendChild(frame);
835
+ }
836
+
837
+ return {
838
+ id: frame.id,
839
+ name: frame.name,
840
+ x: frame.x,
841
+ y: frame.y,
842
+ width: frame.width,
843
+ height: frame.height,
844
+ fills: frame.fills,
845
+ strokes: frame.strokes,
846
+ strokeWeight: frame.strokeWeight,
847
+ layoutMode: frame.layoutMode,
848
+ layoutWrap: frame.layoutWrap,
849
+ parentId: frame.parent ? frame.parent.id : undefined,
850
+ };
851
+ }
852
+
853
+ async function createText(params) {
854
+ const {
855
+ x = 0,
856
+ y = 0,
857
+ text = "Text",
858
+ fontSize = 14,
859
+ fontWeight = 400,
860
+ fontColor = { r: 0, g: 0, b: 0, a: 1 }, // Default to black
861
+ name = "",
862
+ parentId,
863
+ } = params || {};
864
+
865
+ // Map common font weights to Figma font styles
866
+ const getFontStyle = (weight) => {
867
+ switch (weight) {
868
+ case 100:
869
+ return "Thin";
870
+ case 200:
871
+ return "Extra Light";
872
+ case 300:
873
+ return "Light";
874
+ case 400:
875
+ return "Regular";
876
+ case 500:
877
+ return "Medium";
878
+ case 600:
879
+ return "Semi Bold";
880
+ case 700:
881
+ return "Bold";
882
+ case 800:
883
+ return "Extra Bold";
884
+ case 900:
885
+ return "Black";
886
+ default:
887
+ return "Regular";
888
+ }
889
+ };
890
+
891
+ const textNode = figma.createText();
892
+ textNode.x = x;
893
+ textNode.y = y;
894
+ textNode.name = name || text;
895
+ try {
896
+ await figma.loadFontAsync({
897
+ family: "Inter",
898
+ style: getFontStyle(fontWeight),
899
+ });
900
+ textNode.fontName = { family: "Inter", style: getFontStyle(fontWeight) };
901
+ textNode.fontSize = parseInt(fontSize);
902
+ } catch (error) {
903
+ console.error("Error setting font size", error);
904
+ }
905
+ setCharacters(textNode, text);
906
+
907
+ // Set text color
908
+ const paintStyle = {
909
+ type: "SOLID",
910
+ color: {
911
+ r: parseFloat(fontColor.r) || 0,
912
+ g: parseFloat(fontColor.g) || 0,
913
+ b: parseFloat(fontColor.b) || 0,
914
+ },
915
+ opacity: parseFloat(fontColor.a) || 1,
916
+ };
917
+ textNode.fills = [paintStyle];
918
+
919
+ // If parentId is provided, append to that node, otherwise append to current page
920
+ if (parentId) {
921
+ const parentNode = await figma.getNodeByIdAsync(parentId);
922
+ if (!parentNode) {
923
+ throw new Error(`Parent node not found with ID: ${parentId}`);
924
+ }
925
+ if (!("appendChild" in parentNode)) {
926
+ throw new Error(`Parent node does not support children: ${parentId}`);
927
+ }
928
+ parentNode.appendChild(textNode);
929
+ } else {
930
+ figma.currentPage.appendChild(textNode);
931
+ }
932
+
933
+ return {
934
+ id: textNode.id,
935
+ name: textNode.name,
936
+ x: textNode.x,
937
+ y: textNode.y,
938
+ width: textNode.width,
939
+ height: textNode.height,
940
+ characters: textNode.characters,
941
+ fontSize: textNode.fontSize,
942
+ fontWeight: fontWeight,
943
+ fontColor: fontColor,
944
+ fontName: textNode.fontName,
945
+ fills: textNode.fills,
946
+ parentId: textNode.parent ? textNode.parent.id : undefined,
947
+ };
948
+ }
949
+
950
+ async function setFillColor(params) {
951
+ console.log("setFillColor", params);
952
+ const {
953
+ nodeId,
954
+ color: { r, g, b, a },
955
+ } = params || {};
956
+
957
+ if (!nodeId) {
958
+ throw new Error("Missing nodeId parameter");
959
+ }
960
+
961
+ const node = await figma.getNodeByIdAsync(nodeId);
962
+ if (!node) {
963
+ throw new Error(`Node not found with ID: ${nodeId}`);
964
+ }
965
+
966
+ if (!("fills" in node)) {
967
+ throw new Error(`Node does not support fills: ${nodeId}`);
968
+ }
969
+
970
+ // Create RGBA color
971
+ const rgbColor = {
972
+ r: parseFloat(r) || 0,
973
+ g: parseFloat(g) || 0,
974
+ b: parseFloat(b) || 0,
975
+ a: parseFloat(a) || 1,
976
+ };
977
+
978
+ // Set fill
979
+ const paintStyle = {
980
+ type: "SOLID",
981
+ color: {
982
+ r: parseFloat(rgbColor.r),
983
+ g: parseFloat(rgbColor.g),
984
+ b: parseFloat(rgbColor.b),
985
+ },
986
+ opacity: parseFloat(rgbColor.a),
987
+ };
988
+
989
+ console.log("paintStyle", paintStyle);
990
+
991
+ node.fills = [paintStyle];
992
+
993
+ return {
994
+ id: node.id,
995
+ name: node.name,
996
+ fills: [paintStyle],
997
+ };
998
+ }
999
+
1000
+ async function setStrokeColor(params) {
1001
+ const {
1002
+ nodeId,
1003
+ color: { r, g, b, a },
1004
+ weight = 1,
1005
+ } = params || {};
1006
+
1007
+ if (!nodeId) {
1008
+ throw new Error("Missing nodeId parameter");
1009
+ }
1010
+
1011
+ const node = await figma.getNodeByIdAsync(nodeId);
1012
+ if (!node) {
1013
+ throw new Error(`Node not found with ID: ${nodeId}`);
1014
+ }
1015
+
1016
+ if (!("strokes" in node)) {
1017
+ throw new Error(`Node does not support strokes: ${nodeId}`);
1018
+ }
1019
+
1020
+ // Create RGBA color
1021
+ const rgbColor = {
1022
+ r: r !== undefined ? r : 0,
1023
+ g: g !== undefined ? g : 0,
1024
+ b: b !== undefined ? b : 0,
1025
+ a: a !== undefined ? a : 1,
1026
+ };
1027
+
1028
+ // Set stroke
1029
+ const paintStyle = {
1030
+ type: "SOLID",
1031
+ color: {
1032
+ r: rgbColor.r,
1033
+ g: rgbColor.g,
1034
+ b: rgbColor.b,
1035
+ },
1036
+ opacity: rgbColor.a,
1037
+ };
1038
+
1039
+ node.strokes = [paintStyle];
1040
+
1041
+ // Set stroke weight if available
1042
+ if ("strokeWeight" in node) {
1043
+ node.strokeWeight = weight;
1044
+ }
1045
+
1046
+ return {
1047
+ id: node.id,
1048
+ name: node.name,
1049
+ strokes: node.strokes,
1050
+ strokeWeight: "strokeWeight" in node ? node.strokeWeight : undefined,
1051
+ };
1052
+ }
1053
+
1054
+ async function moveNode(params) {
1055
+ const { nodeId, x, y } = params || {};
1056
+
1057
+ if (!nodeId) {
1058
+ throw new Error("Missing nodeId parameter");
1059
+ }
1060
+
1061
+ if (x === undefined || y === undefined) {
1062
+ throw new Error("Missing x or y parameters");
1063
+ }
1064
+
1065
+ const node = await figma.getNodeByIdAsync(nodeId);
1066
+ if (!node) {
1067
+ throw new Error(`Node not found with ID: ${nodeId}`);
1068
+ }
1069
+
1070
+ if (!("x" in node) || !("y" in node)) {
1071
+ throw new Error(`Node does not support position: ${nodeId}`);
1072
+ }
1073
+
1074
+ node.x = x;
1075
+ node.y = y;
1076
+
1077
+ return {
1078
+ id: node.id,
1079
+ name: node.name,
1080
+ x: node.x,
1081
+ y: node.y,
1082
+ };
1083
+ }
1084
+
1085
+ async function resizeNode(params) {
1086
+ const { nodeId, width, height } = params || {};
1087
+
1088
+ if (!nodeId) {
1089
+ throw new Error("Missing nodeId parameter");
1090
+ }
1091
+
1092
+ if (width === undefined || height === undefined) {
1093
+ throw new Error("Missing width or height parameters");
1094
+ }
1095
+
1096
+ const node = await figma.getNodeByIdAsync(nodeId);
1097
+ if (!node) {
1098
+ throw new Error(`Node not found with ID: ${nodeId}`);
1099
+ }
1100
+
1101
+ if (!("resize" in node)) {
1102
+ throw new Error(`Node does not support resizing: ${nodeId}`);
1103
+ }
1104
+
1105
+ node.resize(width, height);
1106
+
1107
+ return {
1108
+ id: node.id,
1109
+ name: node.name,
1110
+ width: node.width,
1111
+ height: node.height,
1112
+ };
1113
+ }
1114
+
1115
+ async function deleteNode(params) {
1116
+ const { nodeId } = params || {};
1117
+
1118
+ if (!nodeId) {
1119
+ throw new Error("Missing nodeId parameter");
1120
+ }
1121
+
1122
+ const node = await figma.getNodeByIdAsync(nodeId);
1123
+ if (!node) {
1124
+ throw new Error(`Node not found with ID: ${nodeId}`);
1125
+ }
1126
+
1127
+ // Save node info before deleting
1128
+ const nodeInfo = {
1129
+ id: node.id,
1130
+ name: node.name,
1131
+ type: node.type,
1132
+ };
1133
+
1134
+ node.remove();
1135
+
1136
+ return nodeInfo;
1137
+ }
1138
+
1139
+ async function getStyles() {
1140
+ const styles = {
1141
+ colors: await figma.getLocalPaintStylesAsync(),
1142
+ texts: await figma.getLocalTextStylesAsync(),
1143
+ effects: await figma.getLocalEffectStylesAsync(),
1144
+ grids: await figma.getLocalGridStylesAsync(),
1145
+ };
1146
+
1147
+ return {
1148
+ colors: styles.colors.map((style) => ({
1149
+ id: style.id,
1150
+ name: style.name,
1151
+ key: style.key,
1152
+ paint: style.paints[0],
1153
+ })),
1154
+ texts: styles.texts.map((style) => ({
1155
+ id: style.id,
1156
+ name: style.name,
1157
+ key: style.key,
1158
+ fontSize: style.fontSize,
1159
+ fontName: style.fontName,
1160
+ })),
1161
+ effects: styles.effects.map((style) => ({
1162
+ id: style.id,
1163
+ name: style.name,
1164
+ key: style.key,
1165
+ })),
1166
+ grids: styles.grids.map((style) => ({
1167
+ id: style.id,
1168
+ name: style.name,
1169
+ key: style.key,
1170
+ })),
1171
+ };
1172
+ }
1173
+
1174
+ async function getLocalComponents() {
1175
+ await figma.loadAllPagesAsync();
1176
+
1177
+ const components = figma.root.findAllWithCriteria({
1178
+ types: ["COMPONENT"],
1179
+ });
1180
+
1181
+ return {
1182
+ count: components.length,
1183
+ components: components.map((component) => ({
1184
+ id: component.id,
1185
+ name: component.name,
1186
+ key: "key" in component ? component.key : null,
1187
+ })),
1188
+ };
1189
+ }
1190
+
1191
+ async function getTeamComponents() {
1192
+ try {
1193
+ const teamComponents =
1194
+ await figma.teamLibrary.getAvailableComponentsAsync();
1195
+
1196
+ return {
1197
+ count: teamComponents.length,
1198
+ components: teamComponents.map((component) => ({
1199
+ key: component.key,
1200
+ name: component.name,
1201
+ description: component.description,
1202
+ libraryName: component.libraryName,
1203
+ })),
1204
+ };
1205
+ } catch (error) {
1206
+ throw new Error(`Error getting team components: ${error.message}`);
1207
+ }
1208
+ }
1209
+
1210
+ async function getTeamLibraryVariables() {
1211
+ try {
1212
+ const teamVariables =
1213
+ await figma.teamLibrary.getAvailableVariablesAsync();
1214
+
1215
+ return {
1216
+ count: teamVariables.length,
1217
+ variables: teamVariables.map((v) => ({
1218
+ key: v.key,
1219
+ name: v.name,
1220
+ resolvedType: v.resolvedType,
1221
+ libraryName: v.libraryName,
1222
+ })),
1223
+ };
1224
+ } catch (error) {
1225
+ throw new Error(`Error getting team library variables: ${error.message}`);
1226
+ }
1227
+ }
1228
+
1229
+ async function importVariableByKey(params) {
1230
+ const { key } = params || {};
1231
+ if (!key) {
1232
+ throw new Error("Missing key parameter");
1233
+ }
1234
+
1235
+ try {
1236
+ const variable = await figma.variables.importVariableByKeyAsync(key);
1237
+ return {
1238
+ id: variable.id,
1239
+ name: variable.name,
1240
+ resolvedType: variable.resolvedType,
1241
+ variableCollectionId: variable.variableCollectionId,
1242
+ };
1243
+ } catch (error) {
1244
+ throw new Error(`Error importing variable by key: ${error.message}`);
1245
+ }
1246
+ }
1247
+
1248
+ async function getLocalVariables() {
1249
+ try {
1250
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
1251
+ const variables = await figma.variables.getLocalVariablesAsync();
1252
+
1253
+ return {
1254
+ collections: collections.map((c) => ({
1255
+ id: c.id,
1256
+ name: c.name,
1257
+ modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
1258
+ variableIds: c.variableIds,
1259
+ })),
1260
+ variables: await Promise.all(variables.map(async (v) => {
1261
+ var values = {};
1262
+ var collection = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId);
1263
+ if (collection) {
1264
+ collection.modes.forEach((mode) => {
1265
+ try {
1266
+ var val = v.valuesByMode[mode.modeId];
1267
+ if (v.resolvedType === "COLOR" && val && typeof val === "object") {
1268
+ values[mode.modeId] = {
1269
+ r: val.r,
1270
+ g: val.g,
1271
+ b: val.b,
1272
+ a: val.a,
1273
+ hex: rgbaToHex(val),
1274
+ };
1275
+ } else {
1276
+ values[mode.modeId] = val;
1277
+ }
1278
+ } catch (e) {
1279
+ values[mode.modeId] = null;
1280
+ }
1281
+ });
1282
+ }
1283
+ return {
1284
+ id: v.id,
1285
+ name: v.name,
1286
+ resolvedType: v.resolvedType,
1287
+ variableCollectionId: v.variableCollectionId,
1288
+ valuesByMode: values,
1289
+ };
1290
+ })),
1291
+ };
1292
+ } catch (error) {
1293
+ throw new Error(`Error getting local variables: ${error.message}`);
1294
+ }
1295
+ }
1296
+
1297
+ async function getLocalVariableCollections() {
1298
+ try {
1299
+ const collections = await figma.variables.getLocalVariableCollectionsAsync();
1300
+ return {
1301
+ count: collections.length,
1302
+ collections: collections.map((c) => ({
1303
+ id: c.id,
1304
+ name: c.name,
1305
+ modes: c.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
1306
+ defaultModeId: c.defaultModeId,
1307
+ variableIds: c.variableIds,
1308
+ })),
1309
+ };
1310
+ } catch (error) {
1311
+ throw new Error(`Error getting local variable collections: ${error.message}`);
1312
+ }
1313
+ }
1314
+
1315
+ async function getVariableById(params) {
1316
+ const { variableId } = params || {};
1317
+ if (!variableId) {
1318
+ throw new Error("Missing variableId parameter");
1319
+ }
1320
+
1321
+ try {
1322
+ const v = await figma.variables.getVariableByIdAsync(variableId);
1323
+ if (!v) {
1324
+ throw new Error(`Variable not found: ${variableId}`);
1325
+ }
1326
+
1327
+ var values = {};
1328
+ var collection = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId);
1329
+ if (collection) {
1330
+ collection.modes.forEach((mode) => {
1331
+ try {
1332
+ var val = v.valuesByMode[mode.modeId];
1333
+ if (v.resolvedType === "COLOR" && val && typeof val === "object") {
1334
+ values[mode.modeId] = {
1335
+ r: val.r,
1336
+ g: val.g,
1337
+ b: val.b,
1338
+ a: val.a,
1339
+ hex: rgbaToHex(val),
1340
+ };
1341
+ } else {
1342
+ values[mode.modeId] = val;
1343
+ }
1344
+ } catch (e) {
1345
+ values[mode.modeId] = null;
1346
+ }
1347
+ });
1348
+ }
1349
+
1350
+ return {
1351
+ id: v.id,
1352
+ name: v.name,
1353
+ resolvedType: v.resolvedType,
1354
+ variableCollectionId: v.variableCollectionId,
1355
+ collectionName: collection ? collection.name : undefined,
1356
+ modes: collection ? collection.modes.map((m) => ({ modeId: m.modeId, name: m.name })) : [],
1357
+ valuesByMode: values,
1358
+ scopes: v.scopes,
1359
+ codeSyntax: v.codeSyntax,
1360
+ };
1361
+ } catch (error) {
1362
+ throw new Error(`Error getting variable by ID: ${error.message}`);
1363
+ }
1364
+ }
1365
+
1366
+ async function createVariableCollection(params) {
1367
+ const { name } = params || {};
1368
+ if (!name) {
1369
+ throw new Error("Missing name parameter");
1370
+ }
1371
+
1372
+ try {
1373
+ const collection = figma.variables.createVariableCollection(name);
1374
+ return {
1375
+ id: collection.id,
1376
+ name: collection.name,
1377
+ modes: collection.modes.map((m) => ({ modeId: m.modeId, name: m.name })),
1378
+ defaultModeId: collection.defaultModeId,
1379
+ };
1380
+ } catch (error) {
1381
+ throw new Error(`Error creating variable collection: ${error.message}`);
1382
+ }
1383
+ }
1384
+
1385
+ async function createVariable(params) {
1386
+ const { name, collectionId, resolvedType } = params || {};
1387
+ if (!name || !collectionId || !resolvedType) {
1388
+ throw new Error("Missing required parameters: name, collectionId, resolvedType");
1389
+ }
1390
+
1391
+ try {
1392
+ const variable = figma.variables.createVariable(name, collectionId, resolvedType);
1393
+ return {
1394
+ id: variable.id,
1395
+ name: variable.name,
1396
+ resolvedType: variable.resolvedType,
1397
+ variableCollectionId: variable.variableCollectionId,
1398
+ };
1399
+ } catch (error) {
1400
+ throw new Error(`Error creating variable: ${error.message}`);
1401
+ }
1402
+ }
1403
+
1404
+ async function setVariableValue(params) {
1405
+ const { variableId, modeId, value } = params || {};
1406
+ if (!variableId || !modeId || value === undefined) {
1407
+ throw new Error("Missing required parameters: variableId, modeId, value");
1408
+ }
1409
+
1410
+ try {
1411
+ const variable = await figma.variables.getVariableByIdAsync(variableId);
1412
+ if (!variable) {
1413
+ throw new Error(`Variable not found: ${variableId}`);
1414
+ }
1415
+ variable.setValueForMode(modeId, value);
1416
+ return {
1417
+ success: true,
1418
+ variableId: variable.id,
1419
+ name: variable.name,
1420
+ modeId: modeId,
1421
+ };
1422
+ } catch (error) {
1423
+ throw new Error(`Error setting variable value: ${error.message}`);
1424
+ }
1425
+ }
1426
+
1427
+ async function setVariableModeName(params) {
1428
+ const { collectionId, modeId, newName } = params || {};
1429
+ if (!collectionId || !modeId || !newName) {
1430
+ throw new Error("Missing required parameters: collectionId, modeId, newName");
1431
+ }
1432
+
1433
+ try {
1434
+ const collection = await figma.variables.getVariableCollectionByIdAsync(collectionId);
1435
+ if (!collection) {
1436
+ throw new Error(`Variable collection not found: ${collectionId}`);
1437
+ }
1438
+ collection.renameMode(modeId, newName);
1439
+ return {
1440
+ success: true,
1441
+ collectionId: collection.id,
1442
+ modeId: modeId,
1443
+ newName: newName,
1444
+ };
1445
+ } catch (error) {
1446
+ throw new Error(`Error renaming variable mode: ${error.message}`);
1447
+ }
1448
+ }
1449
+
1450
+ async function setVariableBinding(params) {
1451
+ const { nodeId, field, variableId } = params || {};
1452
+ if (!nodeId || !field || !variableId) {
1453
+ throw new Error("Missing required parameters: nodeId, field, variableId");
1454
+ }
1455
+
1456
+ try {
1457
+ const node = await figma.getNodeByIdAsync(nodeId);
1458
+ if (!node) {
1459
+ throw new Error(`Node not found: ${nodeId}`);
1460
+ }
1461
+ const variable = await figma.variables.getVariableByIdAsync(variableId);
1462
+ if (!variable) {
1463
+ throw new Error(`Variable not found: ${variableId}`);
1464
+ }
1465
+ node.setBoundVariable(field, variable);
1466
+ return {
1467
+ success: true,
1468
+ nodeId: node.id,
1469
+ field: field,
1470
+ variableId: variable.id,
1471
+ variableName: variable.name,
1472
+ };
1473
+ } catch (error) {
1474
+ throw new Error(`Error setting variable binding: ${error.message}`);
1475
+ }
1476
+ }
1477
+
1478
+ async function createComponentInstance(params) {
1479
+ const { componentKey, x = 0, y = 0 } = params || {};
1480
+
1481
+ if (!componentKey) {
1482
+ throw new Error("Missing componentKey parameter");
1483
+ }
1484
+
1485
+ try {
1486
+ const component = await figma.importComponentByKeyAsync(componentKey);
1487
+ const instance = component.createInstance();
1488
+
1489
+ instance.x = x;
1490
+ instance.y = y;
1491
+
1492
+ figma.currentPage.appendChild(instance);
1493
+
1494
+ return {
1495
+ id: instance.id,
1496
+ name: instance.name,
1497
+ x: instance.x,
1498
+ y: instance.y,
1499
+ width: instance.width,
1500
+ height: instance.height,
1501
+ componentId: instance.componentId,
1502
+ };
1503
+ } catch (error) {
1504
+ throw new Error(`Error creating component instance: ${error.message}`);
1505
+ }
1506
+ }
1507
+
1508
+ async function exportNodeAsImage(params) {
1509
+ const { nodeId, scale = 1 } = params || {};
1510
+
1511
+ const format = "PNG";
1512
+
1513
+ if (!nodeId) {
1514
+ throw new Error("Missing nodeId parameter");
1515
+ }
1516
+
1517
+ const node = await figma.getNodeByIdAsync(nodeId);
1518
+ if (!node) {
1519
+ throw new Error(`Node not found with ID: ${nodeId}`);
1520
+ }
1521
+
1522
+ if (!("exportAsync" in node)) {
1523
+ throw new Error(`Node does not support exporting: ${nodeId}`);
1524
+ }
1525
+
1526
+ try {
1527
+ const settings = {
1528
+ format: format,
1529
+ constraint: { type: "SCALE", value: scale },
1530
+ };
1531
+
1532
+ const bytes = await node.exportAsync(settings);
1533
+
1534
+ let mimeType;
1535
+ switch (format) {
1536
+ case "PNG":
1537
+ mimeType = "image/png";
1538
+ break;
1539
+ case "JPG":
1540
+ mimeType = "image/jpeg";
1541
+ break;
1542
+ case "SVG":
1543
+ mimeType = "image/svg+xml";
1544
+ break;
1545
+ case "PDF":
1546
+ mimeType = "application/pdf";
1547
+ break;
1548
+ default:
1549
+ mimeType = "application/octet-stream";
1550
+ }
1551
+
1552
+ // Proper way to convert Uint8Array to base64
1553
+ const base64 = customBase64Encode(bytes);
1554
+ // const imageData = `data:${mimeType};base64,${base64}`;
1555
+
1556
+ return {
1557
+ nodeId,
1558
+ format,
1559
+ scale,
1560
+ mimeType,
1561
+ imageData: base64,
1562
+ };
1563
+ } catch (error) {
1564
+ throw new Error(`Error exporting node as image: ${error.message}`);
1565
+ }
1566
+ }
1567
+ function customBase64Encode(bytes) {
1568
+ const chars =
1569
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1570
+ let base64 = "";
1571
+
1572
+ const byteLength = bytes.byteLength;
1573
+ const byteRemainder = byteLength % 3;
1574
+ const mainLength = byteLength - byteRemainder;
1575
+
1576
+ let a, b, c, d;
1577
+ let chunk;
1578
+
1579
+ // Main loop deals with bytes in chunks of 3
1580
+ for (let i = 0; i < mainLength; i = i + 3) {
1581
+ // Combine the three bytes into a single integer
1582
+ chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
1583
+
1584
+ // Use bitmasks to extract 6-bit segments from the triplet
1585
+ a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
1586
+ b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
1587
+ c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
1588
+ d = chunk & 63; // 63 = 2^6 - 1
1589
+
1590
+ // Convert the raw binary segments to the appropriate ASCII encoding
1591
+ base64 += chars[a] + chars[b] + chars[c] + chars[d];
1592
+ }
1593
+
1594
+ // Deal with the remaining bytes and padding
1595
+ if (byteRemainder === 1) {
1596
+ chunk = bytes[mainLength];
1597
+
1598
+ a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
1599
+
1600
+ // Set the 4 least significant bits to zero
1601
+ b = (chunk & 3) << 4; // 3 = 2^2 - 1
1602
+
1603
+ base64 += chars[a] + chars[b] + "==";
1604
+ } else if (byteRemainder === 2) {
1605
+ chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
1606
+
1607
+ a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
1608
+ b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
1609
+
1610
+ // Set the 2 least significant bits to zero
1611
+ c = (chunk & 15) << 2; // 15 = 2^4 - 1
1612
+
1613
+ base64 += chars[a] + chars[b] + chars[c] + "=";
1614
+ }
1615
+
1616
+ return base64;
1617
+ }
1618
+
1619
+ async function setCornerRadius(params) {
1620
+ const { nodeId, radius, corners } = params || {};
1621
+
1622
+ if (!nodeId) {
1623
+ throw new Error("Missing nodeId parameter");
1624
+ }
1625
+
1626
+ if (radius === undefined) {
1627
+ throw new Error("Missing radius parameter");
1628
+ }
1629
+
1630
+ const node = await figma.getNodeByIdAsync(nodeId);
1631
+ if (!node) {
1632
+ throw new Error(`Node not found with ID: ${nodeId}`);
1633
+ }
1634
+
1635
+ // Check if node supports corner radius
1636
+ if (!("cornerRadius" in node)) {
1637
+ throw new Error(`Node does not support corner radius: ${nodeId}`);
1638
+ }
1639
+
1640
+ // If corners array is provided, set individual corner radii
1641
+ if (corners && Array.isArray(corners) && corners.length === 4) {
1642
+ if ("topLeftRadius" in node) {
1643
+ // Node supports individual corner radii
1644
+ if (corners[0]) node.topLeftRadius = radius;
1645
+ if (corners[1]) node.topRightRadius = radius;
1646
+ if (corners[2]) node.bottomRightRadius = radius;
1647
+ if (corners[3]) node.bottomLeftRadius = radius;
1648
+ } else {
1649
+ // Node only supports uniform corner radius
1650
+ node.cornerRadius = radius;
1651
+ }
1652
+ } else {
1653
+ // Set uniform corner radius
1654
+ node.cornerRadius = radius;
1655
+ }
1656
+
1657
+ return {
1658
+ id: node.id,
1659
+ name: node.name,
1660
+ cornerRadius: "cornerRadius" in node ? node.cornerRadius : undefined,
1661
+ topLeftRadius: "topLeftRadius" in node ? node.topLeftRadius : undefined,
1662
+ topRightRadius: "topRightRadius" in node ? node.topRightRadius : undefined,
1663
+ bottomRightRadius:
1664
+ "bottomRightRadius" in node ? node.bottomRightRadius : undefined,
1665
+ bottomLeftRadius:
1666
+ "bottomLeftRadius" in node ? node.bottomLeftRadius : undefined,
1667
+ };
1668
+ }
1669
+
1670
+ async function setTextContent(params) {
1671
+ const { nodeId, text } = params || {};
1672
+
1673
+ if (!nodeId) {
1674
+ throw new Error("Missing nodeId parameter");
1675
+ }
1676
+
1677
+ if (text === undefined) {
1678
+ throw new Error("Missing text parameter");
1679
+ }
1680
+
1681
+ const node = await figma.getNodeByIdAsync(nodeId);
1682
+ if (!node) {
1683
+ throw new Error(`Node not found with ID: ${nodeId}`);
1684
+ }
1685
+
1686
+ if (node.type !== "TEXT") {
1687
+ throw new Error(`Node is not a text node: ${nodeId}`);
1688
+ }
1689
+
1690
+ try {
1691
+ await figma.loadFontAsync(node.fontName);
1692
+
1693
+ await setCharacters(node, text);
1694
+
1695
+ return {
1696
+ id: node.id,
1697
+ name: node.name,
1698
+ characters: node.characters,
1699
+ fontName: node.fontName,
1700
+ };
1701
+ } catch (error) {
1702
+ throw new Error(`Error setting text content: ${error.message}`);
1703
+ }
1704
+ }
1705
+
1706
+ // Initialize settings on load
1707
+ (async function initializePlugin() {
1708
+ try {
1709
+ const savedSettings = await figma.clientStorage.getAsync("settings");
1710
+ if (savedSettings) {
1711
+ if (savedSettings.serverPort) {
1712
+ state.serverPort = savedSettings.serverPort;
1713
+ }
1714
+ }
1715
+
1716
+ // Send initial settings to UI
1717
+ figma.ui.postMessage({
1718
+ type: "init-settings",
1719
+ settings: {
1720
+ serverPort: state.serverPort,
1721
+ },
1722
+ });
1723
+ } catch (error) {
1724
+ console.error("Error loading settings:", error);
1725
+ }
1726
+ })();
1727
+
1728
+ function uniqBy(arr, predicate) {
1729
+ const cb = typeof predicate === "function" ? predicate : (o) => o[predicate];
1730
+ return [
1731
+ ...arr
1732
+ .reduce((map, item) => {
1733
+ const key = item === null || item === undefined ? item : cb(item);
1734
+
1735
+ map.has(key) || map.set(key, item);
1736
+
1737
+ return map;
1738
+ }, new Map())
1739
+ .values(),
1740
+ ];
1741
+ }
1742
+ const setCharacters = async (node, characters, options) => {
1743
+ const fallbackFont = (options && options.fallbackFont) || {
1744
+ family: "Inter",
1745
+ style: "Regular",
1746
+ };
1747
+ try {
1748
+ if (node.fontName === figma.mixed) {
1749
+ if (options && options.smartStrategy === "prevail") {
1750
+ const fontHashTree = {};
1751
+ for (let i = 1; i < node.characters.length; i++) {
1752
+ const charFont = node.getRangeFontName(i - 1, i);
1753
+ const key = `${charFont.family}::${charFont.style}`;
1754
+ fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1;
1755
+ }
1756
+ const prevailedTreeItem = Object.entries(fontHashTree).sort(
1757
+ (a, b) => b[1] - a[1]
1758
+ )[0];
1759
+ const [family, style] = prevailedTreeItem[0].split("::");
1760
+ const prevailedFont = {
1761
+ family,
1762
+ style,
1763
+ };
1764
+ await figma.loadFontAsync(prevailedFont);
1765
+ node.fontName = prevailedFont;
1766
+ } else if (options && options.smartStrategy === "strict") {
1767
+ return setCharactersWithStrictMatchFont(node, characters, fallbackFont);
1768
+ } else if (options && options.smartStrategy === "experimental") {
1769
+ return setCharactersWithSmartMatchFont(node, characters, fallbackFont);
1770
+ } else {
1771
+ const firstCharFont = node.getRangeFontName(0, 1);
1772
+ await figma.loadFontAsync(firstCharFont);
1773
+ node.fontName = firstCharFont;
1774
+ }
1775
+ } else {
1776
+ await figma.loadFontAsync({
1777
+ family: node.fontName.family,
1778
+ style: node.fontName.style,
1779
+ });
1780
+ }
1781
+ } catch (err) {
1782
+ console.warn(
1783
+ `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`,
1784
+ err
1785
+ );
1786
+ await figma.loadFontAsync(fallbackFont);
1787
+ node.fontName = fallbackFont;
1788
+ }
1789
+ try {
1790
+ node.characters = characters;
1791
+ return true;
1792
+ } catch (err) {
1793
+ console.warn(`Failed to set characters. Skipped.`, err);
1794
+ return false;
1795
+ }
1796
+ };
1797
+
1798
+ const setCharactersWithStrictMatchFont = async (
1799
+ node,
1800
+ characters,
1801
+ fallbackFont
1802
+ ) => {
1803
+ const fontHashTree = {};
1804
+ for (let i = 1; i < node.characters.length; i++) {
1805
+ const startIdx = i - 1;
1806
+ const startCharFont = node.getRangeFontName(startIdx, i);
1807
+ const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`;
1808
+ while (i < node.characters.length) {
1809
+ i++;
1810
+ const charFont = node.getRangeFontName(i - 1, i);
1811
+ if (startCharFontVal !== `${charFont.family}::${charFont.style}`) {
1812
+ break;
1813
+ }
1814
+ }
1815
+ fontHashTree[`${startIdx}_${i}`] = startCharFontVal;
1816
+ }
1817
+ await figma.loadFontAsync(fallbackFont);
1818
+ node.fontName = fallbackFont;
1819
+ node.characters = characters;
1820
+ console.log(fontHashTree);
1821
+ await Promise.all(
1822
+ Object.keys(fontHashTree).map(async (range) => {
1823
+ console.log(range, fontHashTree[range]);
1824
+ const [start, end] = range.split("_");
1825
+ const [family, style] = fontHashTree[range].split("::");
1826
+ const matchedFont = {
1827
+ family,
1828
+ style,
1829
+ };
1830
+ await figma.loadFontAsync(matchedFont);
1831
+ return node.setRangeFontName(Number(start), Number(end), matchedFont);
1832
+ })
1833
+ );
1834
+ return true;
1835
+ };
1836
+
1837
+ const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => {
1838
+ const indices = [];
1839
+ let temp = startIdx;
1840
+ for (let i = startIdx; i < endIdx; i++) {
1841
+ if (
1842
+ str[i] === delimiter &&
1843
+ i + startIdx !== endIdx &&
1844
+ temp !== i + startIdx
1845
+ ) {
1846
+ indices.push([temp, i + startIdx]);
1847
+ temp = i + startIdx + 1;
1848
+ }
1849
+ }
1850
+ temp !== endIdx && indices.push([temp, endIdx]);
1851
+ return indices.filter(Boolean);
1852
+ };
1853
+
1854
+ const buildLinearOrder = (node) => {
1855
+ const fontTree = [];
1856
+ const newLinesPos = getDelimiterPos(node.characters, "\n");
1857
+ newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => {
1858
+ const newLinesRangeFont = node.getRangeFontName(
1859
+ newLinesRangeStart,
1860
+ newLinesRangeEnd
1861
+ );
1862
+ if (newLinesRangeFont === figma.mixed) {
1863
+ const spacesPos = getDelimiterPos(
1864
+ node.characters,
1865
+ " ",
1866
+ newLinesRangeStart,
1867
+ newLinesRangeEnd
1868
+ );
1869
+ spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => {
1870
+ const spacesRangeFont = node.getRangeFontName(
1871
+ spacesRangeStart,
1872
+ spacesRangeEnd
1873
+ );
1874
+ if (spacesRangeFont === figma.mixed) {
1875
+ const spacesRangeFont = node.getRangeFontName(
1876
+ spacesRangeStart,
1877
+ spacesRangeStart[0]
1878
+ );
1879
+ fontTree.push({
1880
+ start: spacesRangeStart,
1881
+ delimiter: " ",
1882
+ family: spacesRangeFont.family,
1883
+ style: spacesRangeFont.style,
1884
+ });
1885
+ } else {
1886
+ fontTree.push({
1887
+ start: spacesRangeStart,
1888
+ delimiter: " ",
1889
+ family: spacesRangeFont.family,
1890
+ style: spacesRangeFont.style,
1891
+ });
1892
+ }
1893
+ });
1894
+ } else {
1895
+ fontTree.push({
1896
+ start: newLinesRangeStart,
1897
+ delimiter: "\n",
1898
+ family: newLinesRangeFont.family,
1899
+ style: newLinesRangeFont.style,
1900
+ });
1901
+ }
1902
+ });
1903
+ return fontTree
1904
+ .sort((a, b) => +a.start - +b.start)
1905
+ .map(({ family, style, delimiter }) => ({ family, style, delimiter }));
1906
+ };
1907
+
1908
+ const setCharactersWithSmartMatchFont = async (
1909
+ node,
1910
+ characters,
1911
+ fallbackFont
1912
+ ) => {
1913
+ const rangeTree = buildLinearOrder(node);
1914
+ const fontsToLoad = uniqBy(
1915
+ rangeTree,
1916
+ ({ family, style }) => `${family}::${style}`
1917
+ ).map(({ family, style }) => ({
1918
+ family,
1919
+ style,
1920
+ }));
1921
+
1922
+ await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync));
1923
+
1924
+ node.fontName = fallbackFont;
1925
+ node.characters = characters;
1926
+
1927
+ let prevPos = 0;
1928
+ rangeTree.forEach(({ family, style, delimiter }) => {
1929
+ if (prevPos < node.characters.length) {
1930
+ const delimeterPos = node.characters.indexOf(delimiter, prevPos);
1931
+ const endPos =
1932
+ delimeterPos > prevPos ? delimeterPos : node.characters.length;
1933
+ const matchedFont = {
1934
+ family,
1935
+ style,
1936
+ };
1937
+ node.setRangeFontName(prevPos, endPos, matchedFont);
1938
+ prevPos = endPos + 1;
1939
+ }
1940
+ });
1941
+ return true;
1942
+ };
1943
+
1944
+ // Add the cloneNode function implementation
1945
+ async function cloneNode(params) {
1946
+ const { nodeId, x, y } = params || {};
1947
+
1948
+ if (!nodeId) {
1949
+ throw new Error("Missing nodeId parameter");
1950
+ }
1951
+
1952
+ const node = await figma.getNodeByIdAsync(nodeId);
1953
+ if (!node) {
1954
+ throw new Error(`Node not found with ID: ${nodeId}`);
1955
+ }
1956
+
1957
+ // Clone the node
1958
+ const clone = node.clone();
1959
+
1960
+ // If x and y are provided, move the clone to that position
1961
+ if (x !== undefined && y !== undefined) {
1962
+ if (!("x" in clone) || !("y" in clone)) {
1963
+ throw new Error(`Cloned node does not support position: ${nodeId}`);
1964
+ }
1965
+ clone.x = x;
1966
+ clone.y = y;
1967
+ }
1968
+
1969
+ // Add the clone to the same parent as the original node
1970
+ if (node.parent) {
1971
+ node.parent.appendChild(clone);
1972
+ } else {
1973
+ figma.currentPage.appendChild(clone);
1974
+ }
1975
+
1976
+ return {
1977
+ id: clone.id,
1978
+ name: clone.name,
1979
+ x: "x" in clone ? clone.x : undefined,
1980
+ y: "y" in clone ? clone.y : undefined,
1981
+ width: "width" in clone ? clone.width : undefined,
1982
+ height: "height" in clone ? clone.height : undefined,
1983
+ };
1984
+ }
1985
+
1986
+ async function scanTextNodes(params) {
1987
+ console.log(`Starting to scan text nodes from node ID: ${params.nodeId}`);
1988
+ const {
1989
+ nodeId,
1990
+ useChunking = true,
1991
+ chunkSize = 10,
1992
+ commandId = generateCommandId(),
1993
+ } = params || {};
1994
+
1995
+ const node = await figma.getNodeByIdAsync(nodeId);
1996
+
1997
+ if (!node) {
1998
+ console.error(`Node with ID ${nodeId} not found`);
1999
+ // Send error progress update
2000
+ sendProgressUpdate(
2001
+ commandId,
2002
+ "scan_text_nodes",
2003
+ "error",
2004
+ 0,
2005
+ 0,
2006
+ 0,
2007
+ `Node with ID ${nodeId} not found`,
2008
+ { error: `Node not found: ${nodeId}` }
2009
+ );
2010
+ throw new Error(`Node with ID ${nodeId} not found`);
2011
+ }
2012
+
2013
+ // If chunking is not enabled, use the original implementation
2014
+ if (!useChunking) {
2015
+ const textNodes = [];
2016
+ try {
2017
+ // Send started progress update
2018
+ sendProgressUpdate(
2019
+ commandId,
2020
+ "scan_text_nodes",
2021
+ "started",
2022
+ 0,
2023
+ 1, // Not known yet how many nodes there are
2024
+ 0,
2025
+ `Starting scan of node "${node.name || nodeId}" without chunking`,
2026
+ null
2027
+ );
2028
+
2029
+ await findTextNodes(node, [], 0, textNodes);
2030
+
2031
+ // Send completed progress update
2032
+ sendProgressUpdate(
2033
+ commandId,
2034
+ "scan_text_nodes",
2035
+ "completed",
2036
+ 100,
2037
+ textNodes.length,
2038
+ textNodes.length,
2039
+ `Scan complete. Found ${textNodes.length} text nodes.`,
2040
+ { textNodes }
2041
+ );
2042
+
2043
+ return {
2044
+ success: true,
2045
+ message: `Scanned ${textNodes.length} text nodes.`,
2046
+ count: textNodes.length,
2047
+ textNodes: textNodes,
2048
+ commandId,
2049
+ };
2050
+ } catch (error) {
2051
+ console.error("Error scanning text nodes:", error);
2052
+
2053
+ // Send error progress update
2054
+ sendProgressUpdate(
2055
+ commandId,
2056
+ "scan_text_nodes",
2057
+ "error",
2058
+ 0,
2059
+ 0,
2060
+ 0,
2061
+ `Error scanning text nodes: ${error.message}`,
2062
+ { error: error.message }
2063
+ );
2064
+
2065
+ throw new Error(`Error scanning text nodes: ${error.message}`);
2066
+ }
2067
+ }
2068
+
2069
+ // Chunked implementation
2070
+ console.log(`Using chunked scanning with chunk size: ${chunkSize}`);
2071
+
2072
+ // First, collect all nodes to process (without processing them yet)
2073
+ const nodesToProcess = [];
2074
+
2075
+ // Send started progress update
2076
+ sendProgressUpdate(
2077
+ commandId,
2078
+ "scan_text_nodes",
2079
+ "started",
2080
+ 0,
2081
+ 0, // Not known yet how many nodes there are
2082
+ 0,
2083
+ `Starting chunked scan of node "${node.name || nodeId}"`,
2084
+ { chunkSize }
2085
+ );
2086
+
2087
+ await collectNodesToProcess(node, [], 0, nodesToProcess);
2088
+
2089
+ const totalNodes = nodesToProcess.length;
2090
+ console.log(`Found ${totalNodes} total nodes to process`);
2091
+
2092
+ // Calculate number of chunks needed
2093
+ const totalChunks = Math.ceil(totalNodes / chunkSize);
2094
+ console.log(`Will process in ${totalChunks} chunks`);
2095
+
2096
+ // Send update after node collection
2097
+ sendProgressUpdate(
2098
+ commandId,
2099
+ "scan_text_nodes",
2100
+ "in_progress",
2101
+ 5, // 5% progress for collection phase
2102
+ totalNodes,
2103
+ 0,
2104
+ `Found ${totalNodes} nodes to scan. Will process in ${totalChunks} chunks.`,
2105
+ {
2106
+ totalNodes,
2107
+ totalChunks,
2108
+ chunkSize,
2109
+ }
2110
+ );
2111
+
2112
+ // Process nodes in chunks
2113
+ const allTextNodes = [];
2114
+ let processedNodes = 0;
2115
+ let chunksProcessed = 0;
2116
+
2117
+ for (let i = 0; i < totalNodes; i += chunkSize) {
2118
+ const chunkEnd = Math.min(i + chunkSize, totalNodes);
2119
+ console.log(
2120
+ `Processing chunk ${chunksProcessed + 1}/${totalChunks} (nodes ${i} to ${chunkEnd - 1
2121
+ })`
2122
+ );
2123
+
2124
+ // Send update before processing chunk
2125
+ sendProgressUpdate(
2126
+ commandId,
2127
+ "scan_text_nodes",
2128
+ "in_progress",
2129
+ Math.round(5 + (chunksProcessed / totalChunks) * 90), // 5-95% for processing
2130
+ totalNodes,
2131
+ processedNodes,
2132
+ `Processing chunk ${chunksProcessed + 1}/${totalChunks}`,
2133
+ {
2134
+ currentChunk: chunksProcessed + 1,
2135
+ totalChunks,
2136
+ textNodesFound: allTextNodes.length,
2137
+ }
2138
+ );
2139
+
2140
+ const chunkNodes = nodesToProcess.slice(i, chunkEnd);
2141
+ const chunkTextNodes = [];
2142
+
2143
+ // Process each node in this chunk
2144
+ for (const nodeInfo of chunkNodes) {
2145
+ if (nodeInfo.node.type === "TEXT") {
2146
+ try {
2147
+ const textNodeInfo = await processTextNode(
2148
+ nodeInfo.node,
2149
+ nodeInfo.parentPath,
2150
+ nodeInfo.depth
2151
+ );
2152
+ if (textNodeInfo) {
2153
+ chunkTextNodes.push(textNodeInfo);
2154
+ }
2155
+ } catch (error) {
2156
+ console.error(`Error processing text node: ${error.message}`);
2157
+ // Continue with other nodes
2158
+ }
2159
+ }
2160
+
2161
+ // Brief delay to allow UI updates and prevent freezing
2162
+ await delay(5);
2163
+ }
2164
+
2165
+ // Add results from this chunk
2166
+ allTextNodes.push(...chunkTextNodes);
2167
+ processedNodes += chunkNodes.length;
2168
+ chunksProcessed++;
2169
+
2170
+ // Send update after processing chunk
2171
+ sendProgressUpdate(
2172
+ commandId,
2173
+ "scan_text_nodes",
2174
+ "in_progress",
2175
+ Math.round(5 + (chunksProcessed / totalChunks) * 90), // 5-95% for processing
2176
+ totalNodes,
2177
+ processedNodes,
2178
+ `Processed chunk ${chunksProcessed}/${totalChunks}. Found ${allTextNodes.length} text nodes so far.`,
2179
+ {
2180
+ currentChunk: chunksProcessed,
2181
+ totalChunks,
2182
+ processedNodes,
2183
+ textNodesFound: allTextNodes.length,
2184
+ chunkResult: chunkTextNodes,
2185
+ }
2186
+ );
2187
+
2188
+ // Small delay between chunks to prevent UI freezing
2189
+ if (i + chunkSize < totalNodes) {
2190
+ await delay(50);
2191
+ }
2192
+ }
2193
+
2194
+ // Send completed progress update
2195
+ sendProgressUpdate(
2196
+ commandId,
2197
+ "scan_text_nodes",
2198
+ "completed",
2199
+ 100,
2200
+ totalNodes,
2201
+ processedNodes,
2202
+ `Scan complete. Found ${allTextNodes.length} text nodes.`,
2203
+ {
2204
+ textNodes: allTextNodes,
2205
+ processedNodes,
2206
+ chunks: chunksProcessed,
2207
+ }
2208
+ );
2209
+
2210
+ return {
2211
+ success: true,
2212
+ message: `Chunked scan complete. Found ${allTextNodes.length} text nodes.`,
2213
+ totalNodes: allTextNodes.length,
2214
+ processedNodes: processedNodes,
2215
+ chunks: chunksProcessed,
2216
+ textNodes: allTextNodes,
2217
+ commandId,
2218
+ };
2219
+ }
2220
+
2221
+ // Helper function to collect all nodes that need to be processed
2222
+ async function collectNodesToProcess(
2223
+ node,
2224
+ parentPath = [],
2225
+ depth = 0,
2226
+ nodesToProcess = []
2227
+ ) {
2228
+ // Skip invisible nodes
2229
+ if (node.visible === false) return;
2230
+
2231
+ // Get the path to this node
2232
+ const nodePath = [...parentPath, node.name || `Unnamed ${node.type}`];
2233
+
2234
+ // Add this node to the processing list
2235
+ nodesToProcess.push({
2236
+ node: node,
2237
+ parentPath: nodePath,
2238
+ depth: depth,
2239
+ });
2240
+
2241
+ // Recursively add children
2242
+ if ("children" in node) {
2243
+ for (const child of node.children) {
2244
+ await collectNodesToProcess(child, nodePath, depth + 1, nodesToProcess);
2245
+ }
2246
+ }
2247
+ }
2248
+
2249
+ // Process a single text node
2250
+ async function processTextNode(node, parentPath, depth) {
2251
+ if (node.type !== "TEXT") return null;
2252
+
2253
+ try {
2254
+ // Safely extract font information
2255
+ let fontFamily = "";
2256
+ let fontStyle = "";
2257
+
2258
+ if (node.fontName) {
2259
+ if (typeof node.fontName === "object") {
2260
+ if ("family" in node.fontName) fontFamily = node.fontName.family;
2261
+ if ("style" in node.fontName) fontStyle = node.fontName.style;
2262
+ }
2263
+ }
2264
+
2265
+ // Create a safe representation of the text node
2266
+ const safeTextNode = {
2267
+ id: node.id,
2268
+ name: node.name || "Text",
2269
+ type: node.type,
2270
+ characters: node.characters,
2271
+ fontSize: typeof node.fontSize === "number" ? node.fontSize : 0,
2272
+ fontFamily: fontFamily,
2273
+ fontStyle: fontStyle,
2274
+ x: typeof node.x === "number" ? node.x : 0,
2275
+ y: typeof node.y === "number" ? node.y : 0,
2276
+ width: typeof node.width === "number" ? node.width : 0,
2277
+ height: typeof node.height === "number" ? node.height : 0,
2278
+ path: parentPath.join(" > "),
2279
+ depth: depth,
2280
+ };
2281
+
2282
+ // Highlight the node briefly (optional visual feedback)
2283
+ try {
2284
+ const originalFills = JSON.parse(JSON.stringify(node.fills));
2285
+ node.fills = [
2286
+ {
2287
+ type: "SOLID",
2288
+ color: { r: 1, g: 0.5, b: 0 },
2289
+ opacity: 0.3,
2290
+ },
2291
+ ];
2292
+
2293
+ // Brief delay for the highlight to be visible
2294
+ await delay(100);
2295
+
2296
+ try {
2297
+ node.fills = originalFills;
2298
+ } catch (err) {
2299
+ console.error("Error resetting fills:", err);
2300
+ }
2301
+ } catch (highlightErr) {
2302
+ console.error("Error highlighting text node:", highlightErr);
2303
+ // Continue anyway, highlighting is just visual feedback
2304
+ }
2305
+
2306
+ return safeTextNode;
2307
+ } catch (nodeErr) {
2308
+ console.error("Error processing text node:", nodeErr);
2309
+ return null;
2310
+ }
2311
+ }
2312
+
2313
+ // A delay function that returns a promise
2314
+ function delay(ms) {
2315
+ return new Promise((resolve) => setTimeout(resolve, ms));
2316
+ }
2317
+
2318
+ // Keep the original findTextNodes for backward compatibility
2319
+ async function findTextNodes(node, parentPath = [], depth = 0, textNodes = []) {
2320
+ // Skip invisible nodes
2321
+ if (node.visible === false) return;
2322
+
2323
+ // Get the path to this node including its name
2324
+ const nodePath = [...parentPath, node.name || `Unnamed ${node.type}`];
2325
+
2326
+ if (node.type === "TEXT") {
2327
+ try {
2328
+ // Safely extract font information to avoid Symbol serialization issues
2329
+ let fontFamily = "";
2330
+ let fontStyle = "";
2331
+
2332
+ if (node.fontName) {
2333
+ if (typeof node.fontName === "object") {
2334
+ if ("family" in node.fontName) fontFamily = node.fontName.family;
2335
+ if ("style" in node.fontName) fontStyle = node.fontName.style;
2336
+ }
2337
+ }
2338
+
2339
+ // Create a safe representation of the text node with only serializable properties
2340
+ const safeTextNode = {
2341
+ id: node.id,
2342
+ name: node.name || "Text",
2343
+ type: node.type,
2344
+ characters: node.characters,
2345
+ fontSize: typeof node.fontSize === "number" ? node.fontSize : 0,
2346
+ fontFamily: fontFamily,
2347
+ fontStyle: fontStyle,
2348
+ x: typeof node.x === "number" ? node.x : 0,
2349
+ y: typeof node.y === "number" ? node.y : 0,
2350
+ width: typeof node.width === "number" ? node.width : 0,
2351
+ height: typeof node.height === "number" ? node.height : 0,
2352
+ path: nodePath.join(" > "),
2353
+ depth: depth,
2354
+ };
2355
+
2356
+ // Only highlight the node if it's not being done via API
2357
+ try {
2358
+ // Safe way to create a temporary highlight without causing serialization issues
2359
+ const originalFills = JSON.parse(JSON.stringify(node.fills));
2360
+ node.fills = [
2361
+ {
2362
+ type: "SOLID",
2363
+ color: { r: 1, g: 0.5, b: 0 },
2364
+ opacity: 0.3,
2365
+ },
2366
+ ];
2367
+
2368
+ // Promise-based delay instead of setTimeout
2369
+ await delay(500);
2370
+
2371
+ try {
2372
+ node.fills = originalFills;
2373
+ } catch (err) {
2374
+ console.error("Error resetting fills:", err);
2375
+ }
2376
+ } catch (highlightErr) {
2377
+ console.error("Error highlighting text node:", highlightErr);
2378
+ // Continue anyway, highlighting is just visual feedback
2379
+ }
2380
+
2381
+ textNodes.push(safeTextNode);
2382
+ } catch (nodeErr) {
2383
+ console.error("Error processing text node:", nodeErr);
2384
+ // Skip this node but continue with others
2385
+ }
2386
+ }
2387
+
2388
+ // Recursively process children of container nodes
2389
+ if ("children" in node) {
2390
+ for (const child of node.children) {
2391
+ await findTextNodes(child, nodePath, depth + 1, textNodes);
2392
+ }
2393
+ }
2394
+ }
2395
+
2396
+ // Replace text in a specific node
2397
+ async function setMultipleTextContents(params) {
2398
+ const { nodeId, text } = params || {};
2399
+ const commandId = params.commandId || generateCommandId();
2400
+
2401
+ if (!nodeId || !text || !Array.isArray(text)) {
2402
+ const errorMsg = "Missing required parameters: nodeId and text array";
2403
+
2404
+ // Send error progress update
2405
+ sendProgressUpdate(
2406
+ commandId,
2407
+ "set_multiple_text_contents",
2408
+ "error",
2409
+ 0,
2410
+ 0,
2411
+ 0,
2412
+ errorMsg,
2413
+ { error: errorMsg }
2414
+ );
2415
+
2416
+ throw new Error(errorMsg);
2417
+ }
2418
+
2419
+ console.log(
2420
+ `Starting text replacement for node: ${nodeId} with ${text.length} text replacements`
2421
+ );
2422
+
2423
+ // Send started progress update
2424
+ sendProgressUpdate(
2425
+ commandId,
2426
+ "set_multiple_text_contents",
2427
+ "started",
2428
+ 0,
2429
+ text.length,
2430
+ 0,
2431
+ `Starting text replacement for ${text.length} nodes`,
2432
+ { totalReplacements: text.length }
2433
+ );
2434
+
2435
+ // Define the results array and counters
2436
+ const results = [];
2437
+ let successCount = 0;
2438
+ let failureCount = 0;
2439
+
2440
+ // Split text replacements into chunks of 5
2441
+ const CHUNK_SIZE = 5;
2442
+ const chunks = [];
2443
+
2444
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
2445
+ chunks.push(text.slice(i, i + CHUNK_SIZE));
2446
+ }
2447
+
2448
+ console.log(`Split ${text.length} replacements into ${chunks.length} chunks`);
2449
+
2450
+ // Send chunking info update
2451
+ sendProgressUpdate(
2452
+ commandId,
2453
+ "set_multiple_text_contents",
2454
+ "in_progress",
2455
+ 5, // 5% progress for planning phase
2456
+ text.length,
2457
+ 0,
2458
+ `Preparing to replace text in ${text.length} nodes using ${chunks.length} chunks`,
2459
+ {
2460
+ totalReplacements: text.length,
2461
+ chunks: chunks.length,
2462
+ chunkSize: CHUNK_SIZE,
2463
+ }
2464
+ );
2465
+
2466
+ // Process each chunk sequentially
2467
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
2468
+ const chunk = chunks[chunkIndex];
2469
+ console.log(
2470
+ `Processing chunk ${chunkIndex + 1}/${chunks.length} with ${chunk.length
2471
+ } replacements`
2472
+ );
2473
+
2474
+ // Send chunk processing start update
2475
+ sendProgressUpdate(
2476
+ commandId,
2477
+ "set_multiple_text_contents",
2478
+ "in_progress",
2479
+ Math.round(5 + (chunkIndex / chunks.length) * 90), // 5-95% for processing
2480
+ text.length,
2481
+ successCount + failureCount,
2482
+ `Processing text replacements chunk ${chunkIndex + 1}/${chunks.length}`,
2483
+ {
2484
+ currentChunk: chunkIndex + 1,
2485
+ totalChunks: chunks.length,
2486
+ successCount,
2487
+ failureCount,
2488
+ }
2489
+ );
2490
+
2491
+ // Process replacements within a chunk in parallel
2492
+ const chunkPromises = chunk.map(async (replacement) => {
2493
+ if (!replacement.nodeId || replacement.text === undefined) {
2494
+ console.error(`Missing nodeId or text for replacement`);
2495
+ return {
2496
+ success: false,
2497
+ nodeId: replacement.nodeId || "unknown",
2498
+ error: "Missing nodeId or text in replacement entry",
2499
+ };
2500
+ }
2501
+
2502
+ try {
2503
+ console.log(
2504
+ `Attempting to replace text in node: ${replacement.nodeId}`
2505
+ );
2506
+
2507
+ // Get the text node to update (just to check it exists and get original text)
2508
+ const textNode = await figma.getNodeByIdAsync(replacement.nodeId);
2509
+
2510
+ if (!textNode) {
2511
+ console.error(`Text node not found: ${replacement.nodeId}`);
2512
+ return {
2513
+ success: false,
2514
+ nodeId: replacement.nodeId,
2515
+ error: `Node not found: ${replacement.nodeId}`,
2516
+ };
2517
+ }
2518
+
2519
+ if (textNode.type !== "TEXT") {
2520
+ console.error(
2521
+ `Node is not a text node: ${replacement.nodeId} (type: ${textNode.type})`
2522
+ );
2523
+ return {
2524
+ success: false,
2525
+ nodeId: replacement.nodeId,
2526
+ error: `Node is not a text node: ${replacement.nodeId} (type: ${textNode.type})`,
2527
+ };
2528
+ }
2529
+
2530
+ // Save original text for the result
2531
+ const originalText = textNode.characters;
2532
+ console.log(`Original text: "${originalText}"`);
2533
+ console.log(`Will translate to: "${replacement.text}"`);
2534
+
2535
+ // Highlight the node before changing text
2536
+ let originalFills;
2537
+ try {
2538
+ // Save original fills for restoration later
2539
+ originalFills = JSON.parse(JSON.stringify(textNode.fills));
2540
+ // Apply highlight color (orange with 30% opacity)
2541
+ textNode.fills = [
2542
+ {
2543
+ type: "SOLID",
2544
+ color: { r: 1, g: 0.5, b: 0 },
2545
+ opacity: 0.3,
2546
+ },
2547
+ ];
2548
+ } catch (highlightErr) {
2549
+ console.error(
2550
+ `Error highlighting text node: ${highlightErr.message}`
2551
+ );
2552
+ // Continue anyway, highlighting is just visual feedback
2553
+ }
2554
+
2555
+ // Use the existing setTextContent function to handle font loading and text setting
2556
+ await setTextContent({
2557
+ nodeId: replacement.nodeId,
2558
+ text: replacement.text,
2559
+ });
2560
+
2561
+ // Keep highlight for a moment after text change, then restore original fills
2562
+ if (originalFills) {
2563
+ try {
2564
+ // Use delay function for consistent timing
2565
+ await delay(500);
2566
+ textNode.fills = originalFills;
2567
+ } catch (restoreErr) {
2568
+ console.error(`Error restoring fills: ${restoreErr.message}`);
2569
+ }
2570
+ }
2571
+
2572
+ console.log(
2573
+ `Successfully replaced text in node: ${replacement.nodeId}`
2574
+ );
2575
+ return {
2576
+ success: true,
2577
+ nodeId: replacement.nodeId,
2578
+ originalText: originalText,
2579
+ translatedText: replacement.text,
2580
+ };
2581
+ } catch (error) {
2582
+ console.error(
2583
+ `Error replacing text in node ${replacement.nodeId}: ${error.message}`
2584
+ );
2585
+ return {
2586
+ success: false,
2587
+ nodeId: replacement.nodeId,
2588
+ error: `Error applying replacement: ${error.message}`,
2589
+ };
2590
+ }
2591
+ });
2592
+
2593
+ // Wait for all replacements in this chunk to complete
2594
+ const chunkResults = await Promise.all(chunkPromises);
2595
+
2596
+ // Process results for this chunk
2597
+ chunkResults.forEach((result) => {
2598
+ if (result.success) {
2599
+ successCount++;
2600
+ } else {
2601
+ failureCount++;
2602
+ }
2603
+ results.push(result);
2604
+ });
2605
+
2606
+ // Send chunk processing complete update with partial results
2607
+ sendProgressUpdate(
2608
+ commandId,
2609
+ "set_multiple_text_contents",
2610
+ "in_progress",
2611
+ Math.round(5 + ((chunkIndex + 1) / chunks.length) * 90), // 5-95% for processing
2612
+ text.length,
2613
+ successCount + failureCount,
2614
+ `Completed chunk ${chunkIndex + 1}/${chunks.length
2615
+ }. ${successCount} successful, ${failureCount} failed so far.`,
2616
+ {
2617
+ currentChunk: chunkIndex + 1,
2618
+ totalChunks: chunks.length,
2619
+ successCount,
2620
+ failureCount,
2621
+ chunkResults: chunkResults,
2622
+ }
2623
+ );
2624
+
2625
+ // Add a small delay between chunks to avoid overloading Figma
2626
+ if (chunkIndex < chunks.length - 1) {
2627
+ console.log("Pausing between chunks to avoid overloading Figma...");
2628
+ await delay(1000); // 1 second delay between chunks
2629
+ }
2630
+ }
2631
+
2632
+ console.log(
2633
+ `Replacement complete: ${successCount} successful, ${failureCount} failed`
2634
+ );
2635
+
2636
+ // Send completed progress update
2637
+ sendProgressUpdate(
2638
+ commandId,
2639
+ "set_multiple_text_contents",
2640
+ "completed",
2641
+ 100,
2642
+ text.length,
2643
+ successCount + failureCount,
2644
+ `Text replacement complete: ${successCount} successful, ${failureCount} failed`,
2645
+ {
2646
+ totalReplacements: text.length,
2647
+ replacementsApplied: successCount,
2648
+ replacementsFailed: failureCount,
2649
+ completedInChunks: chunks.length,
2650
+ results: results,
2651
+ }
2652
+ );
2653
+
2654
+ return {
2655
+ success: successCount > 0,
2656
+ nodeId: nodeId,
2657
+ replacementsApplied: successCount,
2658
+ replacementsFailed: failureCount,
2659
+ totalReplacements: text.length,
2660
+ results: results,
2661
+ completedInChunks: chunks.length,
2662
+ commandId,
2663
+ };
2664
+ }
2665
+
2666
+ // Function to generate simple UUIDs for command IDs
2667
+ function generateCommandId() {
2668
+ return (
2669
+ "cmd_" +
2670
+ Math.random().toString(36).substring(2, 15) +
2671
+ Math.random().toString(36).substring(2, 15)
2672
+ );
2673
+ }
2674
+
2675
+ async function getAnnotations(params) {
2676
+ try {
2677
+ const { nodeId, includeCategories = true } = params;
2678
+
2679
+ // Get categories first if needed
2680
+ let categoriesMap = {};
2681
+ if (includeCategories) {
2682
+ const categories = await figma.annotations.getAnnotationCategoriesAsync();
2683
+ categoriesMap = categories.reduce((map, category) => {
2684
+ map[category.id] = {
2685
+ id: category.id,
2686
+ label: category.label,
2687
+ color: category.color,
2688
+ isPreset: category.isPreset,
2689
+ };
2690
+ return map;
2691
+ }, {});
2692
+ }
2693
+
2694
+ if (nodeId) {
2695
+ // Get annotations for a specific node
2696
+ const node = await figma.getNodeByIdAsync(nodeId);
2697
+ if (!node) {
2698
+ throw new Error(`Node not found: ${nodeId}`);
2699
+ }
2700
+
2701
+ if (!("annotations" in node)) {
2702
+ throw new Error(`Node type ${node.type} does not support annotations`);
2703
+ }
2704
+
2705
+ // Collect annotations from this node and all its descendants
2706
+ const mergedAnnotations = [];
2707
+ const collect = async (n) => {
2708
+ if ("annotations" in n && n.annotations && n.annotations.length > 0) {
2709
+ for (const a of n.annotations) {
2710
+ mergedAnnotations.push({ nodeId: n.id, annotation: a });
2711
+ }
2712
+ }
2713
+ if ("children" in n) {
2714
+ for (const child of n.children) {
2715
+ await collect(child);
2716
+ }
2717
+ }
2718
+ };
2719
+ await collect(node);
2720
+
2721
+ const result = {
2722
+ nodeId: node.id,
2723
+ name: node.name,
2724
+ annotations: mergedAnnotations,
2725
+ };
2726
+
2727
+ if (includeCategories) {
2728
+ result.categories = Object.values(categoriesMap);
2729
+ }
2730
+
2731
+ return result;
2732
+ } else {
2733
+ // Get all annotations in the current page
2734
+ const annotations = [];
2735
+ const processNode = async (node) => {
2736
+ if (
2737
+ "annotations" in node &&
2738
+ node.annotations &&
2739
+ node.annotations.length > 0
2740
+ ) {
2741
+ annotations.push({
2742
+ nodeId: node.id,
2743
+ name: node.name,
2744
+ annotations: node.annotations,
2745
+ });
2746
+ }
2747
+ if ("children" in node) {
2748
+ for (const child of node.children) {
2749
+ await processNode(child);
2750
+ }
2751
+ }
2752
+ };
2753
+
2754
+ // Start from current page
2755
+ await processNode(figma.currentPage);
2756
+
2757
+ const result = {
2758
+ annotatedNodes: annotations,
2759
+ };
2760
+
2761
+ if (includeCategories) {
2762
+ result.categories = Object.values(categoriesMap);
2763
+ }
2764
+
2765
+ return result;
2766
+ }
2767
+ } catch (error) {
2768
+ console.error("Error in getAnnotations:", error);
2769
+ throw error;
2770
+ }
2771
+ }
2772
+
2773
+ async function setAnnotation(params) {
2774
+ try {
2775
+ console.log("=== setAnnotation Debug Start ===");
2776
+ console.log("Input params:", JSON.stringify(params, null, 2));
2777
+
2778
+ const { nodeId, annotationId, labelMarkdown, categoryId, properties } =
2779
+ params;
2780
+
2781
+ // Validate required parameters
2782
+ if (!nodeId) {
2783
+ console.error("Validation failed: Missing nodeId");
2784
+ return { success: false, error: "Missing nodeId" };
2785
+ }
2786
+
2787
+ if (!labelMarkdown) {
2788
+ console.error("Validation failed: Missing labelMarkdown");
2789
+ return { success: false, error: "Missing labelMarkdown" };
2790
+ }
2791
+
2792
+ console.log("Attempting to get node:", nodeId);
2793
+ // Get and validate node
2794
+ const node = await figma.getNodeByIdAsync(nodeId);
2795
+ console.log("Node lookup result:", {
2796
+ id: nodeId,
2797
+ found: !!node,
2798
+ type: node ? node.type : undefined,
2799
+ name: node ? node.name : undefined,
2800
+ hasAnnotations: node ? "annotations" in node : false,
2801
+ });
2802
+
2803
+ if (!node) {
2804
+ console.error("Node lookup failed:", nodeId);
2805
+ return { success: false, error: `Node not found: ${nodeId}` };
2806
+ }
2807
+
2808
+ // Validate node supports annotations
2809
+ if (!("annotations" in node)) {
2810
+ console.error("Node annotation support check failed:", {
2811
+ nodeType: node.type,
2812
+ nodeId: node.id,
2813
+ });
2814
+ return {
2815
+ success: false,
2816
+ error: `Node type ${node.type} does not support annotations`,
2817
+ };
2818
+ }
2819
+
2820
+ // Create the annotation object
2821
+ const newAnnotation = {
2822
+ labelMarkdown,
2823
+ };
2824
+
2825
+ // Validate and add categoryId if provided
2826
+ if (categoryId) {
2827
+ console.log("Adding categoryId to annotation:", categoryId);
2828
+ newAnnotation.categoryId = categoryId;
2829
+ }
2830
+
2831
+ // Validate and add properties if provided
2832
+ if (properties && Array.isArray(properties) && properties.length > 0) {
2833
+ console.log(
2834
+ "Adding properties to annotation:",
2835
+ JSON.stringify(properties, null, 2)
2836
+ );
2837
+ newAnnotation.properties = properties;
2838
+ }
2839
+
2840
+ // Log current annotations before update
2841
+ console.log("Current node annotations:", node.annotations);
2842
+
2843
+ // Overwrite annotations
2844
+ console.log(
2845
+ "Setting new annotation:",
2846
+ JSON.stringify(newAnnotation, null, 2)
2847
+ );
2848
+ node.annotations = [newAnnotation];
2849
+
2850
+ // Verify the update
2851
+ console.log("Updated node annotations:", node.annotations);
2852
+ console.log("=== setAnnotation Debug End ===");
2853
+
2854
+ return {
2855
+ success: true,
2856
+ nodeId: node.id,
2857
+ name: node.name,
2858
+ annotations: node.annotations,
2859
+ };
2860
+ } catch (error) {
2861
+ console.error("=== setAnnotation Error ===");
2862
+ console.error("Error details:", {
2863
+ message: error.message,
2864
+ stack: error.stack,
2865
+ params: JSON.stringify(params, null, 2),
2866
+ });
2867
+ return { success: false, error: error.message };
2868
+ }
2869
+ }
2870
+
2871
+ /**
2872
+ * Scan for nodes with specific types within a node
2873
+ * @param {Object} params - Parameters object
2874
+ * @param {string} params.nodeId - ID of the node to scan within
2875
+ * @param {Array<string>} params.types - Array of node types to find (e.g. ['COMPONENT', 'FRAME'])
2876
+ * @returns {Object} - Object containing found nodes
2877
+ */
2878
+ async function scanNodesByTypes(params) {
2879
+ console.log(`Starting to scan nodes by types from node ID: ${params.nodeId}`);
2880
+ const { nodeId, types = [] } = params || {};
2881
+
2882
+ if (!types || types.length === 0) {
2883
+ throw new Error("No types specified to search for");
2884
+ }
2885
+
2886
+ const node = await figma.getNodeByIdAsync(nodeId);
2887
+
2888
+ if (!node) {
2889
+ throw new Error(`Node with ID ${nodeId} not found`);
2890
+ }
2891
+
2892
+ // Simple implementation without chunking
2893
+ const matchingNodes = [];
2894
+
2895
+ // Send a single progress update to notify start
2896
+ const commandId = generateCommandId();
2897
+ sendProgressUpdate(
2898
+ commandId,
2899
+ "scan_nodes_by_types",
2900
+ "started",
2901
+ 0,
2902
+ 1,
2903
+ 0,
2904
+ `Starting scan of node "${node.name || nodeId}" for types: ${types.join(
2905
+ ", "
2906
+ )}`,
2907
+ null
2908
+ );
2909
+
2910
+ // Recursively find nodes with specified types
2911
+ await findNodesByTypes(node, types, matchingNodes);
2912
+
2913
+ // Send completion update
2914
+ sendProgressUpdate(
2915
+ commandId,
2916
+ "scan_nodes_by_types",
2917
+ "completed",
2918
+ 100,
2919
+ matchingNodes.length,
2920
+ matchingNodes.length,
2921
+ `Scan complete. Found ${matchingNodes.length} matching nodes.`,
2922
+ { matchingNodes }
2923
+ );
2924
+
2925
+ return {
2926
+ success: true,
2927
+ message: `Found ${matchingNodes.length} matching nodes.`,
2928
+ count: matchingNodes.length,
2929
+ matchingNodes: matchingNodes,
2930
+ searchedTypes: types,
2931
+ };
2932
+ }
2933
+
2934
+ /**
2935
+ * Helper function to recursively find nodes with specific types
2936
+ * @param {SceneNode} node - The root node to start searching from
2937
+ * @param {Array<string>} types - Array of node types to find
2938
+ * @param {Array} matchingNodes - Array to store found nodes
2939
+ */
2940
+ async function findNodesByTypes(node, types, matchingNodes = []) {
2941
+ // Skip invisible nodes
2942
+ if (node.visible === false) return;
2943
+
2944
+ // Check if this node is one of the specified types
2945
+ if (types.includes(node.type)) {
2946
+ // Create a minimal representation with just ID, type and bbox
2947
+ matchingNodes.push({
2948
+ id: node.id,
2949
+ name: node.name || `Unnamed ${node.type}`,
2950
+ type: node.type,
2951
+ // Basic bounding box info
2952
+ bbox: {
2953
+ x: typeof node.x === "number" ? node.x : 0,
2954
+ y: typeof node.y === "number" ? node.y : 0,
2955
+ width: typeof node.width === "number" ? node.width : 0,
2956
+ height: typeof node.height === "number" ? node.height : 0,
2957
+ },
2958
+ });
2959
+ }
2960
+
2961
+ // Recursively process children of container nodes
2962
+ if ("children" in node) {
2963
+ for (const child of node.children) {
2964
+ await findNodesByTypes(child, types, matchingNodes);
2965
+ }
2966
+ }
2967
+ }
2968
+
2969
+ // Set multiple annotations with async progress updates
2970
+ async function setMultipleAnnotations(params) {
2971
+ console.log("=== setMultipleAnnotations Debug Start ===");
2972
+ console.log("Input params:", JSON.stringify(params, null, 2));
2973
+
2974
+ const { nodeId, annotations } = params;
2975
+
2976
+ if (!annotations || annotations.length === 0) {
2977
+ console.error("Validation failed: No annotations provided");
2978
+ return { success: false, error: "No annotations provided" };
2979
+ }
2980
+
2981
+ console.log(
2982
+ `Processing ${annotations.length} annotations for node ${nodeId}`
2983
+ );
2984
+
2985
+ const results = [];
2986
+ let successCount = 0;
2987
+ let failureCount = 0;
2988
+
2989
+ // Process annotations sequentially
2990
+ for (let i = 0; i < annotations.length; i++) {
2991
+ const annotation = annotations[i];
2992
+ console.log(
2993
+ `\nProcessing annotation ${i + 1}/${annotations.length}:`,
2994
+ JSON.stringify(annotation, null, 2)
2995
+ );
2996
+
2997
+ try {
2998
+ console.log("Calling setAnnotation with params:", {
2999
+ nodeId: annotation.nodeId,
3000
+ labelMarkdown: annotation.labelMarkdown,
3001
+ categoryId: annotation.categoryId,
3002
+ properties: annotation.properties,
3003
+ });
3004
+
3005
+ const result = await setAnnotation({
3006
+ nodeId: annotation.nodeId,
3007
+ labelMarkdown: annotation.labelMarkdown,
3008
+ categoryId: annotation.categoryId,
3009
+ properties: annotation.properties,
3010
+ });
3011
+
3012
+ console.log("setAnnotation result:", JSON.stringify(result, null, 2));
3013
+
3014
+ if (result.success) {
3015
+ successCount++;
3016
+ results.push({ success: true, nodeId: annotation.nodeId });
3017
+ console.log(`✓ Annotation ${i + 1} applied successfully`);
3018
+ } else {
3019
+ failureCount++;
3020
+ results.push({
3021
+ success: false,
3022
+ nodeId: annotation.nodeId,
3023
+ error: result.error,
3024
+ });
3025
+ console.error(`✗ Annotation ${i + 1} failed:`, result.error);
3026
+ }
3027
+ } catch (error) {
3028
+ failureCount++;
3029
+ const errorResult = {
3030
+ success: false,
3031
+ nodeId: annotation.nodeId,
3032
+ error: error.message,
3033
+ };
3034
+ results.push(errorResult);
3035
+ console.error(`✗ Annotation ${i + 1} failed with error:`, error);
3036
+ console.error("Error details:", {
3037
+ message: error.message,
3038
+ stack: error.stack,
3039
+ });
3040
+ }
3041
+ }
3042
+
3043
+ const summary = {
3044
+ success: successCount > 0,
3045
+ annotationsApplied: successCount,
3046
+ annotationsFailed: failureCount,
3047
+ totalAnnotations: annotations.length,
3048
+ results: results,
3049
+ };
3050
+
3051
+ console.log("\n=== setMultipleAnnotations Summary ===");
3052
+ console.log(JSON.stringify(summary, null, 2));
3053
+ console.log("=== setMultipleAnnotations Debug End ===");
3054
+
3055
+ return summary;
3056
+ }
3057
+
3058
+ async function deleteMultipleNodes(params) {
3059
+ const { nodeIds } = params || {};
3060
+ const commandId = generateCommandId();
3061
+
3062
+ if (!nodeIds || !Array.isArray(nodeIds) || nodeIds.length === 0) {
3063
+ const errorMsg = "Missing or invalid nodeIds parameter";
3064
+ sendProgressUpdate(
3065
+ commandId,
3066
+ "delete_multiple_nodes",
3067
+ "error",
3068
+ 0,
3069
+ 0,
3070
+ 0,
3071
+ errorMsg,
3072
+ { error: errorMsg }
3073
+ );
3074
+ throw new Error(errorMsg);
3075
+ }
3076
+
3077
+ console.log(`Starting deletion of ${nodeIds.length} nodes`);
3078
+
3079
+ // Send started progress update
3080
+ sendProgressUpdate(
3081
+ commandId,
3082
+ "delete_multiple_nodes",
3083
+ "started",
3084
+ 0,
3085
+ nodeIds.length,
3086
+ 0,
3087
+ `Starting deletion of ${nodeIds.length} nodes`,
3088
+ { totalNodes: nodeIds.length }
3089
+ );
3090
+
3091
+ const results = [];
3092
+ let successCount = 0;
3093
+ let failureCount = 0;
3094
+
3095
+ // Process nodes in chunks of 5 to avoid overwhelming Figma
3096
+ const CHUNK_SIZE = 5;
3097
+ const chunks = [];
3098
+
3099
+ for (let i = 0; i < nodeIds.length; i += CHUNK_SIZE) {
3100
+ chunks.push(nodeIds.slice(i, i + CHUNK_SIZE));
3101
+ }
3102
+
3103
+ console.log(`Split ${nodeIds.length} deletions into ${chunks.length} chunks`);
3104
+
3105
+ // Send chunking info update
3106
+ sendProgressUpdate(
3107
+ commandId,
3108
+ "delete_multiple_nodes",
3109
+ "in_progress",
3110
+ 5,
3111
+ nodeIds.length,
3112
+ 0,
3113
+ `Preparing to delete ${nodeIds.length} nodes using ${chunks.length} chunks`,
3114
+ {
3115
+ totalNodes: nodeIds.length,
3116
+ chunks: chunks.length,
3117
+ chunkSize: CHUNK_SIZE,
3118
+ }
3119
+ );
3120
+
3121
+ // Process each chunk sequentially
3122
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
3123
+ const chunk = chunks[chunkIndex];
3124
+ console.log(
3125
+ `Processing chunk ${chunkIndex + 1}/${chunks.length} with ${chunk.length
3126
+ } nodes`
3127
+ );
3128
+
3129
+ // Send chunk processing start update
3130
+ sendProgressUpdate(
3131
+ commandId,
3132
+ "delete_multiple_nodes",
3133
+ "in_progress",
3134
+ Math.round(5 + (chunkIndex / chunks.length) * 90),
3135
+ nodeIds.length,
3136
+ successCount + failureCount,
3137
+ `Processing deletion chunk ${chunkIndex + 1}/${chunks.length}`,
3138
+ {
3139
+ currentChunk: chunkIndex + 1,
3140
+ totalChunks: chunks.length,
3141
+ successCount,
3142
+ failureCount,
3143
+ }
3144
+ );
3145
+
3146
+ // Process deletions within a chunk in parallel
3147
+ const chunkPromises = chunk.map(async (nodeId) => {
3148
+ try {
3149
+ const node = await figma.getNodeByIdAsync(nodeId);
3150
+
3151
+ if (!node) {
3152
+ console.error(`Node not found: ${nodeId}`);
3153
+ return {
3154
+ success: false,
3155
+ nodeId: nodeId,
3156
+ error: `Node not found: ${nodeId}`,
3157
+ };
3158
+ }
3159
+
3160
+ // Save node info before deleting
3161
+ const nodeInfo = {
3162
+ id: node.id,
3163
+ name: node.name,
3164
+ type: node.type,
3165
+ };
3166
+
3167
+ // Delete the node
3168
+ node.remove();
3169
+
3170
+ console.log(`Successfully deleted node: ${nodeId}`);
3171
+ return {
3172
+ success: true,
3173
+ nodeId: nodeId,
3174
+ nodeInfo: nodeInfo,
3175
+ };
3176
+ } catch (error) {
3177
+ console.error(`Error deleting node ${nodeId}: ${error.message}`);
3178
+ return {
3179
+ success: false,
3180
+ nodeId: nodeId,
3181
+ error: error.message,
3182
+ };
3183
+ }
3184
+ });
3185
+
3186
+ // Wait for all deletions in this chunk to complete
3187
+ const chunkResults = await Promise.all(chunkPromises);
3188
+
3189
+ // Process results for this chunk
3190
+ chunkResults.forEach((result) => {
3191
+ if (result.success) {
3192
+ successCount++;
3193
+ } else {
3194
+ failureCount++;
3195
+ }
3196
+ results.push(result);
3197
+ });
3198
+
3199
+ // Send chunk processing complete update
3200
+ sendProgressUpdate(
3201
+ commandId,
3202
+ "delete_multiple_nodes",
3203
+ "in_progress",
3204
+ Math.round(5 + ((chunkIndex + 1) / chunks.length) * 90),
3205
+ nodeIds.length,
3206
+ successCount + failureCount,
3207
+ `Completed chunk ${chunkIndex + 1}/${chunks.length
3208
+ }. ${successCount} successful, ${failureCount} failed so far.`,
3209
+ {
3210
+ currentChunk: chunkIndex + 1,
3211
+ totalChunks: chunks.length,
3212
+ successCount,
3213
+ failureCount,
3214
+ chunkResults: chunkResults,
3215
+ }
3216
+ );
3217
+
3218
+ // Add a small delay between chunks
3219
+ if (chunkIndex < chunks.length - 1) {
3220
+ console.log("Pausing between chunks...");
3221
+ await delay(1000);
3222
+ }
3223
+ }
3224
+
3225
+ console.log(
3226
+ `Deletion complete: ${successCount} successful, ${failureCount} failed`
3227
+ );
3228
+
3229
+ // Send completed progress update
3230
+ sendProgressUpdate(
3231
+ commandId,
3232
+ "delete_multiple_nodes",
3233
+ "completed",
3234
+ 100,
3235
+ nodeIds.length,
3236
+ successCount + failureCount,
3237
+ `Node deletion complete: ${successCount} successful, ${failureCount} failed`,
3238
+ {
3239
+ totalNodes: nodeIds.length,
3240
+ nodesDeleted: successCount,
3241
+ nodesFailed: failureCount,
3242
+ completedInChunks: chunks.length,
3243
+ results: results,
3244
+ }
3245
+ );
3246
+
3247
+ return {
3248
+ success: successCount > 0,
3249
+ nodesDeleted: successCount,
3250
+ nodesFailed: failureCount,
3251
+ totalNodes: nodeIds.length,
3252
+ results: results,
3253
+ completedInChunks: chunks.length,
3254
+ commandId,
3255
+ };
3256
+ }
3257
+
3258
+ // Implementation for getInstanceOverrides function
3259
+ async function getInstanceOverrides(instanceNode = null) {
3260
+ console.log("=== getInstanceOverrides called ===");
3261
+
3262
+ let sourceInstance = null;
3263
+
3264
+ // Check if an instance node was passed directly
3265
+ if (instanceNode) {
3266
+ console.log("Using provided instance node");
3267
+
3268
+ // Validate that the provided node is an instance
3269
+ if (instanceNode.type !== "INSTANCE") {
3270
+ console.error("Provided node is not an instance");
3271
+ figma.notify("Provided node is not a component instance");
3272
+ return { success: false, message: "Provided node is not a component instance" };
3273
+ }
3274
+
3275
+ sourceInstance = instanceNode;
3276
+ } else {
3277
+ // No node provided, use selection
3278
+ console.log("No node provided, using current selection");
3279
+
3280
+ // Get the current selection
3281
+ const selection = figma.currentPage.selection;
3282
+
3283
+ // Check if there's anything selected
3284
+ if (selection.length === 0) {
3285
+ console.log("No nodes selected");
3286
+ figma.notify("Please select at least one instance");
3287
+ return { success: false, message: "No nodes selected" };
3288
+ }
3289
+
3290
+ // Filter for instances in the selection
3291
+ const instances = selection.filter(node => node.type === "INSTANCE");
3292
+
3293
+ if (instances.length === 0) {
3294
+ console.log("No instances found in selection");
3295
+ figma.notify("Please select at least one component instance");
3296
+ return { success: false, message: "No instances found in selection" };
3297
+ }
3298
+
3299
+ // Take the first instance from the selection
3300
+ sourceInstance = instances[0];
3301
+ }
3302
+
3303
+ try {
3304
+ console.log(`Getting instance information:`);
3305
+ console.log(sourceInstance);
3306
+
3307
+ // Get component overrides and main component
3308
+ const overrides = sourceInstance.overrides || [];
3309
+ console.log(` Raw Overrides:`, overrides);
3310
+
3311
+ // Get main component
3312
+ const mainComponent = await sourceInstance.getMainComponentAsync();
3313
+ if (!mainComponent) {
3314
+ console.error("Failed to get main component");
3315
+ figma.notify("Failed to get main component");
3316
+ return { success: false, message: "Failed to get main component" };
3317
+ }
3318
+
3319
+ // return data to MCP server
3320
+ const returnData = {
3321
+ success: true,
3322
+ message: `Got component information from "${sourceInstance.name}" for overrides.length: ${overrides.length}`,
3323
+ sourceInstanceId: sourceInstance.id,
3324
+ mainComponentId: mainComponent.id,
3325
+ overridesCount: overrides.length
3326
+ };
3327
+
3328
+ console.log("Data to return to MCP server:", returnData);
3329
+ figma.notify(`Got component information from "${sourceInstance.name}"`);
3330
+
3331
+ return returnData;
3332
+ } catch (error) {
3333
+ console.error("Error in getInstanceOverrides:", error);
3334
+ figma.notify(`Error: ${error.message}`);
3335
+ return {
3336
+ success: false,
3337
+ message: `Error: ${error.message}`
3338
+ };
3339
+ }
3340
+ }
3341
+
3342
+ /**
3343
+ * Helper function to validate and get target instances
3344
+ * @param {string[]} targetNodeIds - Array of instance node IDs
3345
+ * @returns {instanceNode[]} targetInstances - Array of target instances
3346
+ */
3347
+ async function getValidTargetInstances(targetNodeIds) {
3348
+ let targetInstances = [];
3349
+
3350
+ // Handle array of instances or single instance
3351
+ if (Array.isArray(targetNodeIds)) {
3352
+ if (targetNodeIds.length === 0) {
3353
+ return { success: false, message: "No instances provided" };
3354
+ }
3355
+ for (const targetNodeId of targetNodeIds) {
3356
+ const targetNode = await figma.getNodeByIdAsync(targetNodeId);
3357
+ if (targetNode && targetNode.type === "INSTANCE") {
3358
+ targetInstances.push(targetNode);
3359
+ }
3360
+ }
3361
+ if (targetInstances.length === 0) {
3362
+ return { success: false, message: "No valid instances provided" };
3363
+ }
3364
+ } else {
3365
+ return { success: false, message: "Invalid target node IDs provided" };
3366
+ }
3367
+
3368
+
3369
+ return { success: true, message: "Valid target instances provided", targetInstances };
3370
+ }
3371
+
3372
+ /**
3373
+ * Helper function to validate and get saved override data
3374
+ * @param {string} sourceInstanceId - Source instance ID
3375
+ * @returns {Promise<Object>} - Validation result with source instance data or error
3376
+ */
3377
+ async function getSourceInstanceData(sourceInstanceId) {
3378
+ if (!sourceInstanceId) {
3379
+ return { success: false, message: "Missing source instance ID" };
3380
+ }
3381
+
3382
+ // Get source instance by ID
3383
+ const sourceInstance = await figma.getNodeByIdAsync(sourceInstanceId);
3384
+ if (!sourceInstance) {
3385
+ return {
3386
+ success: false,
3387
+ message: "Source instance not found. The original instance may have been deleted."
3388
+ };
3389
+ }
3390
+
3391
+ // Verify it's an instance
3392
+ if (sourceInstance.type !== "INSTANCE") {
3393
+ return {
3394
+ success: false,
3395
+ message: "Source node is not a component instance."
3396
+ };
3397
+ }
3398
+
3399
+ // Get main component
3400
+ const mainComponent = await sourceInstance.getMainComponentAsync();
3401
+ if (!mainComponent) {
3402
+ return {
3403
+ success: false,
3404
+ message: "Failed to get main component from source instance."
3405
+ };
3406
+ }
3407
+
3408
+ return {
3409
+ success: true,
3410
+ sourceInstance,
3411
+ mainComponent,
3412
+ overrides: sourceInstance.overrides || []
3413
+ };
3414
+ }
3415
+
3416
+ /**
3417
+ * Sets saved overrides to the selected component instance(s)
3418
+ * @param {InstanceNode[] | null} targetInstances - Array of instance nodes to set overrides to
3419
+ * @param {Object} sourceResult - Source instance data from getSourceInstanceData
3420
+ * @returns {Promise<Object>} - Result of the set operation
3421
+ */
3422
+ async function setInstanceOverrides(targetInstances, sourceResult) {
3423
+ try {
3424
+
3425
+
3426
+ const { sourceInstance, mainComponent, overrides } = sourceResult;
3427
+
3428
+ console.log(`Processing ${targetInstances.length} instances with ${overrides.length} overrides`);
3429
+ console.log(`Source instance: ${sourceInstance.id}, Main component: ${mainComponent.id}`);
3430
+ console.log(`Overrides:`, overrides);
3431
+
3432
+ // Process all instances
3433
+ const results = [];
3434
+ let totalAppliedCount = 0;
3435
+
3436
+ for (const targetInstance of targetInstances) {
3437
+ try {
3438
+ // // Skip if trying to apply to the source instance itself
3439
+ // if (targetInstance.id === sourceInstance.id) {
3440
+ // console.log(`Skipping source instance itself: ${targetInstance.id}`);
3441
+ // results.push({
3442
+ // success: false,
3443
+ // instanceId: targetInstance.id,
3444
+ // instanceName: targetInstance.name,
3445
+ // message: "This is the source instance itself, skipping"
3446
+ // });
3447
+ // continue;
3448
+ // }
3449
+
3450
+ // Swap component
3451
+ try {
3452
+ targetInstance.swapComponent(mainComponent);
3453
+ console.log(`Swapped component for instance "${targetInstance.name}"`);
3454
+ } catch (error) {
3455
+ console.error(`Error swapping component for instance "${targetInstance.name}":`, error);
3456
+ results.push({
3457
+ success: false,
3458
+ instanceId: targetInstance.id,
3459
+ instanceName: targetInstance.name,
3460
+ message: `Error: ${error.message}`
3461
+ });
3462
+ }
3463
+
3464
+ // Prepare overrides by replacing node IDs
3465
+ let appliedCount = 0;
3466
+
3467
+ // Apply each override
3468
+ for (const override of overrides) {
3469
+ // Skip if no ID or overriddenFields
3470
+ if (!override.id || !override.overriddenFields || override.overriddenFields.length === 0) {
3471
+ continue;
3472
+ }
3473
+
3474
+ // Replace source instance ID with target instance ID in the node path
3475
+ const overrideNodeId = override.id.replace(sourceInstance.id, targetInstance.id);
3476
+ const overrideNode = await figma.getNodeByIdAsync(overrideNodeId);
3477
+
3478
+ if (!overrideNode) {
3479
+ console.log(`Override node not found: ${overrideNodeId}`);
3480
+ continue;
3481
+ }
3482
+
3483
+ // Get source node to copy properties from
3484
+ const sourceNode = await figma.getNodeByIdAsync(override.id);
3485
+ if (!sourceNode) {
3486
+ console.log(`Source node not found: ${override.id}`);
3487
+ continue;
3488
+ }
3489
+
3490
+ // Apply each overridden field
3491
+ let fieldApplied = false;
3492
+ for (const field of override.overriddenFields) {
3493
+ try {
3494
+ if (field === "componentProperties") {
3495
+ // Apply component properties
3496
+ if (sourceNode.componentProperties && overrideNode.componentProperties) {
3497
+ const properties = {};
3498
+ for (const key in sourceNode.componentProperties) {
3499
+ // if INSTANCE_SWAP use id, otherwise use value
3500
+ if (sourceNode.componentProperties[key].type === 'INSTANCE_SWAP') {
3501
+ properties[key] = sourceNode.componentProperties[key].value;
3502
+
3503
+ } else {
3504
+ properties[key] = sourceNode.componentProperties[key].value;
3505
+ }
3506
+ }
3507
+ overrideNode.setProperties(properties);
3508
+ fieldApplied = true;
3509
+ }
3510
+ } else if (field === "characters" && overrideNode.type === "TEXT") {
3511
+ // For text nodes, need to load fonts first
3512
+ await figma.loadFontAsync(overrideNode.fontName);
3513
+ overrideNode.characters = sourceNode.characters;
3514
+ fieldApplied = true;
3515
+ } else if (field in overrideNode) {
3516
+ // Direct property assignment
3517
+ overrideNode[field] = sourceNode[field];
3518
+ fieldApplied = true;
3519
+ }
3520
+ } catch (fieldError) {
3521
+ console.error(`Error applying field ${field}:`, fieldError);
3522
+ }
3523
+ }
3524
+
3525
+ if (fieldApplied) {
3526
+ appliedCount++;
3527
+ }
3528
+ }
3529
+
3530
+ if (appliedCount > 0) {
3531
+ totalAppliedCount += appliedCount;
3532
+ results.push({
3533
+ success: true,
3534
+ instanceId: targetInstance.id,
3535
+ instanceName: targetInstance.name,
3536
+ appliedCount
3537
+ });
3538
+ console.log(`Applied ${appliedCount} overrides to "${targetInstance.name}"`);
3539
+ } else {
3540
+ results.push({
3541
+ success: false,
3542
+ instanceId: targetInstance.id,
3543
+ instanceName: targetInstance.name,
3544
+ message: "No overrides were applied"
3545
+ });
3546
+ }
3547
+ } catch (instanceError) {
3548
+ console.error(`Error processing instance "${targetInstance.name}":`, instanceError);
3549
+ results.push({
3550
+ success: false,
3551
+ instanceId: targetInstance.id,
3552
+ instanceName: targetInstance.name,
3553
+ message: `Error: ${instanceError.message}`
3554
+ });
3555
+ }
3556
+ }
3557
+
3558
+ // Return results
3559
+ if (totalAppliedCount > 0) {
3560
+ const instanceCount = results.filter(r => r.success).length;
3561
+ const message = `Applied ${totalAppliedCount} overrides to ${instanceCount} instances`;
3562
+ figma.notify(message);
3563
+ return {
3564
+ success: true,
3565
+ message,
3566
+ totalCount: totalAppliedCount,
3567
+ results
3568
+ };
3569
+ } else {
3570
+ const message = "No overrides applied to any instance";
3571
+ figma.notify(message);
3572
+ return { success: false, message, results };
3573
+ }
3574
+
3575
+ } catch (error) {
3576
+ console.error("Error in setInstanceOverrides:", error);
3577
+ const message = `Error: ${error.message}`;
3578
+ figma.notify(message);
3579
+ return { success: false, message };
3580
+ }
3581
+ }
3582
+
3583
+ async function setLayoutMode(params) {
3584
+ const { nodeId, layoutMode = "NONE", layoutWrap = "NO_WRAP" } = params || {};
3585
+
3586
+ // Get the target node
3587
+ const node = await figma.getNodeByIdAsync(nodeId);
3588
+ if (!node) {
3589
+ throw new Error(`Node with ID ${nodeId} not found`);
3590
+ }
3591
+
3592
+ // Check if node is a frame or component that supports layoutMode
3593
+ if (
3594
+ node.type !== "FRAME" &&
3595
+ node.type !== "COMPONENT" &&
3596
+ node.type !== "COMPONENT_SET" &&
3597
+ node.type !== "INSTANCE"
3598
+ ) {
3599
+ throw new Error(`Node type ${node.type} does not support layoutMode`);
3600
+ }
3601
+
3602
+ // Set layout mode
3603
+ node.layoutMode = layoutMode;
3604
+
3605
+ // Set layoutWrap if applicable
3606
+ if (layoutMode !== "NONE") {
3607
+ node.layoutWrap = layoutWrap;
3608
+ }
3609
+
3610
+ return {
3611
+ id: node.id,
3612
+ name: node.name,
3613
+ layoutMode: node.layoutMode,
3614
+ layoutWrap: node.layoutWrap,
3615
+ };
3616
+ }
3617
+
3618
+ async function setPadding(params) {
3619
+ const { nodeId, paddingTop, paddingRight, paddingBottom, paddingLeft } =
3620
+ params || {};
3621
+
3622
+ // Get the target node
3623
+ const node = await figma.getNodeByIdAsync(nodeId);
3624
+ if (!node) {
3625
+ throw new Error(`Node with ID ${nodeId} not found`);
3626
+ }
3627
+
3628
+ // Check if node is a frame or component that supports padding
3629
+ if (
3630
+ node.type !== "FRAME" &&
3631
+ node.type !== "COMPONENT" &&
3632
+ node.type !== "COMPONENT_SET" &&
3633
+ node.type !== "INSTANCE"
3634
+ ) {
3635
+ throw new Error(`Node type ${node.type} does not support padding`);
3636
+ }
3637
+
3638
+ // Check if the node has auto-layout enabled
3639
+ if (node.layoutMode === "NONE") {
3640
+ throw new Error(
3641
+ "Padding can only be set on auto-layout frames (layoutMode must not be NONE)"
3642
+ );
3643
+ }
3644
+
3645
+ // Set padding values if provided
3646
+ if (paddingTop !== undefined) node.paddingTop = paddingTop;
3647
+ if (paddingRight !== undefined) node.paddingRight = paddingRight;
3648
+ if (paddingBottom !== undefined) node.paddingBottom = paddingBottom;
3649
+ if (paddingLeft !== undefined) node.paddingLeft = paddingLeft;
3650
+
3651
+ return {
3652
+ id: node.id,
3653
+ name: node.name,
3654
+ paddingTop: node.paddingTop,
3655
+ paddingRight: node.paddingRight,
3656
+ paddingBottom: node.paddingBottom,
3657
+ paddingLeft: node.paddingLeft,
3658
+ };
3659
+ }
3660
+
3661
+ async function setAxisAlign(params) {
3662
+ const { nodeId, primaryAxisAlignItems, counterAxisAlignItems } = params || {};
3663
+
3664
+ // Get the target node
3665
+ const node = await figma.getNodeByIdAsync(nodeId);
3666
+ if (!node) {
3667
+ throw new Error(`Node with ID ${nodeId} not found`);
3668
+ }
3669
+
3670
+ // Check if node is a frame or component that supports axis alignment
3671
+ if (
3672
+ node.type !== "FRAME" &&
3673
+ node.type !== "COMPONENT" &&
3674
+ node.type !== "COMPONENT_SET" &&
3675
+ node.type !== "INSTANCE"
3676
+ ) {
3677
+ throw new Error(`Node type ${node.type} does not support axis alignment`);
3678
+ }
3679
+
3680
+ // Check if the node has auto-layout enabled
3681
+ if (node.layoutMode === "NONE") {
3682
+ throw new Error(
3683
+ "Axis alignment can only be set on auto-layout frames (layoutMode must not be NONE)"
3684
+ );
3685
+ }
3686
+
3687
+ // Validate and set primaryAxisAlignItems if provided
3688
+ if (primaryAxisAlignItems !== undefined) {
3689
+ if (
3690
+ !["MIN", "MAX", "CENTER", "SPACE_BETWEEN"].includes(primaryAxisAlignItems)
3691
+ ) {
3692
+ throw new Error(
3693
+ "Invalid primaryAxisAlignItems value. Must be one of: MIN, MAX, CENTER, SPACE_BETWEEN"
3694
+ );
3695
+ }
3696
+ node.primaryAxisAlignItems = primaryAxisAlignItems;
3697
+ }
3698
+
3699
+ // Validate and set counterAxisAlignItems if provided
3700
+ if (counterAxisAlignItems !== undefined) {
3701
+ if (!["MIN", "MAX", "CENTER", "BASELINE"].includes(counterAxisAlignItems)) {
3702
+ throw new Error(
3703
+ "Invalid counterAxisAlignItems value. Must be one of: MIN, MAX, CENTER, BASELINE"
3704
+ );
3705
+ }
3706
+ // BASELINE is only valid for horizontal layout
3707
+ if (
3708
+ counterAxisAlignItems === "BASELINE" &&
3709
+ node.layoutMode !== "HORIZONTAL"
3710
+ ) {
3711
+ throw new Error(
3712
+ "BASELINE alignment is only valid for horizontal auto-layout frames"
3713
+ );
3714
+ }
3715
+ node.counterAxisAlignItems = counterAxisAlignItems;
3716
+ }
3717
+
3718
+ return {
3719
+ id: node.id,
3720
+ name: node.name,
3721
+ primaryAxisAlignItems: node.primaryAxisAlignItems,
3722
+ counterAxisAlignItems: node.counterAxisAlignItems,
3723
+ layoutMode: node.layoutMode,
3724
+ };
3725
+ }
3726
+
3727
+ async function setLayoutSizing(params) {
3728
+ const { nodeId, layoutSizingHorizontal, layoutSizingVertical } = params || {};
3729
+
3730
+ // Get the target node
3731
+ const node = await figma.getNodeByIdAsync(nodeId);
3732
+ if (!node) {
3733
+ throw new Error(`Node with ID ${nodeId} not found`);
3734
+ }
3735
+
3736
+ // Check if node is a frame or component that supports layout sizing
3737
+ if (
3738
+ node.type !== "FRAME" &&
3739
+ node.type !== "COMPONENT" &&
3740
+ node.type !== "COMPONENT_SET" &&
3741
+ node.type !== "INSTANCE"
3742
+ ) {
3743
+ throw new Error(`Node type ${node.type} does not support layout sizing`);
3744
+ }
3745
+
3746
+ // Check if the node has auto-layout enabled
3747
+ if (node.layoutMode === "NONE") {
3748
+ throw new Error(
3749
+ "Layout sizing can only be set on auto-layout frames (layoutMode must not be NONE)"
3750
+ );
3751
+ }
3752
+
3753
+ // Validate and set layoutSizingHorizontal if provided
3754
+ if (layoutSizingHorizontal !== undefined) {
3755
+ if (!["FIXED", "HUG", "FILL"].includes(layoutSizingHorizontal)) {
3756
+ throw new Error(
3757
+ "Invalid layoutSizingHorizontal value. Must be one of: FIXED, HUG, FILL"
3758
+ );
3759
+ }
3760
+ // HUG is only valid on auto-layout frames and text nodes
3761
+ if (
3762
+ layoutSizingHorizontal === "HUG" &&
3763
+ !["FRAME", "TEXT"].includes(node.type)
3764
+ ) {
3765
+ throw new Error(
3766
+ "HUG sizing is only valid on auto-layout frames and text nodes"
3767
+ );
3768
+ }
3769
+ // FILL is only valid on auto-layout children
3770
+ if (
3771
+ layoutSizingHorizontal === "FILL" &&
3772
+ (!node.parent || node.parent.layoutMode === "NONE")
3773
+ ) {
3774
+ throw new Error("FILL sizing is only valid on auto-layout children");
3775
+ }
3776
+ node.layoutSizingHorizontal = layoutSizingHorizontal;
3777
+ }
3778
+
3779
+ // Validate and set layoutSizingVertical if provided
3780
+ if (layoutSizingVertical !== undefined) {
3781
+ if (!["FIXED", "HUG", "FILL"].includes(layoutSizingVertical)) {
3782
+ throw new Error(
3783
+ "Invalid layoutSizingVertical value. Must be one of: FIXED, HUG, FILL"
3784
+ );
3785
+ }
3786
+ // HUG is only valid on auto-layout frames and text nodes
3787
+ if (
3788
+ layoutSizingVertical === "HUG" &&
3789
+ !["FRAME", "TEXT"].includes(node.type)
3790
+ ) {
3791
+ throw new Error(
3792
+ "HUG sizing is only valid on auto-layout frames and text nodes"
3793
+ );
3794
+ }
3795
+ // FILL is only valid on auto-layout children
3796
+ if (
3797
+ layoutSizingVertical === "FILL" &&
3798
+ (!node.parent || node.parent.layoutMode === "NONE")
3799
+ ) {
3800
+ throw new Error("FILL sizing is only valid on auto-layout children");
3801
+ }
3802
+ node.layoutSizingVertical = layoutSizingVertical;
3803
+ }
3804
+
3805
+ return {
3806
+ id: node.id,
3807
+ name: node.name,
3808
+ layoutSizingHorizontal: node.layoutSizingHorizontal,
3809
+ layoutSizingVertical: node.layoutSizingVertical,
3810
+ layoutMode: node.layoutMode,
3811
+ };
3812
+ }
3813
+
3814
+ async function setItemSpacing(params) {
3815
+ const { nodeId, itemSpacing, counterAxisSpacing } = params || {};
3816
+
3817
+ // Validate that at least one spacing parameter is provided
3818
+ if (itemSpacing === undefined && counterAxisSpacing === undefined) {
3819
+ throw new Error("At least one of itemSpacing or counterAxisSpacing must be provided");
3820
+ }
3821
+
3822
+ // Get the target node
3823
+ const node = await figma.getNodeByIdAsync(nodeId);
3824
+ if (!node) {
3825
+ throw new Error(`Node with ID ${nodeId} not found`);
3826
+ }
3827
+
3828
+ // Check if node is a frame or component that supports item spacing
3829
+ if (
3830
+ node.type !== "FRAME" &&
3831
+ node.type !== "COMPONENT" &&
3832
+ node.type !== "COMPONENT_SET" &&
3833
+ node.type !== "INSTANCE"
3834
+ ) {
3835
+ throw new Error(`Node type ${node.type} does not support item spacing`);
3836
+ }
3837
+
3838
+ // Check if the node has auto-layout enabled
3839
+ if (node.layoutMode === "NONE") {
3840
+ throw new Error(
3841
+ "Item spacing can only be set on auto-layout frames (layoutMode must not be NONE)"
3842
+ );
3843
+ }
3844
+
3845
+ // Set item spacing if provided
3846
+ if (itemSpacing !== undefined) {
3847
+ if (typeof itemSpacing !== "number") {
3848
+ throw new Error("Item spacing must be a number");
3849
+ }
3850
+ node.itemSpacing = itemSpacing;
3851
+ }
3852
+
3853
+ // Set counter axis spacing if provided
3854
+ if (counterAxisSpacing !== undefined) {
3855
+ if (typeof counterAxisSpacing !== "number") {
3856
+ throw new Error("Counter axis spacing must be a number");
3857
+ }
3858
+ // counterAxisSpacing only applies when layoutWrap is WRAP
3859
+ if (node.layoutWrap !== "WRAP") {
3860
+ throw new Error(
3861
+ "Counter axis spacing can only be set on frames with layoutWrap set to WRAP"
3862
+ );
3863
+ }
3864
+ node.counterAxisSpacing = counterAxisSpacing;
3865
+ }
3866
+
3867
+ return {
3868
+ id: node.id,
3869
+ name: node.name,
3870
+ itemSpacing: node.itemSpacing || undefined,
3871
+ counterAxisSpacing: node.counterAxisSpacing || undefined,
3872
+ layoutMode: node.layoutMode,
3873
+ layoutWrap: node.layoutWrap,
3874
+ };
3875
+ }
3876
+
3877
+ async function setDefaultConnector(params) {
3878
+ const { connectorId } = params || {};
3879
+
3880
+ // If connectorId is provided, search and set by that ID (do not check existing storage)
3881
+ if (connectorId) {
3882
+ // Get node by specified ID
3883
+ const node = await figma.getNodeByIdAsync(connectorId);
3884
+ if (!node) {
3885
+ throw new Error(`Connector node not found with ID: ${connectorId}`);
3886
+ }
3887
+
3888
+ // Check node type
3889
+ if (node.type !== 'CONNECTOR') {
3890
+ throw new Error(`Node is not a connector: ${connectorId}`);
3891
+ }
3892
+
3893
+ // Set the found connector as the default connector
3894
+ await figma.clientStorage.setAsync('defaultConnectorId', connectorId);
3895
+
3896
+ return {
3897
+ success: true,
3898
+ message: `Default connector set to: ${connectorId}`,
3899
+ connectorId: connectorId
3900
+ };
3901
+ }
3902
+ // If connectorId is not provided, check existing storage
3903
+ else {
3904
+ // Check if there is an existing default connector in client storage
3905
+ try {
3906
+ const existingConnectorId = await figma.clientStorage.getAsync('defaultConnectorId');
3907
+
3908
+ // If there is an existing connector ID, check if the node is still valid
3909
+ if (existingConnectorId) {
3910
+ try {
3911
+ const existingConnector = await figma.getNodeByIdAsync(existingConnectorId);
3912
+
3913
+ // If the stored connector still exists and is of type CONNECTOR
3914
+ if (existingConnector && existingConnector.type === 'CONNECTOR') {
3915
+ return {
3916
+ success: true,
3917
+ message: `Default connector is already set to: ${existingConnectorId}`,
3918
+ connectorId: existingConnectorId,
3919
+ exists: true
3920
+ };
3921
+ }
3922
+ // The stored connector is no longer valid - find a new connector
3923
+ else {
3924
+ console.log(`Stored connector ID ${existingConnectorId} is no longer valid, finding a new connector...`);
3925
+ }
3926
+ } catch (error) {
3927
+ console.log(`Error finding stored connector: ${error.message}. Will try to set a new one.`);
3928
+ }
3929
+ }
3930
+ } catch (error) {
3931
+ console.log(`Error checking for existing connector: ${error.message}`);
3932
+ }
3933
+
3934
+ // If there is no stored default connector or it is invalid, find one in the current page
3935
+ try {
3936
+ // Find CONNECTOR type nodes in the current page
3937
+ const currentPageConnectors = figma.currentPage.findAllWithCriteria({ types: ['CONNECTOR'] });
3938
+
3939
+ if (currentPageConnectors && currentPageConnectors.length > 0) {
3940
+ // Use the first connector found
3941
+ const foundConnector = currentPageConnectors[0];
3942
+ const autoFoundId = foundConnector.id;
3943
+
3944
+ // Set the found connector as the default connector
3945
+ await figma.clientStorage.setAsync('defaultConnectorId', autoFoundId);
3946
+
3947
+ return {
3948
+ success: true,
3949
+ message: `Automatically found and set default connector to: ${autoFoundId}`,
3950
+ connectorId: autoFoundId,
3951
+ autoSelected: true
3952
+ };
3953
+ } else {
3954
+ // If no connector is found in the current page, show a guide message
3955
+ throw new Error('No connector found in the current page. Please create a connector in Figma first or specify a connector ID.');
3956
+ }
3957
+ } catch (error) {
3958
+ // Error occurred while running findAllWithCriteria
3959
+ throw new Error(`Failed to find a connector: ${error.message}`);
3960
+ }
3961
+ }
3962
+ }
3963
+
3964
+ async function createCursorNode(targetNodeId) {
3965
+ const svgString = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
3966
+ <path d="M16 8V35.2419L22 28.4315L27 39.7823C27 39.7823 28.3526 40.2722 29 39.7823C29.6474 39.2924 30.2913 38.3057 30 37.5121C28.6247 33.7654 25 26.1613 25 26.1613H32L16 8Z" fill="#202125" />
3967
+ </svg>`;
3968
+ try {
3969
+ const targetNode = await figma.getNodeByIdAsync(targetNodeId);
3970
+ if (!targetNode) throw new Error("Target node not found");
3971
+
3972
+ // The targetNodeId has semicolons since it is a nested node.
3973
+ // So we need to get the parent node ID from the target node ID and check if we can appendChild to it or not.
3974
+ let parentNodeId = targetNodeId.includes(';')
3975
+ ? targetNodeId.split(';')[0]
3976
+ : targetNodeId;
3977
+ if (!parentNodeId) throw new Error("Could not determine parent node ID");
3978
+
3979
+ // Find the parent node to append cursor node as child
3980
+ let parentNode = await figma.getNodeByIdAsync(parentNodeId);
3981
+ if (!parentNode) throw new Error("Parent node not found");
3982
+
3983
+ // If the parent node is not eligible to appendChild, set the parentNode to the parent of the parentNode
3984
+ if (parentNode.type === 'INSTANCE' || parentNode.type === 'COMPONENT' || parentNode.type === 'COMPONENT_SET') {
3985
+ parentNode = parentNode.parent;
3986
+ if (!parentNode) throw new Error("Parent node not found");
3987
+ }
3988
+
3989
+ // Create the cursor node
3990
+ const importedNode = await figma.createNodeFromSvg(svgString);
3991
+ if (!importedNode || !importedNode.id) {
3992
+ throw new Error("Failed to create imported cursor node");
3993
+ }
3994
+ importedNode.name = "TTF_Connector / Mouse Cursor";
3995
+ importedNode.resize(48, 48);
3996
+
3997
+ const cursorNode = importedNode.findOne(node => node.type === 'VECTOR');
3998
+ if (cursorNode) {
3999
+ cursorNode.fills = [{
4000
+ type: 'SOLID',
4001
+ color: { r: 0, g: 0, b: 0 },
4002
+ opacity: 1
4003
+ }];
4004
+ cursorNode.strokes = [{
4005
+ type: 'SOLID',
4006
+ color: { r: 1, g: 1, b: 1 },
4007
+ opacity: 1
4008
+ }];
4009
+ cursorNode.strokeWeight = 2;
4010
+ cursorNode.strokeAlign = 'OUTSIDE';
4011
+ cursorNode.effects = [{
4012
+ type: "DROP_SHADOW",
4013
+ color: { r: 0, g: 0, b: 0, a: 0.3 },
4014
+ offset: { x: 1, y: 1 },
4015
+ radius: 2,
4016
+ spread: 0,
4017
+ visible: true,
4018
+ blendMode: "NORMAL"
4019
+ }];
4020
+ }
4021
+
4022
+ // Append the cursor node to the parent node
4023
+ parentNode.appendChild(importedNode);
4024
+
4025
+ // if the parentNode has auto-layout enabled, set the layoutPositioning to ABSOLUTE
4026
+ if ('layoutMode' in parentNode && parentNode.layoutMode !== 'NONE') {
4027
+ importedNode.layoutPositioning = 'ABSOLUTE';
4028
+ }
4029
+
4030
+ // Adjust the importedNode's position to the targetNode's position
4031
+ if (
4032
+ targetNode.absoluteBoundingBox &&
4033
+ parentNode.absoluteBoundingBox
4034
+ ) {
4035
+ // if the targetNode has absoluteBoundingBox, set the importedNode's absoluteBoundingBox to the targetNode's absoluteBoundingBox
4036
+ console.log('targetNode.absoluteBoundingBox', targetNode.absoluteBoundingBox);
4037
+ console.log('parentNode.absoluteBoundingBox', parentNode.absoluteBoundingBox);
4038
+ importedNode.x = targetNode.absoluteBoundingBox.x - parentNode.absoluteBoundingBox.x + targetNode.absoluteBoundingBox.width / 2 - 48 / 2
4039
+ importedNode.y = targetNode.absoluteBoundingBox.y - parentNode.absoluteBoundingBox.y + targetNode.absoluteBoundingBox.height / 2 - 48 / 2;
4040
+ } else if (
4041
+ 'x' in targetNode && 'y' in targetNode && 'width' in targetNode && 'height' in targetNode) {
4042
+ // if the targetNode has x, y, width, height, calculate center based on relative position
4043
+ console.log('targetNode.x/y/width/height', targetNode.x, targetNode.y, targetNode.width, targetNode.height);
4044
+ importedNode.x = targetNode.x + targetNode.width / 2 - 48 / 2;
4045
+ importedNode.y = targetNode.y + targetNode.height / 2 - 48 / 2;
4046
+ } else {
4047
+ // Fallback: Place at top-left of target if possible, otherwise at (0,0) relative to parent
4048
+ if ('x' in targetNode && 'y' in targetNode) {
4049
+ console.log('Fallback to targetNode x/y');
4050
+ importedNode.x = targetNode.x;
4051
+ importedNode.y = targetNode.y;
4052
+ } else {
4053
+ console.log('Fallback to (0,0)');
4054
+ importedNode.x = 0;
4055
+ importedNode.y = 0;
4056
+ }
4057
+ }
4058
+
4059
+ // get the importedNode ID and the importedNode
4060
+ console.log('importedNode', importedNode);
4061
+
4062
+
4063
+ return { id: importedNode.id, node: importedNode };
4064
+
4065
+ } catch (error) {
4066
+ console.error("Error creating cursor from SVG:", error);
4067
+ return { id: null, node: null, error: error.message };
4068
+ }
4069
+ }
4070
+
4071
+ async function createConnections(params) {
4072
+ if (!params || !params.connections || !Array.isArray(params.connections)) {
4073
+ throw new Error('Missing or invalid connections parameter');
4074
+ }
4075
+
4076
+ const { connections } = params;
4077
+
4078
+ // Command ID for progress tracking
4079
+ const commandId = generateCommandId();
4080
+ sendProgressUpdate(
4081
+ commandId,
4082
+ "create_connections",
4083
+ "started",
4084
+ 0,
4085
+ connections.length,
4086
+ 0,
4087
+ `Starting to create ${connections.length} connections`
4088
+ );
4089
+
4090
+ // Get default connector ID from client storage
4091
+ const defaultConnectorId = await figma.clientStorage.getAsync('defaultConnectorId');
4092
+ if (!defaultConnectorId) {
4093
+ throw new Error('No default connector set. Please try one of the following options to create connections:\n1. Create a connector in FigJam and copy/paste it to your current page, then run the "set_default_connector" command.\n2. Select an existing connector on the current page, then run the "set_default_connector" command.');
4094
+ }
4095
+
4096
+ // Get the default connector
4097
+ const defaultConnector = await figma.getNodeByIdAsync(defaultConnectorId);
4098
+ if (!defaultConnector) {
4099
+ throw new Error(`Default connector not found with ID: ${defaultConnectorId}`);
4100
+ }
4101
+ if (defaultConnector.type !== 'CONNECTOR') {
4102
+ throw new Error(`Node is not a connector: ${defaultConnectorId}`);
4103
+ }
4104
+
4105
+ // Results array for connection creation
4106
+ const results = [];
4107
+ let processedCount = 0;
4108
+ const totalCount = connections.length;
4109
+
4110
+ // Preload fonts (used for text if provided)
4111
+ let fontLoaded = false;
4112
+
4113
+ for (let i = 0; i < connections.length; i++) {
4114
+ try {
4115
+ const { startNodeId: originalStartId, endNodeId: originalEndId, text } = connections[i];
4116
+ let startId = originalStartId;
4117
+ let endId = originalEndId;
4118
+
4119
+ // Check and potentially replace start node ID
4120
+ if (startId.includes(';')) {
4121
+ console.log(`Nested start node detected: ${startId}. Creating cursor node.`);
4122
+ const cursorResult = await createCursorNode(startId);
4123
+ if (!cursorResult || !cursorResult.id) {
4124
+ throw new Error(`Failed to create cursor node for nested start node: ${startId}`);
4125
+ }
4126
+ startId = cursorResult.id;
4127
+ }
4128
+
4129
+ const startNode = await figma.getNodeByIdAsync(startId);
4130
+ if (!startNode) throw new Error(`Start node not found with ID: ${startId}`);
4131
+
4132
+ // Check and potentially replace end node ID
4133
+ if (endId.includes(';')) {
4134
+ console.log(`Nested end node detected: ${endId}. Creating cursor node.`);
4135
+ const cursorResult = await createCursorNode(endId);
4136
+ if (!cursorResult || !cursorResult.id) {
4137
+ throw new Error(`Failed to create cursor node for nested end node: ${endId}`);
4138
+ }
4139
+ endId = cursorResult.id;
4140
+ }
4141
+ const endNode = await figma.getNodeByIdAsync(endId);
4142
+ if (!endNode) throw new Error(`End node not found with ID: ${endId}`);
4143
+
4144
+
4145
+ // Clone the default connector
4146
+ const clonedConnector = defaultConnector.clone();
4147
+
4148
+ // Update connector name using potentially replaced node names
4149
+ clonedConnector.name = `TTF_Connector/${startNode.id}/${endNode.id}`;
4150
+
4151
+ // Set start and end points using potentially replaced IDs
4152
+ clonedConnector.connectorStart = {
4153
+ endpointNodeId: startId,
4154
+ magnet: 'AUTO'
4155
+ };
4156
+
4157
+ clonedConnector.connectorEnd = {
4158
+ endpointNodeId: endId,
4159
+ magnet: 'AUTO'
4160
+ };
4161
+
4162
+ // Add text (if provided)
4163
+ if (text) {
4164
+ try {
4165
+ // Try to load the necessary fonts
4166
+ try {
4167
+ // First check if default connector has font and use the same
4168
+ if (defaultConnector.text && defaultConnector.text.fontName) {
4169
+ const fontName = defaultConnector.text.fontName;
4170
+ await figma.loadFontAsync(fontName);
4171
+ clonedConnector.text.fontName = fontName;
4172
+ } else {
4173
+ // Try default Inter font
4174
+ await figma.loadFontAsync({ family: "Inter", style: "Regular" });
4175
+ }
4176
+ } catch (fontError) {
4177
+ // If first font load fails, try another font style
4178
+ try {
4179
+ await figma.loadFontAsync({ family: "Inter", style: "Medium" });
4180
+ } catch (mediumFontError) {
4181
+ // If second font fails, try system font
4182
+ try {
4183
+ await figma.loadFontAsync({ family: "System", style: "Regular" });
4184
+ } catch (systemFontError) {
4185
+ // If all font loading attempts fail, throw error
4186
+ throw new Error(`Failed to load any font: ${fontError.message}`);
4187
+ }
4188
+ }
4189
+ }
4190
+
4191
+ // Set the text
4192
+ clonedConnector.text.characters = text;
4193
+ } catch (textError) {
4194
+ console.error("Error setting text:", textError);
4195
+ // Continue with connection even if text setting fails
4196
+ results.push({
4197
+ id: clonedConnector.id,
4198
+ startNodeId: startNodeId,
4199
+ endNodeId: endNodeId,
4200
+ text: "",
4201
+ textError: textError.message
4202
+ });
4203
+
4204
+ // Continue to next connection
4205
+ continue;
4206
+ }
4207
+ }
4208
+
4209
+ // Add to results (using the *original* IDs for reference if needed)
4210
+ results.push({
4211
+ id: clonedConnector.id,
4212
+ originalStartNodeId: originalStartId,
4213
+ originalEndNodeId: originalEndId,
4214
+ usedStartNodeId: startId, // ID actually used for connection
4215
+ usedEndNodeId: endId, // ID actually used for connection
4216
+ text: text || ""
4217
+ });
4218
+
4219
+ // Update progress
4220
+ processedCount++;
4221
+ sendProgressUpdate(
4222
+ commandId,
4223
+ "create_connections",
4224
+ "in_progress",
4225
+ processedCount / totalCount,
4226
+ totalCount,
4227
+ processedCount,
4228
+ `Created connection ${processedCount}/${totalCount}`
4229
+ );
4230
+
4231
+ } catch (error) {
4232
+ console.error("Error creating connection", error);
4233
+ // Continue processing remaining connections even if an error occurs
4234
+ processedCount++;
4235
+ sendProgressUpdate(
4236
+ commandId,
4237
+ "create_connections",
4238
+ "in_progress",
4239
+ processedCount / totalCount,
4240
+ totalCount,
4241
+ processedCount,
4242
+ `Error creating connection: ${error.message}`
4243
+ );
4244
+
4245
+ results.push({
4246
+ error: error.message,
4247
+ connectionInfo: connections[i]
4248
+ });
4249
+ }
4250
+ }
4251
+
4252
+ // Completion update
4253
+ sendProgressUpdate(
4254
+ commandId,
4255
+ "create_connections",
4256
+ "completed",
4257
+ 1,
4258
+ totalCount,
4259
+ totalCount,
4260
+ `Completed creating ${results.length} connections`
4261
+ );
4262
+
4263
+ return {
4264
+ success: true,
4265
+ count: results.length,
4266
+ connections: results
4267
+ };
4268
+ }
4269
+
4270
+ // Set focus on a specific node
4271
+ async function setFocus(params) {
4272
+ if (!params || !params.nodeId) {
4273
+ throw new Error("Missing nodeId parameter");
4274
+ }
4275
+
4276
+ const node = await figma.getNodeByIdAsync(params.nodeId);
4277
+ if (!node) {
4278
+ throw new Error(`Node with ID ${params.nodeId} not found`);
4279
+ }
4280
+
4281
+ // Set selection to the node
4282
+ figma.currentPage.selection = [node];
4283
+
4284
+ // Scroll and zoom to show the node in viewport
4285
+ figma.viewport.scrollAndZoomIntoView([node]);
4286
+
4287
+ return {
4288
+ success: true,
4289
+ name: node.name,
4290
+ id: node.id,
4291
+ message: `Focused on node "${node.name}"`
4292
+ };
4293
+ }
4294
+
4295
+ // Set selection to multiple nodes
4296
+ async function setSelections(params) {
4297
+ if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) {
4298
+ throw new Error("Missing or invalid nodeIds parameter");
4299
+ }
4300
+
4301
+ if (params.nodeIds.length === 0) {
4302
+ throw new Error("nodeIds array cannot be empty");
4303
+ }
4304
+
4305
+ // Get all valid nodes
4306
+ const nodes = [];
4307
+ const notFoundIds = [];
4308
+
4309
+ for (const nodeId of params.nodeIds) {
4310
+ const node = await figma.getNodeByIdAsync(nodeId);
4311
+ if (node) {
4312
+ nodes.push(node);
4313
+ } else {
4314
+ notFoundIds.push(nodeId);
4315
+ }
4316
+ }
4317
+
4318
+ if (nodes.length === 0) {
4319
+ throw new Error(`No valid nodes found for the provided IDs: ${params.nodeIds.join(', ')}`);
4320
+ }
4321
+
4322
+ // Set selection to the nodes
4323
+ figma.currentPage.selection = nodes;
4324
+
4325
+ // Scroll and zoom to show all nodes in viewport
4326
+ figma.viewport.scrollAndZoomIntoView(nodes);
4327
+
4328
+ const selectedNodes = nodes.map(node => ({
4329
+ name: node.name,
4330
+ id: node.id
4331
+ }));
4332
+
4333
+ return {
4334
+ success: true,
4335
+ count: nodes.length,
4336
+ selectedNodes: selectedNodes,
4337
+ notFoundIds: notFoundIds,
4338
+ message: `Selected ${nodes.length} nodes${notFoundIds.length > 0 ? ` (${notFoundIds.length} not found)` : ''}`
4339
+ };
4340
+ }