You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
216 lines
8.1 KiB
216 lines
8.1 KiB
figma.showUI(__html__, { width: 320, height: 180 });
|
|
|
|
const NODE_LIMIT = 500;
|
|
|
|
async function runExport() {
|
|
const selection = figma.currentPage.selection;
|
|
|
|
if (selection.length === 0) {
|
|
figma.ui.postMessage({ type: 'error', message: 'Please select a frame or node.' });
|
|
return;
|
|
}
|
|
|
|
let nodeCount = 0;
|
|
function countNodes(node: SceneNode) {
|
|
nodeCount++;
|
|
if ("children" in node) {
|
|
for (const child of node.children) countNodes(child);
|
|
}
|
|
}
|
|
|
|
for (const node of selection) countNodes(node);
|
|
|
|
if (nodeCount > NODE_LIMIT) {
|
|
figma.ui.postMessage({ type: 'error', message: `Selection exceeds limit. Selected: ${nodeCount}. Limit: ${NODE_LIMIT}.` });
|
|
return;
|
|
}
|
|
|
|
let markdownOutput = "# AI DATA EXTRACTION GUIDE\n\n";
|
|
markdownOutput += "This archive contains structural design data and visual assets extracted from Figma specifically for machine learning interpretation.\n\n";
|
|
markdownOutput += "## Structural Architecture\n";
|
|
markdownOutput += "- data.md: This document contains the hierarchical tree of design nodes, text strings, and layout properties.\n";
|
|
markdownOutput += "- Folders: Named after the top-level selected nodes, containing the associated PNG and SVG image files.\n\n";
|
|
markdownOutput += "## Interpretation Rules for AI\n";
|
|
markdownOutput += "1. Tree Hierarchy: Indentation levels indicate parent-child node relationships.\n";
|
|
markdownOutput += "2. Asset Mapping: Visual exports (PNG and SVG formats) are located inside the corresponding top-level folder. Nodes are exported visually if they contain photographic fills or child text layers requiring structural context.\n";
|
|
markdownOutput += "3. Text Handling: Independent text nodes skip visual image export because their complete textual data is captured inline within this document.\n";
|
|
markdownOutput += "4. Property Metadata: The Raw Properties object provides precise dimensions, positions, fills, and styling definitions parsed directly from the Figma canvas API.\n\n";
|
|
markdownOutput += "--- \n\n";
|
|
markdownOutput += "## Hierarchical Tree Data\n\n";
|
|
|
|
const exportedImages: Array<{ folder: string; name: string; data: Uint8Array }> = [];
|
|
const allFolders: string[] = [];
|
|
|
|
function getSafeName(node: SceneNode): string {
|
|
let cleanName = node.name.replace(/[^a-z0-9]/gi, '_');
|
|
if (cleanName.length > 30) {
|
|
cleanName = cleanName.substring(0, 30);
|
|
}
|
|
const cleanId = node.id.replace(/[^a-z0-9]/gi, '_');
|
|
return `${cleanName}_${cleanId}`;
|
|
}
|
|
|
|
function hasText(node: SceneNode): boolean {
|
|
if (node.type === "TEXT") return true;
|
|
if ("children" in node) {
|
|
for (const child of node.children) {
|
|
if (hasText(child)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function cleanMixed(val: any): any {
|
|
if (val === figma.mixed) return "MIXED";
|
|
return val;
|
|
}
|
|
|
|
function cleanRawProps(props: any): any {
|
|
const cleaned: any = {};
|
|
for (const key of Object.keys(props)) {
|
|
const val = props[key];
|
|
if (val === null || val === undefined) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(val) && val.length === 0) {
|
|
continue;
|
|
}
|
|
if (key === "opacity" && val === 1) continue;
|
|
if (key === "rotation" && val === 0) continue;
|
|
if (key === "cornerRadius" && val === 0) continue;
|
|
if (key === "topLeftRadius" && val === 0) continue;
|
|
if (key === "topRightRadius" && val === 0) continue;
|
|
if (key === "bottomLeftRadius" && val === 0) continue;
|
|
if (key === "bottomRightRadius" && val === 0) continue;
|
|
if (key === "paddingLeft" && val === 0) continue;
|
|
if (key === "paddingRight" && val === 0) continue;
|
|
if (key === "paddingTop" && val === 0) continue;
|
|
if (key === "paddingBottom" && val === 0) continue;
|
|
if (key === "itemSpacing" && val === 0) continue;
|
|
if (key === "textCase" && val === "ORIGINAL") continue;
|
|
if (key === "textDecoration" && val === "NONE") continue;
|
|
|
|
if (key === "lineHeight" && typeof val === "object" && val.unit === "AUTO") {
|
|
continue;
|
|
}
|
|
if (key === "letterSpacing" && typeof val === "object" && val.value === 0) {
|
|
continue;
|
|
}
|
|
cleaned[key] = val;
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
async function traverseNode(node: SceneNode, depth: number, currentFolderPath: string, rootNode: SceneNode) {
|
|
allFolders.push(currentFolderPath);
|
|
|
|
const indent = " ".repeat(depth);
|
|
markdownOutput += `${indent}- Name: ${node.name} (Type: ${node.type}, Path: ${currentFolderPath})\n`;
|
|
|
|
if (node.type === "TEXT") {
|
|
markdownOutput += `${indent} - Text Content: ${node.characters}\n`;
|
|
}
|
|
|
|
let localX = node.x;
|
|
let localY = node.y;
|
|
if (node.id === rootNode.id) {
|
|
localX = 0;
|
|
localY = 0;
|
|
} else if (node.absoluteBoundingBox && rootNode.absoluteBoundingBox) {
|
|
localX = node.absoluteBoundingBox.x - rootNode.absoluteBoundingBox.x;
|
|
localY = node.absoluteBoundingBox.y - rootNode.absoluteBoundingBox.y;
|
|
}
|
|
|
|
const rawProps: any = {
|
|
id: node.id,
|
|
width: node.width,
|
|
height: node.height,
|
|
x: localX,
|
|
y: localY,
|
|
fills: "fills" in node ? node.fills : null,
|
|
strokes: "strokes" in node ? node.strokes : null,
|
|
effects: "effects" in node ? node.effects : null,
|
|
opacity: "opacity" in node ? node.opacity : null,
|
|
rotation: "rotation" in node ? node.rotation : null
|
|
};
|
|
|
|
if (node.type === "TEXT") {
|
|
rawProps.fontSize = cleanMixed(node.fontSize);
|
|
rawProps.fontName = cleanMixed(node.fontName);
|
|
rawProps.lineHeight = cleanMixed(node.lineHeight);
|
|
rawProps.letterSpacing = cleanMixed(node.letterSpacing);
|
|
rawProps.textAlignHorizontal = cleanMixed(node.textAlignHorizontal);
|
|
rawProps.textAlignVertical = cleanMixed(node.textAlignVertical);
|
|
rawProps.textCase = cleanMixed(node.textCase);
|
|
rawProps.textDecoration = cleanMixed(node.textDecoration);
|
|
}
|
|
|
|
if ("layoutMode" in node) {
|
|
rawProps.layoutMode = node.layoutMode;
|
|
rawProps.primaryAxisAlignItems = node.primaryAxisAlignItems;
|
|
rawProps.counterAxisAlignItems = node.counterAxisAlignItems;
|
|
rawProps.paddingLeft = node.paddingLeft;
|
|
rawProps.paddingRight = node.paddingRight;
|
|
rawProps.paddingTop = node.paddingTop;
|
|
rawProps.paddingBottom = node.paddingBottom;
|
|
rawProps.itemSpacing = node.itemSpacing;
|
|
}
|
|
|
|
if ("cornerRadius" in node) {
|
|
rawProps.cornerRadius = cleanMixed(node.cornerRadius);
|
|
}
|
|
|
|
if ("topLeftRadius" in node) {
|
|
rawProps.topLeftRadius = cleanMixed(node.topLeftRadius);
|
|
rawProps.topRightRadius = cleanMixed(node.topRightRadius);
|
|
rawProps.bottomLeftRadius = cleanMixed(node.bottomLeftRadius);
|
|
rawProps.bottomRightRadius = cleanMixed(node.bottomRightRadius);
|
|
}
|
|
|
|
markdownOutput += `${indent} - Raw Properties: ${JSON.stringify(cleanRawProps(rawProps))}\n`;
|
|
|
|
const isTextNode = node.type === "TEXT";
|
|
|
|
if (!isTextNode) {
|
|
try {
|
|
const pngBuffer = await node.exportAsync({ format: 'PNG' });
|
|
const safeNodeName = getSafeName(node);
|
|
exportedImages.push({ folder: currentFolderPath, name: `${safeNodeName}.png`, data: pngBuffer });
|
|
|
|
const nodeHasText = hasText(node);
|
|
if (!nodeHasText) {
|
|
const svgBuffer = await node.exportAsync({ format: 'SVG' });
|
|
exportedImages.push({ folder: currentFolderPath, name: `${safeNodeName}.svg`, data: svgBuffer });
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to export ${node.name}`, e);
|
|
}
|
|
}
|
|
|
|
if ("children" in node) {
|
|
for (const child of node.children) {
|
|
const childSafeName = getSafeName(child);
|
|
const childFolderPath = `${currentFolderPath}/children/${childSafeName}`;
|
|
await traverseNode(child, depth + 1, childFolderPath, rootNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const node of selection) {
|
|
const safeFolderName = getSafeName(node);
|
|
await traverseNode(node, 0, safeFolderName, node);
|
|
}
|
|
|
|
figma.ui.postMessage({
|
|
type: 'export',
|
|
markdown: markdownOutput,
|
|
images: exportedImages,
|
|
folders: allFolders
|
|
});
|
|
}
|
|
|
|
figma.ui.onmessage = (msg) => {
|
|
if (msg.type === 'run-extraction') {
|
|
runExport();
|
|
}
|
|
};
|