Loading...
Loading...
Chrome extension that captures webpages and converts them into editable Figma-compatible JSON files with support for full-page and element capture
npx skill4agent add aradotso/design-skills web-to-figma-chrome-extensionSkill by ara.so — Design Skills collection
.jsongit clone https://github.com/Paidax01/web-to-figma.git
cd web-to-figmachrome://extensions/web-to-figmaweb-to-figma/
├── manifest.json # Extension configuration
├── background.js # Service worker for proxy and coordination
├── capture.js # Core capture logic (DOM traversal, style extraction)
├── runner.js # Orchestrates capture process
├── inpage-toolbar.js # Floating UI toolbar on webpage
├── popup.html/css/js # Extension popup UI
└── logo/ # Extension iconscapture.jsbackground.jsrunner.jsinpage-toolbar.jspopup.html// Example popup.js structure
document.getElementById('startCapture').addEventListener('click', async () => {
const useProxy = document.getElementById('proxyMode').checked;
const concurrency = document.getElementById('concurrency').value;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.tabs.sendMessage(tab.id, {
action: 'startCapture',
config: {
useProxy: useProxy,
imageConcurrency: parseInt(concurrency)
}
});
});// inpage-toolbar.js pattern
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'startCapture') {
const config = message.config || {};
// Start capture process
captureWebpage(config).then(result => {
// Generate download
downloadJSON(result, `figma-capture-${Date.now()}.json`);
});
}
});// capture.js core pattern
function captureElement(element, config) {
const computedStyle = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
const nodeData = {
type: element.tagName.toLowerCase(),
id: element.id || null,
className: element.className || null,
position: {
x: rect.left + window.scrollX,
y: rect.top + window.scrollY,
width: rect.width,
height: rect.height
},
styles: extractStyles(computedStyle),
text: extractText(element),
children: []
};
// Handle images
if (element.tagName === 'IMG') {
nodeData.src = element.src;
if (config.useProxy) {
nodeData.proxyUrl = await fetchImageViaProxy(element.src, config);
}
}
// Recursively capture children
for (const child of element.children) {
if (shouldCaptureElement(child)) {
nodeData.children.push(captureElement(child, config));
}
}
return nodeData;
}
function extractStyles(computedStyle) {
return {
color: computedStyle.color,
backgroundColor: computedStyle.backgroundColor,
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily,
fontWeight: computedStyle.fontWeight,
lineHeight: computedStyle.lineHeight,
padding: {
top: computedStyle.paddingTop,
right: computedStyle.paddingRight,
bottom: computedStyle.paddingBottom,
left: computedStyle.paddingLeft
},
margin: {
top: computedStyle.marginTop,
right: computedStyle.marginRight,
bottom: computedStyle.marginBottom,
left: computedStyle.marginLeft
},
border: {
width: computedStyle.borderWidth,
style: computedStyle.borderStyle,
color: computedStyle.borderColor,
radius: computedStyle.borderRadius
},
display: computedStyle.display,
position: computedStyle.position,
zIndex: computedStyle.zIndex
};
}// background.js proxy implementation
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'fetchImage') {
fetch(message.url, {
method: 'GET',
mode: 'cors',
credentials: 'omit'
})
.then(response => response.blob())
.then(blob => {
const reader = new FileReader();
reader.onloadend = () => {
sendResponse({
success: true,
dataUrl: reader.result
});
};
reader.readAsDataURL(blob);
})
.catch(error => {
sendResponse({
success: false,
error: error.message
});
});
return true; // Async response
}
});
// capture.js usage
async function fetchImageViaProxy(url, config) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'fetchImage',
url: url
}, response => {
if (response.success) {
resolve(response.dataUrl);
} else {
reject(new Error(response.error));
}
});
});
}// Concurrency limiter pattern
class ConcurrencyQueue {
constructor(limit) {
this.limit = limit === 'infinite' ? Infinity : limit;
this.running = 0;
this.queue = [];
}
async add(fn) {
while (this.running >= this.limit) {
await new Promise(resolve => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
const resolve = this.queue.shift();
if (resolve) resolve();
}
}
}
// Usage in capture
async function captureImagesWithConcurrency(images, config) {
const queue = new ConcurrencyQueue(config.imageConcurrency || 4);
return Promise.all(
images.map(img =>
queue.add(() => fetchImageViaProxy(img.src, config))
)
);
}{
"manifest_version": 3,
"name": "Web to Figma",
"version": "1.0.0",
"description": "Convert any webpage into an editable Figma file",
"permissions": [
"activeTab",
"storage",
"downloads"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["inpage-toolbar.js", "capture.js", "runner.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "logo/icon16.png",
"48": "logo/icon48.png",
"128": "logo/icon128.png"
}
}
}const captureConfig = {
useProxy: true, // Enable cross-origin image proxy
imageConcurrency: 8, // Parallel image fetches (4/6/8/10/12/16/20/'infinite')
fullPage: true, // Capture entire page vs. viewport
includeHidden: false, // Capture elements with display:none
maxDepth: 50, // Maximum DOM traversal depth
captureIframes: false, // Whether to capture iframe content
captureBackgrounds: true, // Include CSS background images
minimumSize: { width: 1, height: 1 } // Skip tiny elements
};async function captureFullPage() {
const config = {
useProxy: true,
imageConcurrency: 8,
fullPage: true
};
// Scroll to top
window.scrollTo(0, 0);
// Capture from root
const rootElement = document.body;
const captureData = await captureElement(rootElement, config);
// Add metadata
const result = {
version: '1.0',
timestamp: new Date().toISOString(),
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight
},
tree: captureData
};
return result;
}function captureSpecificElement(selector) {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const config = {
useProxy: true,
imageConcurrency: 6,
fullPage: false
};
return captureElement(element, config);
}
// Usage
const headerData = await captureSpecificElement('header.main-header');function downloadJSON(data, filename) {
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
document.body.removeChild(a);
URL.revokeObjectURL(url);
}function captureSVG(svgElement) {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgElement);
const svgDataUrl = `data:image/svg+xml;base64,${btoa(svgString)}`;
return {
type: 'svg',
content: svgString,
dataUrl: svgDataUrl,
viewBox: svgElement.getAttribute('viewBox'),
width: svgElement.width.baseVal.value,
height: svgElement.height.baseVal.value
};
}function captureCanvas(canvasElement) {
try {
const dataUrl = canvasElement.toDataURL('image/png');
return {
type: 'canvas',
dataUrl: dataUrl,
width: canvasElement.width,
height: canvasElement.height
};
} catch (error) {
// Canvas may be tainted by cross-origin content
return {
type: 'canvas',
error: 'Tainted canvas - cross-origin content',
width: canvasElement.width,
height: canvasElement.height
};
}
}function extractCustomProperties(element) {
const styles = window.getComputedStyle(element);
const customProps = {};
// Extract CSS custom properties (variables)
for (let i = 0; i < styles.length; i++) {
const prop = styles[i];
if (prop.startsWith('--')) {
customProps[prop] = styles.getPropertyValue(prop);
}
}
return customProps;
}function extractTextNodes(element) {
const textNodes = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
const text = node.textContent.trim();
if (text) {
const range = document.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
textNodes.push({
text: text,
position: {
x: rect.left + window.scrollX,
y: rect.top + window.scrollY,
width: rect.width,
height: rect.height
}
});
}
}
return textNodes;
}function extractBackgroundImages(element) {
const style = window.getComputedStyle(element);
const bgImage = style.backgroundImage;
if (bgImage && bgImage !== 'none') {
const urlMatch = bgImage.match(/url\(['"]?(.*?)['"]?\)/);
if (urlMatch && urlMatch[1]) {
return {
url: urlMatch[1],
size: style.backgroundSize,
position: style.backgroundPosition,
repeat: style.backgroundRepeat
};
}
}
return null;
}async function captureWithProgress(element, config, onProgress) {
const totalElements = element.querySelectorAll('*').length;
let processedElements = 0;
async function captureWithCallback(el) {
processedElements++;
if (onProgress) {
onProgress({
processed: processedElements,
total: totalElements,
percentage: (processedElements / totalElements * 100).toFixed(2)
});
}
return captureElement(el, config);
}
return await captureWithCallback(element);
}
// Usage
const result = await captureWithProgress(
document.body,
{ useProxy: true, imageConcurrency: 8 },
(progress) => {
console.log(`Capturing: ${progress.percentage}%`);
}
);const config = { useProxy: true, imageConcurrency: 8 };{
"host_permissions": ["<all_urls>"]
}// Scroll through page to trigger lazy loading
async function scrollToLoadImages() {
const scrollStep = window.innerHeight;
const scrollMax = document.body.scrollHeight;
for (let y = 0; y < scrollMax; y += scrollStep) {
window.scrollTo(0, y);
await new Promise(resolve => setTimeout(resolve, 200));
}
window.scrollTo(0, 0);
}
await scrollToLoadImages();
const result = await captureFullPage();const config = { imageConcurrency: 4 }; // Lower valuefunction shouldCaptureElement(element) {
const style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
}function captureElementWithDepth(element, config, currentDepth = 0) {
if (currentDepth > config.maxDepth) {
return null;
}
// ... capture logic
}{
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["inpage-toolbar.js", "capture.js", "runner.js"],
"run_at": "document_idle"
}]
}// Check console for CSP errors
// May need to adjust for strict CSP pages# In chrome://extensions/, click reload iconasync function captureInChunks(rootElement, chunkSize = 100) {
const allElements = Array.from(rootElement.querySelectorAll('*'));
const chunks = [];
for (let i = 0; i < allElements.length; i += chunkSize) {
const chunk = allElements.slice(i, i + chunkSize);
chunks.push(await Promise.all(
chunk.map(el => captureElement(el, config))
));
// Allow event loop to process
await new Promise(resolve => setTimeout(resolve, 0));
}
return chunks.flat();
}const config = {
minimumSize: { width: 10, height: 10 },
excludeSelectors: ['.ad-container', '.comments-section']
};function compressStyles(styles) {
// Only include non-default values
const compressed = {};
const defaults = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
// ... other defaults
};
for (const [key, value] of Object.entries(styles)) {
if (value !== defaults[key]) {
compressed[key] = value;
}
}
return compressed;
}function exportInPages(captureData, elementsPerFile = 500) {
const files = [];
const elements = flattenTree(captureData);
for (let i = 0; i < elements.length; i += elementsPerFile) {
const chunk = elements.slice(i, i + elementsPerFile);
files.push({
filename: `figma-capture-page-${Math.floor(i / elementsPerFile) + 1}.json`,
data: { elements: chunk }
});
}
return files;
}# Remove development files
zip -r web-to-figma-extension.zip . \
-x "*.DS_Store" \
-x ".git/*" \
-x "node_modules/*" \
-x "*.md" \
-x "tests/*"{
"version": "1.0.1",
"version_name": "1.0.1 Beta"
}// Example post-processing for Figma plugin
function convertToFigmaNodes(captureData) {
return {
name: captureData.type || 'Frame',
type: mapToFigmaType(captureData.type),
x: captureData.position.x,
y: captureData.position.y,
width: captureData.position.width,
height: captureData.position.height,
fills: convertFills(captureData.styles.backgroundColor),
strokes: convertStrokes(captureData.styles.border),
children: captureData.children.map(convertToFigmaNodes)
};
}
function mapToFigmaType(htmlType) {
const typeMap = {
'div': 'FRAME',
'span': 'TEXT',
'img': 'RECTANGLE', // With image fill
'svg': 'VECTOR'
};
return typeMap[htmlType] || 'FRAME';
}