Cursor Talk To Figma MCP

by toiletmadarchut
Verified
// This is the main code file for the Cursor MCP Figma plugin // It handles Figma API commands // Plugin state const state = { serverPort: 3055, // Default port }; // Show UI figma.showUI(__html__, { width: 350, height: 450 }); // Plugin commands from UI figma.ui.onmessage = async (msg) => { switch (msg.type) { case "update-settings": updateSettings(msg); break; case "notify": figma.notify(msg.message); break; case "close-plugin": figma.closePlugin(); break; case "execute-command": // Execute commands received from UI (which gets them from WebSocket) try { const result = await handleCommand(msg.command, msg.params); // Send result back to UI figma.ui.postMessage({ type: "command-result", id: msg.id, result, }); } catch (error) { figma.ui.postMessage({ type: "command-error", id: msg.id, error: error.message || "Error executing command", }); } break; } }; // Listen for plugin commands from menu figma.on("run", ({ command }) => { figma.ui.postMessage({ type: "auto-connect" }); }); // Update plugin settings function updateSettings(settings) { if (settings.serverPort) { state.serverPort = settings.serverPort; } figma.clientStorage.setAsync("settings", { serverPort: state.serverPort, }); } // Handle commands from UI async function handleCommand(command, params) { switch (command) { case "get_document_info": return await getDocumentInfo(); case "get_selection": return await getSelection(); case "get_node_info": if (!params || !params.nodeId) { throw new Error("Missing nodeId parameter"); } return await getNodeInfo(params.nodeId); case "create_rectangle": return await createRectangle(params); case "create_frame": return await createFrame(params); case "create_text": return await createText(params); case "set_fill_color": return await setFillColor(params); case "set_stroke_color": return await setStrokeColor(params); case "move_node": return await moveNode(params); case "resize_node": return await resizeNode(params); case "delete_node": return await deleteNode(params); case "get_styles": return await getStyles(); case "get_local_components": return await getLocalComponents(); // case "get_team_components": // return await getTeamComponents(); case "create_component_instance": return await createComponentInstance(params); case "export_node_as_image": return await exportNodeAsImage(params); case "execute_code": return await executeCode(params); case "set_corner_radius": return await setCornerRadius(params); default: throw new Error(`Unknown command: ${command}`); } } // Command implementations async function getDocumentInfo() { await figma.currentPage.loadAsync(); const page = figma.currentPage; return { name: page.name, id: page.id, type: page.type, children: page.children.map((node) => ({ id: node.id, name: node.name, type: node.type, })), currentPage: { id: page.id, name: page.name, childCount: page.children.length, }, pages: [ { id: page.id, name: page.name, childCount: page.children.length, }, ], }; } async function getSelection() { return { selectionCount: figma.currentPage.selection.length, selection: figma.currentPage.selection.map((node) => ({ id: node.id, name: node.name, type: node.type, visible: node.visible, })), }; } async function getNodeInfo(nodeId) { const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } // Base node information const nodeInfo = { id: node.id, name: node.name, type: node.type, visible: node.visible, }; // Add position and size for SceneNode if ("x" in node && "y" in node) { nodeInfo.x = node.x; nodeInfo.y = node.y; } if ("width" in node && "height" in node) { nodeInfo.width = node.width; nodeInfo.height = node.height; } // Add fills for nodes with fills if ("fills" in node) { nodeInfo.fills = node.fills; } // Add strokes for nodes with strokes if ("strokes" in node) { nodeInfo.strokes = node.strokes; if ("strokeWeight" in node) { nodeInfo.strokeWeight = node.strokeWeight; } } // Add children for parent nodes if ("children" in node) { nodeInfo.children = node.children.map((child) => ({ id: child.id, name: child.name, type: child.type, })); } // Add text-specific properties if (node.type === "TEXT") { nodeInfo.characters = node.characters; nodeInfo.fontSize = node.fontSize; nodeInfo.fontName = node.fontName; } return nodeInfo; } async function createRectangle(params) { const { x = 0, y = 0, width = 100, height = 100, name = "Rectangle", parentId, } = params || {}; const rect = figma.createRectangle(); rect.x = x; rect.y = y; rect.resize(width, height); rect.name = name; // If parentId is provided, append to that node, otherwise append to current page if (parentId) { const parentNode = await figma.getNodeByIdAsync(parentId); if (!parentNode) { throw new Error(`Parent node not found with ID: ${parentId}`); } if (!("appendChild" in parentNode)) { throw new Error(`Parent node does not support children: ${parentId}`); } parentNode.appendChild(rect); } else { figma.currentPage.appendChild(rect); } return { id: rect.id, name: rect.name, x: rect.x, y: rect.y, width: rect.width, height: rect.height, parentId: rect.parent ? rect.parent.id : undefined, }; } async function createFrame(params) { const { x = 0, y = 0, width = 100, height = 100, name = "Frame", parentId, fillColor, strokeColor, strokeWeight, } = params || {}; const frame = figma.createFrame(); frame.x = x; frame.y = y; frame.resize(width, height); frame.name = name; // Set fill color if provided if (fillColor) { const paintStyle = { type: "SOLID", color: { r: parseFloat(fillColor.r) || 0, g: parseFloat(fillColor.g) || 0, b: parseFloat(fillColor.b) || 0, }, opacity: parseFloat(fillColor.a) || 1, }; frame.fills = [paintStyle]; } // Set stroke color and weight if provided if (strokeColor) { const strokeStyle = { type: "SOLID", color: { r: parseFloat(strokeColor.r) || 0, g: parseFloat(strokeColor.g) || 0, b: parseFloat(strokeColor.b) || 0, }, opacity: parseFloat(strokeColor.a) || 1, }; frame.strokes = [strokeStyle]; } // Set stroke weight if provided if (strokeWeight !== undefined) { frame.strokeWeight = strokeWeight; } // If parentId is provided, append to that node, otherwise append to current page if (parentId) { const parentNode = await figma.getNodeByIdAsync(parentId); if (!parentNode) { throw new Error(`Parent node not found with ID: ${parentId}`); } if (!("appendChild" in parentNode)) { throw new Error(`Parent node does not support children: ${parentId}`); } parentNode.appendChild(frame); } else { figma.currentPage.appendChild(frame); } return { id: frame.id, name: frame.name, x: frame.x, y: frame.y, width: frame.width, height: frame.height, fills: frame.fills, strokes: frame.strokes, strokeWeight: frame.strokeWeight, parentId: frame.parent ? frame.parent.id : undefined, }; } async function createText(params) { const { x = 0, y = 0, text = "Text", fontSize = 14, fontWeight = 400, fontColor = { r: 0, g: 0, b: 0, a: 1 }, // Default to black name = "Text", parentId, } = params || {}; // Map common font weights to Figma font styles const getFontStyle = (weight) => { switch (weight) { case 100: return "Thin"; case 200: return "Extra Light"; case 300: return "Light"; case 400: return "Regular"; case 500: return "Medium"; case 600: return "Semi Bold"; case 700: return "Bold"; case 800: return "Extra Bold"; case 900: return "Black"; default: return "Regular"; } }; const textNode = figma.createText(); textNode.x = x; textNode.y = y; textNode.name = name; try { await figma.loadFontAsync({ family: "Inter", style: getFontStyle(fontWeight), }); textNode.fontName = { family: "Inter", style: getFontStyle(fontWeight) }; textNode.fontSize = parseInt(fontSize); } catch (error) { console.error("Error setting font size", error); } setCharacters(textNode, text); // Set text color const paintStyle = { type: "SOLID", color: { r: parseFloat(fontColor.r) || 0, g: parseFloat(fontColor.g) || 0, b: parseFloat(fontColor.b) || 0, }, opacity: parseFloat(fontColor.a) || 1, }; textNode.fills = [paintStyle]; // If parentId is provided, append to that node, otherwise append to current page if (parentId) { const parentNode = await figma.getNodeByIdAsync(parentId); if (!parentNode) { throw new Error(`Parent node not found with ID: ${parentId}`); } if (!("appendChild" in parentNode)) { throw new Error(`Parent node does not support children: ${parentId}`); } parentNode.appendChild(textNode); } else { figma.currentPage.appendChild(textNode); } return { id: textNode.id, name: textNode.name, x: textNode.x, y: textNode.y, width: textNode.width, height: textNode.height, characters: textNode.characters, fontSize: textNode.fontSize, fontWeight: fontWeight, fontColor: fontColor, fontName: textNode.fontName, fills: textNode.fills, parentId: textNode.parent ? textNode.parent.id : undefined, }; } async function setFillColor(params) { console.log("setFillColor", params); const { nodeId, color: { r, g, b, a }, } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } if (!("fills" in node)) { throw new Error(`Node does not support fills: ${nodeId}`); } // Create RGBA color const rgbColor = { r: parseFloat(r) || 0, g: parseFloat(g) || 0, b: parseFloat(b) || 0, a: parseFloat(a) || 1, }; // Set fill const paintStyle = { type: "SOLID", color: { r: parseFloat(rgbColor.r), g: parseFloat(rgbColor.g), b: parseFloat(rgbColor.b), }, opacity: parseFloat(rgbColor.a), }; console.log("paintStyle", paintStyle); node.fills = [paintStyle]; return { id: node.id, name: node.name, fills: [paintStyle], }; } async function setStrokeColor(params) { const { nodeId, color: { r, g, b, a }, weight = 1, } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } if (!("strokes" in node)) { throw new Error(`Node does not support strokes: ${nodeId}`); } // Create RGBA color const rgbColor = { r: r !== undefined ? r : 0, g: g !== undefined ? g : 0, b: b !== undefined ? b : 0, a: a !== undefined ? a : 1, }; // Set stroke const paintStyle = { type: "SOLID", color: { r: rgbColor.r, g: rgbColor.g, b: rgbColor.b, }, opacity: rgbColor.a, }; node.strokes = [paintStyle]; // Set stroke weight if available if ("strokeWeight" in node) { node.strokeWeight = weight; } return { id: node.id, name: node.name, strokes: node.strokes, strokeWeight: "strokeWeight" in node ? node.strokeWeight : undefined, }; } async function moveNode(params) { const { nodeId, x, y } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } if (x === undefined || y === undefined) { throw new Error("Missing x or y parameters"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } if (!("x" in node) || !("y" in node)) { throw new Error(`Node does not support position: ${nodeId}`); } node.x = x; node.y = y; return { id: node.id, name: node.name, x: node.x, y: node.y, }; } async function resizeNode(params) { const { nodeId, width, height } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } if (width === undefined || height === undefined) { throw new Error("Missing width or height parameters"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } if (!("resize" in node)) { throw new Error(`Node does not support resizing: ${nodeId}`); } node.resize(width, height); return { id: node.id, name: node.name, width: node.width, height: node.height, }; } async function deleteNode(params) { const { nodeId } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } // Save node info before deleting const nodeInfo = { id: node.id, name: node.name, type: node.type, }; node.remove(); return nodeInfo; } async function getStyles() { const styles = { colors: await figma.getLocalPaintStylesAsync(), texts: await figma.getLocalTextStylesAsync(), effects: await figma.getLocalEffectStylesAsync(), grids: await figma.getLocalGridStylesAsync(), }; return { colors: styles.colors.map((style) => ({ id: style.id, name: style.name, key: style.key, paint: style.paints[0], })), texts: styles.texts.map((style) => ({ id: style.id, name: style.name, key: style.key, fontSize: style.fontSize, fontName: style.fontName, })), effects: styles.effects.map((style) => ({ id: style.id, name: style.name, key: style.key, })), grids: styles.grids.map((style) => ({ id: style.id, name: style.name, key: style.key, })), }; } async function getLocalComponents() { await figma.loadAllPagesAsync(); const components = figma.root.findAllWithCriteria({ types: ["COMPONENT"], }); return { count: components.length, components: components.map((component) => ({ id: component.id, name: component.name, key: "key" in component ? component.key : null, })), }; } // async function getTeamComponents() { // try { // const teamComponents = // await figma.teamLibrary.getAvailableComponentsAsync(); // return { // count: teamComponents.length, // components: teamComponents.map((component) => ({ // key: component.key, // name: component.name, // description: component.description, // libraryName: component.libraryName, // })), // }; // } catch (error) { // throw new Error(`Error getting team components: ${error.message}`); // } // } async function createComponentInstance(params) { const { componentKey, x = 0, y = 0 } = params || {}; if (!componentKey) { throw new Error("Missing componentKey parameter"); } try { const component = await figma.importComponentByKeyAsync(componentKey); const instance = component.createInstance(); instance.x = x; instance.y = y; figma.currentPage.appendChild(instance); return { id: instance.id, name: instance.name, x: instance.x, y: instance.y, width: instance.width, height: instance.height, componentId: instance.componentId, }; } catch (error) { throw new Error(`Error creating component instance: ${error.message}`); } } async function exportNodeAsImage(params) { const { nodeId, format = "PNG", scale = 1 } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } if (!("exportAsync" in node)) { throw new Error(`Node does not support exporting: ${nodeId}`); } try { const settings = { format: format, constraint: { type: "SCALE", value: scale }, }; const bytes = await node.exportAsync(settings); let mimeType; switch (format) { case "PNG": mimeType = "image/png"; break; case "JPG": mimeType = "image/jpeg"; break; case "SVG": mimeType = "image/svg+xml"; break; case "PDF": mimeType = "application/pdf"; break; default: mimeType = "application/octet-stream"; } // Convert to base64 const uint8Array = new Uint8Array(bytes); let binary = ""; for (let i = 0; i < uint8Array.length; i++) { binary += String.fromCharCode(uint8Array[i]); } const base64 = btoa(binary); const imageData = `data:${mimeType};base64,${base64}`; return { nodeId, format, scale, mimeType, imageData, }; } catch (error) { throw new Error(`Error exporting node as image: ${error.message}`); } } async function executeCode(params) { const { code } = params || {}; if (!code) { throw new Error("Missing code parameter"); } try { // Execute the provided code // Note: This is potentially unsafe, but matches the Blender MCP functionality const executeFn = new Function( "figma", "selection", ` try { const result = (async () => { ${code} })(); return result; } catch (error) { throw new Error('Error executing code: ' + error.message); } ` ); const result = await executeFn(figma, figma.currentPage.selection); return { result }; } catch (error) { throw new Error(`Error executing code: ${error.message}`); } } async function setCornerRadius(params) { const { nodeId, radius, corners } = params || {}; if (!nodeId) { throw new Error("Missing nodeId parameter"); } if (radius === undefined) { throw new Error("Missing radius parameter"); } const node = await figma.getNodeByIdAsync(nodeId); if (!node) { throw new Error(`Node not found with ID: ${nodeId}`); } // Check if node supports corner radius if (!("cornerRadius" in node)) { throw new Error(`Node does not support corner radius: ${nodeId}`); } // If corners array is provided, set individual corner radii if (corners && Array.isArray(corners) && corners.length === 4) { if ("topLeftRadius" in node) { // Node supports individual corner radii if (corners[0]) node.topLeftRadius = radius; if (corners[1]) node.topRightRadius = radius; if (corners[2]) node.bottomRightRadius = radius; if (corners[3]) node.bottomLeftRadius = radius; } else { // Node only supports uniform corner radius node.cornerRadius = radius; } } else { // Set uniform corner radius node.cornerRadius = radius; } return { id: node.id, name: node.name, cornerRadius: "cornerRadius" in node ? node.cornerRadius : undefined, topLeftRadius: "topLeftRadius" in node ? node.topLeftRadius : undefined, topRightRadius: "topRightRadius" in node ? node.topRightRadius : undefined, bottomRightRadius: "bottomRightRadius" in node ? node.bottomRightRadius : undefined, bottomLeftRadius: "bottomLeftRadius" in node ? node.bottomLeftRadius : undefined, }; } // Initialize settings on load (async function initializePlugin() { try { const savedSettings = await figma.clientStorage.getAsync("settings"); if (savedSettings) { if (savedSettings.serverPort) { state.serverPort = savedSettings.serverPort; } } // Send initial settings to UI figma.ui.postMessage({ type: "init-settings", settings: { serverPort: state.serverPort, }, }); } catch (error) { console.error("Error loading settings:", error); } })(); function uniqBy(arr, predicate) { const cb = typeof predicate === "function" ? predicate : (o) => o[predicate]; return [ ...arr .reduce((map, item) => { const key = item === null || item === undefined ? item : cb(item); map.has(key) || map.set(key, item); return map; }, new Map()) .values(), ]; } const setCharacters = async (node, characters, options) => { const fallbackFont = (options && options.fallbackFont) || { family: "Inter", style: "Regular", }; try { if (node.fontName === figma.mixed) { if (options && options.smartStrategy === "prevail") { const fontHashTree = {}; for (let i = 1; i < node.characters.length; i++) { const charFont = node.getRangeFontName(i - 1, i); const key = `${charFont.family}::${charFont.style}`; fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1; } const prevailedTreeItem = Object.entries(fontHashTree).sort( (a, b) => b[1] - a[1] )[0]; const [family, style] = prevailedTreeItem[0].split("::"); const prevailedFont = { family, style, }; await figma.loadFontAsync(prevailedFont); node.fontName = prevailedFont; } else if (options && options.smartStrategy === "strict") { return setCharactersWithStrictMatchFont(node, characters, fallbackFont); } else if (options && options.smartStrategy === "experimental") { return setCharactersWithSmartMatchFont(node, characters, fallbackFont); } else { const firstCharFont = node.getRangeFontName(0, 1); await figma.loadFontAsync(firstCharFont); node.fontName = firstCharFont; } } else { await figma.loadFontAsync({ family: node.fontName.family, style: node.fontName.style, }); } } catch (err) { console.warn( `Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`, err ); await figma.loadFontAsync(fallbackFont); node.fontName = fallbackFont; } try { node.characters = characters; return true; } catch (err) { console.warn(`Failed to set characters. Skipped.`, err); return false; } }; const setCharactersWithStrictMatchFont = async ( node, characters, fallbackFont ) => { const fontHashTree = {}; for (let i = 1; i < node.characters.length; i++) { const startIdx = i - 1; const startCharFont = node.getRangeFontName(startIdx, i); const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`; while (i < node.characters.length) { i++; const charFont = node.getRangeFontName(i - 1, i); if (startCharFontVal !== `${charFont.family}::${charFont.style}`) { break; } } fontHashTree[`${startIdx}_${i}`] = startCharFontVal; } await figma.loadFontAsync(fallbackFont); node.fontName = fallbackFont; node.characters = characters; console.log(fontHashTree); await Promise.all( Object.keys(fontHashTree).map(async (range) => { console.log(range, fontHashTree[range]); const [start, end] = range.split("_"); const [family, style] = fontHashTree[range].split("::"); const matchedFont = { family, style, }; await figma.loadFontAsync(matchedFont); return node.setRangeFontName(Number(start), Number(end), matchedFont); }) ); return true; }; const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => { const indices = []; let temp = startIdx; for (let i = startIdx; i < endIdx; i++) { if ( str[i] === delimiter && i + startIdx !== endIdx && temp !== i + startIdx ) { indices.push([temp, i + startIdx]); temp = i + startIdx + 1; } } temp !== endIdx && indices.push([temp, endIdx]); return indices.filter(Boolean); }; const buildLinearOrder = (node) => { const fontTree = []; const newLinesPos = getDelimiterPos(node.characters, "\n"); newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => { const newLinesRangeFont = node.getRangeFontName( newLinesRangeStart, newLinesRangeEnd ); if (newLinesRangeFont === figma.mixed) { const spacesPos = getDelimiterPos( node.characters, " ", newLinesRangeStart, newLinesRangeEnd ); spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => { const spacesRangeFont = node.getRangeFontName( spacesRangeStart, spacesRangeEnd ); if (spacesRangeFont === figma.mixed) { const spacesRangeFont = node.getRangeFontName( spacesRangeStart, spacesRangeStart[0] ); fontTree.push({ start: spacesRangeStart, delimiter: " ", family: spacesRangeFont.family, style: spacesRangeFont.style, }); } else { fontTree.push({ start: spacesRangeStart, delimiter: " ", family: spacesRangeFont.family, style: spacesRangeFont.style, }); } }); } else { fontTree.push({ start: newLinesRangeStart, delimiter: "\n", family: newLinesRangeFont.family, style: newLinesRangeFont.style, }); } }); return fontTree .sort((a, b) => +a.start - +b.start) .map(({ family, style, delimiter }) => ({ family, style, delimiter })); }; const setCharactersWithSmartMatchFont = async ( node, characters, fallbackFont ) => { const rangeTree = buildLinearOrder(node); const fontsToLoad = uniqBy( rangeTree, ({ family, style }) => `${family}::${style}` ).map(({ family, style }) => ({ family, style, })); await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync)); node.fontName = fallbackFont; node.characters = characters; let prevPos = 0; rangeTree.forEach(({ family, style, delimiter }) => { if (prevPos < node.characters.length) { const delimeterPos = node.characters.indexOf(delimiter, prevPos); const endPos = delimeterPos > prevPos ? delimeterPos : node.characters.length; const matchedFont = { family, style, }; node.setRangeFontName(prevPos, endPos, matchedFont); prevPos = endPos + 1; } }); return true; };