Browse Source
feat: initialize AI Data Extractor Figma plugin with automated structural and asset extraction capabilities
main
feat: initialize AI Data Extractor Figma plugin with automated structural and asset extraction capabilities
main
commit
2d2d791c79
9 changed files with 2192 additions and 0 deletions
-
8.gitignore
-
40README.md
-
132code.ts
-
32eslint.config.js
-
11manifest.json
-
1591package-lock.json
-
22package.json
-
11tsconfig.json
-
345ui.html
@ -0,0 +1,8 @@ |
|||
# Node |
|||
*.log |
|||
*.log.* |
|||
node_modules |
|||
|
|||
out/ |
|||
dist/ |
|||
code.js |
|||
@ -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. |
|||
@ -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(); |
|||
} |
|||
}; |
|||
@ -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'], |
|||
}, |
|||
) |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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" |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "es2020", |
|||
"lib": ["es2020"], |
|||
"strict": true, |
|||
"typeRoots": [ |
|||
"./node_modules/@types", |
|||
"./node_modules/@figma" |
|||
] |
|||
} |
|||
} |
|||
@ -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> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue