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