Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 70 additions & 5 deletions mcp-server/src/figma-export/dom-traversal.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
return 'LEFT';
}

// ─── Font family sanitizer ────────────────────────────────────────────────
// CSS system keywords and generic families are not valid Figma font names.
// Forwarding them results in blank text when pasted into Figma, so we
// resolve the first real family name or fall back to Inter.

const FONT_KEYWORDS = new Set([
'-apple-system', 'blinkmacsystemfont', 'system-ui', '-webkit-system-font',
'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded',
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
]);

function sanitizeFontFamily(cssFontFamily) {
if (!cssFontFamily) return 'Inter';
const parts = cssFontFamily.split(',').map((s) => s.replace(/['"]/g, '').trim());
for (const p of parts) {
if (!p) continue;
if (FONT_KEYWORDS.has(p.toLowerCase())) continue;
return p;
}
return 'Inter';
}

// ─── Build a TEXT node for a pure-text element ────────────────────────────

function makeTextNode(el, rect, cs, parentLeft, parentTop) {
Expand Down Expand Up @@ -200,7 +222,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
opacity: parseFloat(cs.opacity),
characters: text,
style: {
fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(),
fontFamily: sanitizeFontFamily(cs.fontFamily),
fontPostScriptName: null,
fontSize: fs,
fontWeight: parseInt(cs.fontWeight) || 400,
Expand Down Expand Up @@ -291,6 +313,49 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
const cs = window.getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return null;

// SVG elements are painted via SVG `fill` / `stroke` attributes, which the
// generic container path ignores (it only reads CSS `background` / `border`).
// Walking in would emit empty FRAME nodes, so collapse each <svg> into a
// single colored placeholder rectangle at its bounding box.
if (tag === 'svg') {
function resolveSvgColor(raw) {
if (!raw || raw === 'none' || raw === 'transparent') return null;
if (raw === 'currentColor') return parseRgba(cs.color);
return parseRgba(raw);
}
const iconColor =
resolveSvgColor(el.getAttribute('stroke')) ||
resolveSvgColor(el.getAttribute('fill')) ||
resolveSvgColor(cs.stroke) ||
resolveSvgColor(cs.fill) ||
parseRgba(cs.color) ||
{ r: 0.6, g: 0.6, b: 0.6, a: 1 };

const hasCircleChild = !!el.querySelector('circle');
const isSquare = Math.abs(rect.width - rect.height) < 1;
const cornerRadius = hasCircleChild && isSquare
? Math.min(rect.width, rect.height) / 2
: 2;

return {
id: genId(),
type: 'RECTANGLE',
name: el.getAttribute('aria-label') || 'Icon',
x: rect.left - parentLeft,
y: rect.top - parentTop,
width: rect.width,
height: rect.height,
opacity: parseFloat(cs.opacity) || 1,
fills: [{ type: 'SOLID', color: toFigmaColor(iconColor), opacity: iconColor.a }],
strokes: [],
strokeWeight: 0,
strokeAlign: 'INSIDE',
effects: [],
cornerRadius,
clipsContent: false,
};
}

// Pure-text leaf: no child elements, has text content
if (el.childElementCount === 0 && el.textContent.trim()) {
const textNode = makeTextNode(el, rect, cs, parentLeft, parentTop);
Expand Down Expand Up @@ -414,7 +479,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
stackChildAlignSelf: 'AUTO',
characters: displayText,
style: {
fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(),
fontFamily: sanitizeFontFamily(cs.fontFamily),
fontPostScriptName: null,
fontSize: fs,
fontWeight: parseInt(cs.fontWeight) || 400,
Expand Down Expand Up @@ -451,7 +516,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
stackChildAlignSelf: 'AUTO',
characters: text,
style: {
fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(),
fontFamily: sanitizeFontFamily(cs.fontFamily),
fontPostScriptName: null,
fontSize: fs,
fontWeight: parseInt(cs.fontWeight) || 400,
Expand Down Expand Up @@ -509,8 +574,8 @@ export function domTraversalFn(viewportWidth, viewportHeight) {
type: 'FRAME',
name: 'Converted Screen',
x: 0, y: 0,
width: viewportWidth,
height: viewportHeight,
width: Math.max(viewportWidth || 0, 100),
height: Math.max(viewportHeight || 0, 100),
opacity: 1,
fills: bodyBg
? [{ type: 'SOLID', color: toFigmaColor(bodyBg), opacity: bodyBg.a }]
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/useFigmaPaste.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ export function useFigmaPaste({ handlePaste, addScreenAtCenter }) {
frame.imageDataUrl,
frame.frameName,
i * (220 + GAP),
{ figmaSource: frameFigmaSource, sourceHtml: frame.html },
{
figmaSource: frameFigmaSource,
sourceHtml: frame.html,
sourceWidth: frame.width,
sourceHeight: frame.height,
},
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useScreenManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function makeScreen(overrides = {}) {
figmaSource: null,
svgContent: null,
sourceHtml: null,
sourceWidth: null,
sourceHeight: null,
wireframe: null,
...overrides,
};
Expand Down
19 changes: 16 additions & 3 deletions src/utils/copyToFigma.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ function parseHtmlViewport(html) {
};
}

function resolveViewport(screen) {
const storedWidth = Number.isFinite(screen.sourceWidth) ? screen.sourceWidth : null;
const storedHeight = Number.isFinite(screen.sourceHeight) ? screen.sourceHeight : null;
if (storedWidth && storedHeight) {
return { width: storedWidth, height: storedHeight };
}
const fromHtml = parseHtmlViewport(screen.sourceHtml || "");
return {
width: storedWidth || fromHtml.width,
height: storedHeight || fromHtml.height,
};
}

/**
* Copies a single screen to the clipboard in Figma's native binary format.
* When pasted in Figma, this produces editable frames with proper fills,
Expand All @@ -217,7 +230,7 @@ export async function copyScreenForFigmaEditable(screen) {
const { htmlToRawNodeTree } = await import("./htmlToFigmaNodes");
const { copyAsFigmaClipboard } = await import("./figmaClipboard");

const viewport = parseHtmlViewport(screen.sourceHtml);
const viewport = resolveViewport(screen);
const rootNode = await htmlToRawNodeTree(screen.sourceHtml, {
width: viewport.width,
height: viewport.height,
Expand Down Expand Up @@ -245,7 +258,7 @@ export async function copyScreensForFigmaEditable(screens) {
const GAP = 40;

if (withHtml.length === 1) {
const viewport = parseHtmlViewport(withHtml[0].sourceHtml);
const viewport = resolveViewport(withHtml[0]);
const rootNode = await htmlToRawNodeTree(withHtml[0].sourceHtml, {
width: viewport.width,
height: viewport.height,
Expand All @@ -265,7 +278,7 @@ export async function copyScreensForFigmaEditable(screens) {
let maxHeight = 852;
for (let i = 0; i < withHtml.length; i++) {
const s = withHtml[i];
const vp = parseHtmlViewport(s.sourceHtml);
const vp = resolveViewport(s);
if (vp.width > maxWidth) maxWidth = vp.width;
if (vp.height > maxHeight) maxHeight = vp.height;
const rootNode = await htmlToRawNodeTree(s.sourceHtml, {
Expand Down
2 changes: 2 additions & 0 deletions src/utils/importFlow.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export function importFlow(fileText) {
if (screen.figmaSource === undefined) screen.figmaSource = null;
if (screen.svgContent === undefined) screen.svgContent = null;
if (screen.sourceHtml === undefined) screen.sourceHtml = null;
if (screen.sourceWidth === undefined) screen.sourceWidth = null;
if (screen.sourceHeight === undefined) screen.sourceHeight = null;
if (screen.wireframe === undefined) screen.wireframe = null;
if (Array.isArray(screen.hotspots)) {
for (const hs of screen.hotspots) {
Expand Down
Loading