figma-restoration-tools
Version:
Professional Figma Component Restoration Kit - MCP tools with snapDOM-powered high-quality screenshots, intelligent shadow detection, and smart debugging for Vue component restoration. Includes figma_compare and snapdom_screenshot tools.
508 lines (449 loc) • 18 kB
JavaScript
import fs from 'fs/promises';
import path from 'path';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import {
ensureDirectory
} from '../utils/path-config.js';
import { puppeteerManager } from '../utils/puppeteer-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export class SnapDOMScreenshotTool {
constructor() {
this.description = 'Take high-quality screenshots using snapDOM technology for precise DOM-to-image capture';
this.DEFAULT_TIMEOUT = 3000; // 3秒超时
this.inputSchema = {
type: 'object',
properties: {
componentName: {
type: 'string',
description: 'Name of the component to screenshot'
},
projectPath: {
type: 'string',
default: '/Users/yujie_wu/Documents/work/camscanner-cloud-vue3',
description: 'Path to the Vue project'
},
port: {
type: 'number',
default: 83,
description: 'Port where Vue dev server is running'
},
viewport: {
type: 'object',
properties: {
width: { type: 'number', default: 1440 },
height: { type: 'number', default: 800 }
},
description: 'Viewport size for screenshot'
},
snapDOMOptions: {
type: 'object',
properties: {
scale: { type: 'number', default: 3 },
compress: { type: 'boolean', default: true },
fast: { type: 'boolean', default: false },
embedFonts: { type: 'boolean', default: true },
backgroundColor: { type: 'string', default: 'transparent' },
width: { type: 'number', description: 'Fixed width for output' },
height: { type: 'number', description: 'Fixed height for output' },
padding: { type: 'number', default: 0, description: 'Padding around element to capture shadows and effects' }
},
description: 'snapDOM capture options for high-quality screenshots with box-shadow support'
},
outputPath: {
type: 'string',
description: 'Custom output path for screenshot (optional)'
},
selector: {
type: 'string',
description: 'Custom CSS selector to screenshot (optional)'
},
figmaEffects: {
type: 'array',
description: 'Figma effects data for smart shadow padding calculation (optional)'
}
},
required: ['componentName']
};
}
// 超时包装函数
async withTimeout(promise, timeoutMs = this.DEFAULT_TIMEOUT, errorMessage = 'Operation timed out') {
const startTime = Date.now();
return Promise.race([
promise.then(result => {
const duration = Date.now() - startTime;
if (duration > timeoutMs * 0.8) { // 如果操作时间超过80%,发出警告
console.log(chalk.yellow(`⚠️ Slow operation: ${errorMessage} took ${duration}ms (limit: ${timeoutMs}ms)`));
}
return result;
}),
new Promise((_, reject) =>
setTimeout(() => {
console.log(chalk.red(`❌ TIMEOUT: ${errorMessage} after ${timeoutMs}ms`));
reject(new Error(`${errorMessage} (${timeoutMs}ms)`));
}, timeoutMs)
)
]);
}
async execute(args) {
const {
componentName,
projectPath = '/Users/yujie_wu/Documents/work/camscanner-cloud-vue3',
port = 83,
viewport = { width: 1440, height: 800 },
snapDOMOptions = {
scale: 3,
compress: true,
fast: false,
embedFonts: true,
backgroundColor: 'transparent',
padding: 0
},
outputPath,
selector,
figmaEffects // Optional Figma effects data for smart padding calculation
} = args;
try {
console.log(chalk.cyan('📸 snapDOM Screenshot Tool'));
console.log(chalk.cyan(`Component: ${componentName}`));
console.log(chalk.gray('='.repeat(50)));
// Determine results directory based on outputPath or use default
let resultsDir;
if (outputPath) {
// Check if outputPath is a directory or file path
const stats = await fs.stat(outputPath).catch(() => null);
if (stats && stats.isDirectory()) {
resultsDir = outputPath;
} else if (outputPath.endsWith('.png') || outputPath.endsWith('.jpg') || outputPath.endsWith('.jpeg')) {
resultsDir = path.dirname(outputPath);
} else {
// Assume it's a directory path if no file extension
resultsDir = outputPath;
}
} else {
resultsDir = path.join(projectPath, 'src', 'components', componentName, 'results');
}
await ensureDirectory(resultsDir);
// Ensure Vue dev server is running
console.log(chalk.blue('🚀 Checking Vue dev server...'));
await this.ensureDevServerRunning(port, projectPath);
// Calculate smart padding if Figma effects are provided
let enhancedSnapDOMOptions = { ...snapDOMOptions };
if (figmaEffects && snapDOMOptions.padding === 0) {
const calculatedPadding = this.calculateShadowPaddingFromFigma(figmaEffects);
if (calculatedPadding > 0) {
enhancedSnapDOMOptions.padding = calculatedPadding;
console.log(chalk.yellow(`[Smart Padding] Calculated from Figma effects: ${calculatedPadding}px`));
}
}
const screenshotResult = await this.withTimeout(
this.takeSnapDOMScreenshot({
componentName,
port,
viewport,
snapDOMOptions: enhancedSnapDOMOptions,
resultsDir,
outputPath: outputPath && (outputPath.endsWith('.png') || outputPath.endsWith('.jpg') || outputPath.endsWith('.jpeg')) ? outputPath : null,
selector
}),
this.DEFAULT_TIMEOUT * 5, // 给整个截图流程15秒时间
'Overall screenshot process timed out'
);
console.log(chalk.green('✅ snapDOM screenshot completed successfully!'));
return {
success: true,
componentName,
screenshot: screenshotResult,
summary: {
method: 'snapDOM',
quality: 'high',
outputPath: screenshotResult.path,
features: screenshotResult.features
}
};
} catch (error) {
console.error(chalk.red('❌ Screenshot failed:'), error.message);
return {
success: false,
error: error.message,
componentName
};
}
}
async ensureDevServerRunning(port, projectPath) {
try {
// Check if server is already running with timeout
console.log(chalk.gray(`🔍 Checking server on port ${port}...`));
const response = await this.withTimeout(
fetch(`http://localhost:${port}`),
this.DEFAULT_TIMEOUT,
`Server check on port ${port} timed out`
);
if (response.ok) {
console.log(chalk.green(`✅ Vue dev server already running on port ${port}`));
return true;
}
} catch (error) {
if (error.message.includes('timed out')) {
console.log(chalk.red(`❌ Server check timed out after ${this.DEFAULT_TIMEOUT}ms`));
throw error;
}
// Server not running, try to start it
console.log(chalk.yellow(`⚠️ Vue dev server not running on port ${port}, attempting to start...`));
try {
// Start the server
const child = spawn('yarn', ['dev'], {
cwd: projectPath,
detached: true,
stdio: 'ignore'
});
child.unref();
// Wait for server to start
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000));
try {
const serverResponse = await fetch(`http://localhost:${port}`);
if (serverResponse.ok) {
console.log(chalk.green(`✅ Vue dev server started successfully on port ${port}`));
return true;
}
} catch (e) {
// Continue waiting
}
attempts++;
}
throw new Error('Server failed to start within timeout period');
} catch (startError) {
throw new Error(`Failed to start Vue dev server: ${startError.message}`);
}
}
}
// Helper function to calculate padding from Figma shadow data
calculateShadowPaddingFromFigma(figmaEffects) {
if (!figmaEffects || !Array.isArray(figmaEffects)) return 0;
let maxPadding = 0;
for (const effect of figmaEffects) {
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
const offsetX = Math.abs(effect.offset?.x || 0);
const offsetY = Math.abs(effect.offset?.y || 0);
const blurRadius = effect.radius || 0;
const spreadRadius = effect.spread || 0;
// Calculate required padding: max(blur + spread + offset)
const requiredPadding = Math.max(
offsetX + blurRadius + spreadRadius,
offsetY + blurRadius + spreadRadius
);
maxPadding = Math.max(maxPadding, requiredPadding);
}
}
return maxPadding > 0 ? Math.ceil(maxPadding) + 5 : 0; // Add 5px buffer and round up
}
async takeSnapDOMScreenshot({ componentName, port, viewport, snapDOMOptions, resultsDir, outputPath, selector }) {
console.log(chalk.gray(`⏱️ Starting screenshot with ${this.DEFAULT_TIMEOUT}ms timeout for each operation`));
const browser = await this.withTimeout(
puppeteerManager.launchBrowser(),
this.DEFAULT_TIMEOUT,
'Browser launch timed out'
);
try {
const page = await this.withTimeout(
browser.newPage(),
this.DEFAULT_TIMEOUT,
'New page creation timed out'
);
// Set viewport
await this.withTimeout(
page.setViewport({
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1 // snapDOM handles scaling
}),
this.DEFAULT_TIMEOUT,
'Viewport setup timed out'
);
// Navigate to component
const url = `http://localhost:${port}/component/${componentName}`;
console.log(chalk.gray(`📍 Navigating to: ${url}`));
await this.withTimeout(
page.goto(url, {
waitUntil: 'networkidle2',
timeout: this.DEFAULT_TIMEOUT
}),
this.DEFAULT_TIMEOUT,
`Page navigation to ${url} timed out`
);
// Determine selector
let targetSelector = selector;
if (!targetSelector) {
targetSelector = `.${componentName.replace(/([A-Z])/g, (match, letter, index) =>
index === 0 ? letter.toLowerCase() : '-' + letter.toLowerCase()
)}`;
}
console.log(chalk.gray(`🔍 Looking for selector: ${targetSelector}`));
try {
await this.withTimeout(
page.waitForSelector(targetSelector, { timeout: this.DEFAULT_TIMEOUT }),
this.DEFAULT_TIMEOUT,
`Element selector ${targetSelector} wait timed out`
);
} catch (error) {
// Fallback to container selector
console.log(chalk.yellow(`⚠️ Primary selector failed, trying container selector...`));
targetSelector = '#benchmark-container-for-screenshot';
await this.withTimeout(
page.waitForSelector(targetSelector, { timeout: this.DEFAULT_TIMEOUT }),
this.DEFAULT_TIMEOUT,
`Fallback selector ${targetSelector} wait timed out`
);
}
// Additional wait for animations/images
await this.withTimeout(
page.waitForTimeout(500),
this.DEFAULT_TIMEOUT,
'Animation wait timed out'
);
// Use snapDOM for screenshot
console.log(chalk.blue('📸 Using snapDOM for high-quality screenshot...'));
let screenshotPath;
if (outputPath && (outputPath.endsWith('.png') || outputPath.endsWith('.jpg') || outputPath.endsWith('.jpeg'))) {
screenshotPath = outputPath;
} else {
screenshotPath = path.join(resultsDir, 'actual.png');
}
const element = await this.withTimeout(
page.$(targetSelector),
this.DEFAULT_TIMEOUT,
`Element query ${targetSelector} timed out`
);
if (!element) {
throw new Error(`Component selector ${targetSelector} not found`);
}
// Use snapDOM to capture the element with box-shadow support
console.log(chalk.gray(`⏱️ Starting snapDOM capture with ${this.DEFAULT_TIMEOUT}ms timeout...`));
const snapResult = await this.withTimeout(
page.evaluate(async (sel, options) => {
const element = document.querySelector(sel);
if (!element) {
throw new Error(`Element not found: ${sel}`);
}
// Import snapdom from CDN as fallback
let snapdom;
try {
const module = await import('/@fs/Users/yujie_wu/Documents/study/11111/figma-restoration-mcp-vue-tools/node_modules/@zumer/snapdom/dist/snapdom.mjs');
snapdom = module.snapdom;
} catch (error) {
// Fallback to CDN if local import fails
const module = await import('https://unpkg.com/@zumer/snapdom@1.9.5/dist/snapdom.mjs');
snapdom = module.snapdom;
}
// Smart shadow detection and padding calculation
let targetElement = element;
let calculatedPadding = options.padding;
// If padding is 0, check if element has box-shadow and calculate needed padding
if (options.padding === 0) {
const computedStyle = window.getComputedStyle(element);
const boxShadow = computedStyle.getPropertyValue('box-shadow');
if (boxShadow && boxShadow !== 'none') {
// Calculate padding directly in browser context
const shadows = boxShadow.split(',');
let maxPadding = 0;
for (const shadow of shadows) {
const match = shadow.trim().match(/(\d+)px\s+(\d+)px\s+(\d+)px\s+(\d+)px/);
if (match) {
const offsetX = parseInt(match[1]);
const offsetY = parseInt(match[2]);
const blurRadius = parseInt(match[3]);
const spreadRadius = parseInt(match[4]);
const requiredPadding = Math.max(
Math.abs(offsetX) + blurRadius + spreadRadius,
Math.abs(offsetY) + blurRadius + spreadRadius
);
maxPadding = Math.max(maxPadding, requiredPadding);
}
}
if (maxPadding > 0) {
calculatedPadding = maxPadding + 5; // Add 5px buffer
console.log(`[SnapDOM] Detected box-shadow: ${boxShadow}`);
console.log(`[SnapDOM] Calculated padding: ${calculatedPadding}px`);
}
}
}
// Create wrapper if padding is needed
if (calculatedPadding > 0) {
const wrapper = document.createElement('div');
wrapper.style.padding = `${calculatedPadding}px`;
wrapper.style.display = 'inline-block';
wrapper.style.backgroundColor = 'transparent';
// Clone the element to avoid modifying the original
const clonedElement = element.cloneNode(true);
wrapper.appendChild(clonedElement);
// Temporarily insert wrapper into DOM for accurate rendering
element.parentNode.insertBefore(wrapper, element);
element.style.display = 'none';
targetElement = wrapper;
}
// Capture with snapDOM using only supported options
const result = await snapdom(targetElement, {
scale: options.scale,
compress: options.compress,
fast: options.fast,
embedFonts: options.embedFonts,
backgroundColor: options.backgroundColor,
width: options.width,
height: options.height
});
// Clean up wrapper if created
if (calculatedPadding > 0 && targetElement !== element) {
targetElement.remove();
element.style.display = '';
}
// Convert to PNG blob
const blob = await result.toBlob({ type: 'png' });
// Convert blob to base64
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}, targetSelector, snapDOMOptions),
this.DEFAULT_TIMEOUT,
'SnapDOM capture operation timed out'
);
// Save the base64 image to file
console.log(chalk.gray(`💾 Saving screenshot to: ${screenshotPath}`));
const base64Data = snapResult.replace(/^data:image\/png;base64,/, '');
await this.withTimeout(
fs.writeFile(screenshotPath, Buffer.from(base64Data, 'base64')),
this.DEFAULT_TIMEOUT,
'File save operation timed out'
);
console.log(chalk.green(`✅ snapDOM screenshot saved: ${screenshotPath}`));
return {
path: screenshotPath,
url,
selector: targetSelector,
viewport,
snapDOMOptions,
method: 'snapDOM',
snapDOMVersion: '1.9.5',
quality: 'high',
features: ['DOM-to-image', 'font-embedding', 'pseudo-elements', 'shadow-DOM', 'smart-shadow-detection', 'high-performance', '3x-scale']
};
} finally {
try {
await this.withTimeout(
puppeteerManager.closeBrowser(),
this.DEFAULT_TIMEOUT,
'Browser close operation timed out'
);
} catch (error) {
console.log(chalk.yellow(`⚠️ Browser close timeout: ${error.message}`));
// Don't throw here, just log the warning
}
}
}
}