Browse Source

feat: initialize AI Data Extractor Figma plugin with automated structural and asset extraction capabilities

main
sina_sajjadi 1 month ago
commit
2d2d791c79
  1. 8
      .gitignore
  2. 40
      README.md
  3. 132
      code.ts
  4. 32
      eslint.config.js
  5. 11
      manifest.json
  6. 1591
      package-lock.json
  7. 22
      package.json
  8. 11
      tsconfig.json
  9. 345
      ui.html

8
.gitignore

@ -0,0 +1,8 @@
# Node
*.log
*.log.*
node_modules
out/
dist/
code.js

40
README.md

@ -0,0 +1,40 @@
Below are the steps to get your plugin running. You can also find instructions at:
https://www.figma.com/plugin-docs/plugin-quickstart-guide/
This plugin template uses Typescript and NPM, two standard tools in creating JavaScript applications.
First, download Node.js which comes with NPM. This will allow you to install TypeScript and other
libraries. You can find the download link here:
https://nodejs.org/en/download/
Next, install TypeScript using the command:
npm install -g typescript
Finally, in the directory of your plugin, get the latest type definitions for the plugin API by running:
npm install --save-dev @figma/plugin-typings
If you are familiar with JavaScript, TypeScript will look very familiar. In fact, valid JavaScript code
is already valid Typescript code.
TypeScript adds type annotations to variables. This allows code editors such as Visual Studio Code
to provide information about the Figma API while you are writing code, as well as help catch bugs
you previously didn't notice.
For more information, visit https://www.typescriptlang.org/
Using TypeScript requires a compiler to convert TypeScript (code.ts) into JavaScript (code.js)
for the browser to run.
We recommend writing TypeScript code using Visual Studio code:
1. Download Visual Studio Code if you haven't already: https://code.visualstudio.com/.
2. Open this directory in Visual Studio Code.
3. Compile TypeScript to JavaScript: Run the "Terminal > Run Build Task..." menu item,
then select "npm: watch". You will have to do this again every time
you reopen Visual Studio Code.
That's it! Visual Studio Code will regenerate the JavaScript file every time you save.

132
code.ts

@ -0,0 +1,132 @@
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;
}
async function traverseNode(node: SceneNode, depth: number, currentFolderPath: string) {
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`;
}
const rawProps = {
id: node.id,
width: node.width,
height: node.height,
x: node.x,
y: node.y,
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
};
markdownOutput += `${indent} - Raw Properties: ${JSON.stringify(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);
}
}
}
for (const node of selection) {
const safeFolderName = getSafeName(node);
await traverseNode(node, 0, safeFolderName);
}
figma.ui.postMessage({
type: 'export',
markdown: markdownOutput,
images: exportedImages,
folders: allFolders
});
}
figma.ui.onmessage = (msg) => {
if (msg.type === 'run-extraction') {
runExport();
}
};

32
eslint.config.js

@ -0,0 +1,32 @@
const eslint = require('@eslint/js')
const tseslint = require('typescript-eslint')
const figmaPlugin = require('@figma/eslint-plugin-figma-plugins')
module.exports = tseslint.config(
eslint.configs.recommended,
// @typescript-eslint/recommended-type-checked is too aggressive for
// widget code...it doesn't seem to like JSX element return values or
// unbundling the `widget` object for use* hooks. So we'll use
// tseslint.configs.recommended instead.
tseslint.configs.recommended,
{
plugins: {
'@figma/figma-plugins': figmaPlugin,
},
rules: {
...figmaPlugin.configs.recommended.rules,
// allow underscore-prefixing of unused variables
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
ignores: ['code.js', 'dist', 'eslint.config.js'],
},
)

11
manifest.json

@ -0,0 +1,11 @@
{
"name": "AI Data Extractor",
"id": "1646495958553683885",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": ["figma"],
"networkAccess": {
"allowedDomains": ["https://cdnjs.cloudflare.com"]
}
}

1591
package-lock.json
File diff suppressed because it is too large
View File

22
package.json

@ -0,0 +1,22 @@
{
"name": "Data-extractore",
"version": "1.0.0",
"description": "Your Figma Plugin",
"main": "code.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"watch": "npm run build -- --watch"
},
"author": "",
"license": "",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@figma/eslint-plugin-figma-plugins": "*",
"@figma/plugin-typings": "*",
"eslint": "^9.39.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1"
}
}

11
tsconfig.json

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"lib": ["es2020"],
"strict": true,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@figma"
]
}
}

345
ui.html

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
:root {
--bg-color: #0c0c0e;
--card-bg: #16161a;
--text-color: #f3f4f6;
--text-muted: #9ca3af;
--accent-color: #6366f1;
--success-color: #10b981;
--error-color: #ef4444;
--border-color: #27272a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
height: 180px;
width: 320px;
overflow: hidden;
margin: 0;
padding: 16px;
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
text-align: center;
position: relative;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.5);
}
/* Gradient glow outline */
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
z-index: -1;
background: linear-gradient(135deg, #6366f1, #a855f7, #ec4899);
border-radius: 13px;
opacity: 0.15;
transition: opacity 0.3s ease;
}
body.complete .container::before {
background: linear-gradient(135deg, #10b981, #059669);
opacity: 0.3;
}
body.error .container::before {
background: linear-gradient(135deg, #ef4444, #dc2626);
opacity: 0.3;
}
/* Icon styles */
.status-icon {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
}
/* Spinner animation */
.spinner {
width: 26px;
height: 26px;
border: 3px solid rgba(99, 102, 241, 0.15);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* SVG icons */
.success-svg, .error-svg, .idle-svg {
width: 26px;
height: 26px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.idle-svg {
stroke: var(--accent-color);
}
/* Progress bar style for ZIP generation */
.progress-container {
width: 100%;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #6366f1, #a855f7);
width: 30%;
border-radius: 2px;
animation: progress-pulse 1.5s infinite ease-in-out;
}
@keyframes progress-pulse {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(330%);
}
}
#status {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
max-width: 260px;
word-wrap: break-word;
transition: color 0.3s ease;
}
/* Button styles */
#action-btn {
display: none;
align-items: center;
justify-content: center;
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #6366f1, #a855f7);
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
transition: all 0.2s ease;
width: 100%;
max-width: 180px;
}
#action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
background: linear-gradient(135deg, #4f46e5, #9333ea);
}
#action-btn:active {
transform: translateY(1px);
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.2);
}
/* State modifications */
body.idle #action-btn,
body.complete #action-btn,
body.error #action-btn {
display: inline-flex;
}
body.processing #status {
color: var(--text-color);
}
body.exporting .spinner {
border-top-color: #a855f7;
}
body.exporting .progress-container {
display: block;
}
body.complete #status {
color: var(--success-color);
}
body.complete .status-icon {
color: var(--success-color);
animation: pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
body.error #status {
color: var(--error-color);
}
body.error .status-icon {
color: var(--error-color);
animation: shake 0.4s ease-in-out;
}
@keyframes pop {
0% { transform: scale(0.6); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
</style>
</head>
<body class="idle">
<div class="container">
<div class="status-icon" id="status-icon"></div>
<div id="status">Select layers in Figma to extract</div>
<div class="progress-container" id="progress-container">
<div class="progress-bar"></div>
</div>
<button id="action-btn">Extract Selection</button>
</div>
<script>
const statusIconEl = document.getElementById('status-icon');
const progressContainerEl = document.getElementById('progress-container');
function updateState(state, message) {
document.body.className = state;
document.getElementById('status').innerText = message;
const actionBtn = document.getElementById('action-btn');
if (state === 'idle') {
statusIconEl.innerHTML = `
<svg class="idle-svg" viewBox="0 0 24 24">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`;
progressContainerEl.style.display = 'none';
actionBtn.innerText = 'Extract Selection';
} else if (state === 'processing') {
statusIconEl.innerHTML = '<div class="spinner"></div>';
progressContainerEl.style.display = 'none';
} else if (state === 'exporting') {
statusIconEl.innerHTML = '<div class="spinner"></div>';
progressContainerEl.style.display = 'block';
} else if (state === 'complete') {
statusIconEl.innerHTML = `
<svg class="success-svg" viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
progressContainerEl.style.display = 'none';
actionBtn.innerText = 'Extract Again';
} else if (state === 'error') {
statusIconEl.innerHTML = `
<svg class="error-svg" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
`;
progressContainerEl.style.display = 'none';
actionBtn.innerText = 'Retry Extraction';
}
}
// Handle extraction trigger
document.getElementById('action-btn').onclick = () => {
updateState('processing', 'Processing selection...');
parent.postMessage({ pluginMessage: { type: 'run-extraction' } }, '*');
};
// Initialize with idle state
updateState('idle', 'Select layers in Figma to extract');
onmessage = async (event) => {
const msg = event.data.pluginMessage;
if (msg.type === 'error') {
updateState('error', msg.message);
}
else if (msg.type === 'export') {
updateState('exporting', 'Generating ZIP archive...');
try {
const zip = new JSZip();
const createdFolders = new Set();
if (msg.folders && Array.isArray(msg.folders)) {
for (const folder of msg.folders) {
if (folder && !createdFolders.has(folder)) {
zip.folder(folder);
createdFolders.add(folder);
}
}
}
zip.file("data.md", msg.markdown);
for (const img of msg.images) {
const filePath = img.folder ? `${img.folder}/${img.name}` : img.name;
zip.file(filePath, img.data);
}
const content = await zip.generateAsync({
type: "blob",
platform: "DOS"
});
const url = URL.createObjectURL(content);
const a = document.createElement("a");
a.href = url;
a.download = "figma-ai-extraction.zip";
a.click();
URL.revokeObjectURL(url);
updateState('complete', 'Download complete.');
} catch (err) {
updateState('error', 'ZIP generation failed: ' + err.message);
}
}
};
</script>
</body>
</html>
Loading…
Cancel
Save