From 44704004e02c939f683cb31a0d72a58efc8a5ec2 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:29:15 +0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Figma-to-HTML=20paste=20=E2=80=94=20?= =?UTF-8?q?restore=20auto-layout,=20position,=20and=20gradient=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @grida/refig factory drops all auto-layout properties when converting kiwi binary to REST API format. This caused pasted Figma frames to render with no flex layout, children stacked at (0,0), and gradients silently dropped. Changes: - parseFigmaClipboard.js: Extend factory.node monkey-patch to copy 13 auto-layout properties (stackMode, stackSpacing, padding, alignment, etc.) from raw kiwi nodeChanges onto factory output - figmaToHtml.js: New converter — Figma node tree to HTML/CSS with support for auto-layout/flex, relativeTransform positions, linear gradients (both kiwi and REST formats), text styling, fills, strokes, corner radii, opacity, and nested frames - useFigmaPaste.js: Use HTML conversion pipeline instead of WASM renderer for higher-fidelity output; support multi-frame paste - ModalsLayer.jsx: Improve paste feedback toast styling - figmaToHtml.test.js: 11 tests covering auto-layout, position, gradients, text, fills, and corner radius --- src/components/ModalsLayer.jsx | 7 +- src/hooks/useFigmaPaste.js | 60 ++- src/utils/figmaToHtml.js | 660 +++++++++++++++++++++++++++++++ src/utils/figmaToHtml.test.js | 242 ++++++++++++ src/utils/parseFigmaClipboard.js | 97 ++++- 5 files changed, 1044 insertions(+), 22 deletions(-) create mode 100644 src/utils/figmaToHtml.js create mode 100644 src/utils/figmaToHtml.test.js diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx index 86ca49a..6789c61 100644 --- a/src/components/ModalsLayer.jsx +++ b/src/components/ModalsLayer.jsx @@ -206,12 +206,13 @@ export function ModalsLayer({ {figmaError && (
setFigmaError(null)}> - Figma paste failed: {figmaError} + {figmaError}
)} diff --git a/src/hooks/useFigmaPaste.js b/src/hooks/useFigmaPaste.js index 471b507..6cc4ba3 100644 --- a/src/hooks/useFigmaPaste.js +++ b/src/hooks/useFigmaPaste.js @@ -1,10 +1,10 @@ import { useState, useEffect, useRef } from "react"; -import { isFigmaClipboard, extractFigmaData, parseFigmaFrames, renderFigmaBuffer } from "../utils/parseFigmaClipboard"; +import { isFigmaClipboard, extractFigmaData, parseFigmaFrames, convertFigmaBuffer } from "../utils/parseFigmaClipboard"; // Time window (ms) to apply stashed Figma metadata to a regular image paste. // After detecting Figma clipboard and prompting "Copy as PNG", the user re-copies // with Shift+Cmd+C and pastes. The stashed frame name is applied to that paste. -const FIGMA_STASH_TTL = 30000; +const FIGMA_STASH_TTL = 60000; export function useFigmaPaste({ handlePaste, addScreenAtCenter }) { const [figmaProcessing, setFigmaProcessing] = useState(false); @@ -63,26 +63,50 @@ export function useFigmaPaste({ handlePaste, addScreenAtCenter }) { }; reader.readAsDataURL(blob); } else { - // Figma Web: no native PNG. Render via WASM with IMAGE fills - // stripped (they have no pixel data and would show checker patterns). - // Stash metadata so a follow-up Shift+Cmd+C paste inherits the name. - figmaStashRef.current = { frameName, figmaSource, stashedAt: Date.now() }; + // Figma Web: no native PNG available. + // Convert the Figma node tree to HTML and render via the browser's + // own layout engine. This produces higher-fidelity output than the + // WASM renderer (correct fonts, colors, and layout). setFigmaProcessing(true); try { - const rendered = await renderFigmaBuffer(figmaData.buffer); - if (rendered.frameCount > 1) { - alert("Multiple frames detected. Only the first frame was imported. Please copy and paste one frame at a time for best results."); + const converted = await convertFigmaBuffer(figmaData.buffer); + const GAP = 40; + + for (let i = 0; i < converted.length; i++) { + const frame = converted[i]; + const frameFigmaSource = { + fileKey: figmaData.meta.fileKey, + frameName: frame.frameName, + importedAt: new Date().toISOString(), + }; + addScreenAtCenter( + frame.imageDataUrl, + frame.frameName, + i * (220 + GAP), + { figmaSource: frameFigmaSource, sourceHtml: frame.html }, + ); + } + + // Stash metadata for follow-up Shift+Cmd+C pixel-perfect paste + figmaStashRef.current = { + frameName: converted[0]?.frameName ?? frameName, + figmaSource, + stashedAt: Date.now(), + frameCount: converted.length, + }; + + if (converted.length > 0) { + setFigmaError( + `${converted.length} screen${converted.length > 1 ? "s" : ""} imported from Figma. ` + + "For pixel-perfect images, use \u21E7\u2318C in Figma, then paste here.", + ); } - addScreenAtCenter(rendered.imageDataUrl, rendered.frameName, 0, { figmaSource }); - setFigmaError( - "Tip: For pixel-perfect results, use Shift+Cmd+C in Figma to copy as PNG, then paste here.", - ); } catch (err) { - if (import.meta.env.DEV) console.error("[Figma] WASM render failed:", err); + if (import.meta.env.DEV) console.error("[Figma] HTML conversion failed:", err); setFigmaError( - `Figma frame "${frameName}" detected but rendering failed. ` + - "Try Shift+Cmd+C in Figma to copy as PNG, then paste here.", + `Figma frame "${frameName}" detected but conversion failed. ` + + "Try \u21E7\u2318C in Figma to copy as PNG, then paste here.", ); } finally { setFigmaProcessing(false); @@ -120,10 +144,10 @@ export function useFigmaPaste({ handlePaste, addScreenAtCenter }) { return () => document.removeEventListener("paste", onPaste); }, [handlePaste, addScreenAtCenter]); - // Auto-dismiss Figma error after 10 seconds (longer for actionable guidance) + // Auto-dismiss Figma notification after 20 seconds useEffect(() => { if (!figmaError) return; - const timer = setTimeout(() => setFigmaError(null), 10000); + const timer = setTimeout(() => setFigmaError(null), 20000); return () => clearTimeout(timer); }, [figmaError]); diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js new file mode 100644 index 0000000..91846e0 --- /dev/null +++ b/src/utils/figmaToHtml.js @@ -0,0 +1,660 @@ +// Figma node tree → HTML/CSS converter. +// Reverse of mcp-server/src/figma-export/dom-traversal.js: +// that file extracts DOM → Figma nodes; this file converts Figma nodes → HTML. +// +// Input: a node from doc._figFile.pages[].rootNodes[] (parsed by @grida/refig) +// Output: a full HTML document string that can be rendered in an iframe + +// ─── Font fallback map ────────────────────────────────────────────────────── + +const FONT_FALLBACKS = { + "sf pro": "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + "sf pro display": "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + "sf pro text": "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + "sf pro rounded": "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + "sf mono": "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace", + "sf compact": "-apple-system, BlinkMacSystemFont, system-ui, sans-serif", + "new york": "'New York', 'Georgia', 'Times New Roman', serif", + "inter": "'Inter', sans-serif", + "roboto": "'Roboto', sans-serif", + "roboto mono": "'Roboto Mono', monospace", + "open sans": "'Open Sans', sans-serif", + "lato": "'Lato', sans-serif", + "montserrat": "'Montserrat', sans-serif", + "poppins": "'Poppins', sans-serif", + "nunito": "'Nunito', sans-serif", + "raleway": "'Raleway', sans-serif", + "source sans pro": "'Source Sans Pro', sans-serif", + "source code pro": "'Source Code Pro', monospace", + "fira code": "'Fira Code', monospace", + "jetbrains mono": "'JetBrains Mono', monospace", + "helvetica": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "helvetica neue": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "arial": "Arial, Helvetica, sans-serif", + "georgia": "Georgia, 'Times New Roman', serif", + "times new roman": "'Times New Roman', Times, serif", +}; + +function resolveFontFamily(family) { + if (!family) return "sans-serif"; + const key = family.toLowerCase().trim(); + return FONT_FALLBACKS[key] || `'${family}', sans-serif`; +} + +// ─── Color conversion ──────────────────────────────────────────────────────── + +function figmaColorToCss(color, opacity) { + if (!color) return null; + const r = Math.round((color.r ?? 0) * 255); + const g = Math.round((color.g ?? 0) * 255); + const b = Math.round((color.b ?? 0) * 255); + const a = opacity ?? color.a ?? 1; + if (a >= 1) return `rgb(${r}, ${g}, ${b})`; + return `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`; +} + +// ─── Fill conversion ───────────────────────────────────────────────────────── + +function convertFillsToCss(fills) { + if (!fills?.length) return {}; + const styles = {}; + let hasImage = false; + + // Process fills in reverse (Figma draws last fill on top) + for (let i = fills.length - 1; i >= 0; i--) { + const fill = fills[i]; + if (fill.visible === false) continue; + + if (fill.type === "SOLID") { + styles.backgroundColor = figmaColorToCss(fill.color, fill.opacity); + } else if (fill.type === "GRADIENT_LINEAR") { + // Kiwi format uses `stops` + `transform`; REST API uses `gradientStops` + `gradientHandlePositions` + const gradStops = fill.stops ?? fill.gradientStops; + if (gradStops?.length >= 2) { + styles.background = convertGradientToCss(fill); + } + } else if (fill.type === "IMAGE" || fill.type === "image") { + hasImage = true; + } + } + + if (hasImage && !styles.backgroundColor && !styles.background) { + // Image fill with no pixel data — show a subtle placeholder + styles.backgroundColor = "#e8e8e8"; + styles.backgroundImage = + "repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(0,0,0,0.03) 8px, rgba(0,0,0,0.03) 16px)"; + } + + return styles; +} + +function convertGradientToCss(fill) { + // Support both kiwi (`stops`, `transform`) and REST API (`gradientStops`, `gradientHandlePositions`) + const stops = fill.stops ?? fill.gradientStops; + if (!stops?.length || stops.length < 2) return ""; + + // Reconstruct CSS angle from Figma gradient transform. + let angleDeg = 180; + if (fill.transform) { + // Kiwi format: transform object with m00, m10 — direction vector is (m00, m10). + // CSS angle θ = atan2(dx, -dy) where dx=m00, dy=m10. + const dx = fill.transform.m00 ?? 0; + const dy = fill.transform.m10 ?? 0; + angleDeg = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI); + } else if (fill.gradientHandlePositions?.length >= 2) { + // REST API format: array of {x, y} handle positions. + // Handle 0 is the start point, handle 1 is the end point. + const p0 = fill.gradientHandlePositions[0]; + const p1 = fill.gradientHandlePositions[1]; + const dx = p1.x - p0.x; + const dy = p1.y - p0.y; + angleDeg = Math.round((Math.atan2(dx, dy) * 180) / Math.PI); + } + + const colorStops = stops + .map((s) => { + const color = figmaColorToCss(s.color, s.color?.a); + const pos = Math.round(s.position * 100); + return `${color} ${pos}%`; + }) + .join(", "); + + return `linear-gradient(${angleDeg}deg, ${colorStops})`; +} + +// ─── Stroke conversion ────────────────────────────────────────────────────── + +function convertStrokesToCss(strokes, strokeWeight, strokeAlign) { + if (!strokes?.length || !strokeWeight) return {}; + const firstVisible = strokes.find((s) => s.visible !== false && s.type === "SOLID"); + if (!firstVisible) return {}; + + const color = figmaColorToCss(firstVisible.color, firstVisible.opacity); + const styles = {}; + + if (strokeAlign === "INSIDE") { + // Use outline or box-shadow inset to avoid affecting layout + styles.boxShadow = `inset 0 0 0 ${strokeWeight}px ${color}`; + } else { + styles.border = `${strokeWeight}px solid ${color}`; + } + + return styles; +} + +// ─── Effect conversion (box-shadow) ────────────────────────────────────────── + +function convertEffectsToCss(effects) { + if (!effects?.length) return {}; + const shadows = []; + + for (const e of effects) { + if (e.visible === false) continue; + const color = figmaColorToCss(e.color, e.color?.a); + if (!color) continue; + + const ox = e.offset?.x ?? 0; + const oy = e.offset?.y ?? 0; + const radius = e.radius ?? 0; + const spread = e.spread ?? 0; + + if (e.type === "DROP_SHADOW") { + shadows.push(`${ox}px ${oy}px ${radius}px ${spread}px ${color}`); + } else if (e.type === "INNER_SHADOW") { + shadows.push(`inset ${ox}px ${oy}px ${radius}px ${spread}px ${color}`); + } + } + + if (shadows.length === 0) return {}; + return { boxShadow: shadows.join(", ") }; +} + +// ─── Corner radius ─────────────────────────────────────────────────────────── + +function convertCornerRadiusToCss(node) { + // Check for independent corner radii + const tl = node.rectangleTopLeftCornerRadius; + const tr = node.rectangleTopRightCornerRadius; + const br = node.rectangleBottomRightCornerRadius; + const bl = node.rectangleBottomLeftCornerRadius; + + if (tl != null || tr != null || br != null || bl != null) { + return { borderRadius: `${tl || 0}px ${tr || 0}px ${br || 0}px ${bl || 0}px` }; + } + + const r = node.cornerRadius; + if (r && r > 0) return { borderRadius: `${r}px` }; + + // Also check rectangleCornerRadii array (intermediate format) + if (node.rectangleCornerRadii) { + const [rtl, rtr, rbr, rbl] = node.rectangleCornerRadii; + return { borderRadius: `${rtl}px ${rtr}px ${rbr}px ${rbl}px` }; + } + + return {}; +} + +// ─── Font style derivation ─────────────────────────────────────────────────── + +function deriveFontWeight(styleStr) { + if (!styleStr) return 400; + const s = styleStr.toLowerCase(); + if (s.includes("thin") || s.includes("hairline")) return 100; + if (s.includes("extralight") || s.includes("ultralight")) return 200; + if (s.includes("light")) return 300; + if (s.includes("medium")) return 500; + if (s.includes("semibold") || s.includes("demibold")) return 600; + if (s.includes("extrabold") || s.includes("ultrabold")) return 800; + if (s.includes("black") || s.includes("heavy")) return 900; + if (s.includes("bold")) return 700; + return 400; +} + +function isItalicStyle(styleStr) { + if (!styleStr) return false; + return styleStr.toLowerCase().includes("italic"); +} + +// ─── Layout mapping ───────────────────────────────────────────────────────── + +function mapPrimaryAlign(value) { + switch (value) { + case "CENTER": return "center"; + case "MAX": return "flex-end"; + case "SPACE_BETWEEN": return "space-between"; + case "SPACE_EVENLY": case "SPACE_AROUND": return "space-evenly"; + default: return "flex-start"; + } +} + +function mapCounterAlign(value) { + switch (value) { + case "CENTER": return "center"; + case "MAX": return "flex-end"; + case "BASELINE": return "baseline"; + case "STRETCH": return "stretch"; + default: return "flex-start"; + } +} + +function mapTextAlign(value) { + switch (value) { + case "CENTER": return "center"; + case "RIGHT": return "right"; + case "JUSTIFIED": return "justify"; + default: return "left"; + } +} + +// ─── Escape HTML ──────────────────────────────────────────────────────────── + +function escapeHtml(str) { + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// ─── Style object to inline CSS string ────────────────────────────────────── + +function stylesToString(styles) { + return Object.entries(styles) + .filter(([, v]) => v != null && v !== "") + .map(([k, v]) => { + // Convert camelCase to kebab-case + const prop = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + return `${prop}: ${v}`; + }) + .join("; "); +} + +// ─── Get node dimensions ──────────────────────────────────────────────────── + +function getNodeSize(node) { + const w = node.size?.x ?? node.width ?? 0; + const h = node.size?.y ?? node.height ?? 0; + return { width: w, height: h }; +} + +function getNodePosition(node) { + // Position from kiwi transform object, or REST relativeTransform 2D array, or direct x/y + const x = node.transform?.m02 ?? node.relativeTransform?.[0]?.[2] ?? node.x ?? 0; + const y = node.transform?.m12 ?? node.relativeTransform?.[1]?.[2] ?? node.y ?? 0; + return { x, y }; +} + +// ─── Node type detection ──────────────────────────────────────────────────── + +function isTextNode(node) { + return node.type === "TEXT"; +} + +function isContainerNode(node) { + return ["FRAME", "GROUP", "COMPONENT", "COMPONENT_SET", "INSTANCE", "SECTION"].includes(node.type); +} + +function hasAutoLayout(node) { + const mode = node.stackMode ?? node.layoutMode; + return mode === "HORIZONTAL" || mode === "VERTICAL"; +} + +// ─── Main converter ───────────────────────────────────────────────────────── + +function convertNode(node, isRoot) { + if (!node) return ""; + if (node.visible === false) return ""; + + const { width, height } = getNodeSize(node); + if (width < 1 && height < 1) return ""; + + if (isTextNode(node)) { + return convertTextNode(node, isRoot); + } + + if (isContainerNode(node) || node.type === "RECTANGLE" || node.type === "ROUNDED_RECTANGLE") { + return convertFrameNode(node, isRoot); + } + + // VECTOR, LINE, ELLIPSE, STAR, POLYGON, BOOLEAN_OPERATION + return convertShapeNode(node, isRoot); +} + +function convertTextNode(node, isRoot) { + const { width, height } = getNodeSize(node); + const styles = {}; + + if (!isRoot) { + styles.width = `${Math.ceil(width)}px`; + styles.minHeight = `${Math.ceil(height)}px`; + } + + // Font properties — check both kiwi format and intermediate format + const fontName = node.fontName; + const styleObj = node.style; // intermediate format (from dom-traversal) + + const fontFamily = fontName?.family ?? styleObj?.fontFamily ?? "Inter"; + const fontStyleStr = fontName?.style ?? ""; + const fontSize = node.fontSize ?? styleObj?.fontSize ?? 16; + const fontWeight = styleObj?.fontWeight ?? deriveFontWeight(fontStyleStr); + const italic = styleObj?.italic ?? isItalicStyle(fontStyleStr); + + styles.fontFamily = resolveFontFamily(fontFamily); + styles.fontSize = `${fontSize}px`; + styles.fontWeight = fontWeight; + if (italic) styles.fontStyle = "italic"; + + // Line height + const lineHeight = node.lineHeight ?? styleObj?.lineHeightPx; + if (lineHeight?.value) { + styles.lineHeight = `${lineHeight.value}px`; + } else if (typeof lineHeight === "number" && lineHeight > 0) { + styles.lineHeight = `${lineHeight}px`; + } + + // Letter spacing + const letterSpacing = node.letterSpacing ?? styleObj?.letterSpacing; + if (letterSpacing?.value && letterSpacing.value !== 0) { + styles.letterSpacing = `${letterSpacing.value}px`; + } else if (typeof letterSpacing === "number" && letterSpacing !== 0) { + styles.letterSpacing = `${letterSpacing}px`; + } + + // Text align + const hAlign = node.textAlignHorizontal ?? styleObj?.textAlignHorizontal ?? "LEFT"; + styles.textAlign = mapTextAlign(hAlign); + + // Text decoration + const decoration = node.textDecoration ?? styleObj?.textDecoration; + if (decoration === "UNDERLINE") styles.textDecoration = "underline"; + else if (decoration === "STRIKETHROUGH") styles.textDecoration = "line-through"; + + // Text color from fills + const fills = node.fillPaints ?? styleObj?.fills ?? node.fills; + if (fills?.length) { + const solidFill = fills.find((f) => f.type === "SOLID" && f.visible !== false); + if (solidFill) { + styles.color = figmaColorToCss(solidFill.color, solidFill.opacity); + } + } + + // Opacity + const opacity = node.opacity; + if (opacity != null && opacity < 1) { + styles.opacity = opacity.toFixed(3); + } + + // Auto-layout child sizing + if (node.stackChildAlignSelf === "STRETCH") { + styles.alignSelf = "stretch"; + styles.width = "100%"; + } + if (node.stackChildPrimaryGrow > 0) { + styles.flexGrow = node.stackChildPrimaryGrow; + } + + const characters = node.textData?.characters ?? node.characters ?? ""; + const inlineStyle = stylesToString(styles); + return `
${escapeHtml(characters)}
`; +} + +function convertFrameNode(node, isRoot) { + const { width, height } = getNodeSize(node); + const styles = {}; + const autoLayout = hasAutoLayout(node); + + // Dimensions + if (isRoot) { + styles.width = "100%"; + styles.minHeight = "100%"; + } else { + styles.width = `${Math.ceil(width)}px`; + styles.minHeight = `${Math.ceil(height)}px`; + } + + // Box sizing + styles.boxSizing = "border-box"; + + // Background fills + const fills = node.fillPaints ?? node.fills; + Object.assign(styles, convertFillsToCss(fills)); + + // Strokes + const strokes = node.strokePaints ?? node.strokes; + const strokeWeight = node.strokeWeight ?? 0; + const strokeAlign = node.strokeAlign ?? "INSIDE"; + Object.assign(styles, convertStrokesToCss(strokes, strokeWeight, strokeAlign)); + + // Effects + const effects = node.effects; + if (effects) { + const effectCss = convertEffectsToCss(effects); + // Merge box-shadow from effects with box-shadow from strokes + if (effectCss.boxShadow && styles.boxShadow) { + styles.boxShadow = `${styles.boxShadow}, ${effectCss.boxShadow}`; + } else { + Object.assign(styles, effectCss); + } + } + + // Corner radius + Object.assign(styles, convertCornerRadiusToCss(node)); + + // Opacity + const opacity = node.opacity; + if (opacity != null && opacity < 1) { + styles.opacity = opacity.toFixed(3); + } + + // Overflow + const clipsContent = node.clipsContent ?? (node.frameMaskDisabled === false); + if (clipsContent) { + styles.overflow = "hidden"; + } + + // Layout + const stackMode = node.stackMode ?? node.layoutMode; + if (autoLayout) { + styles.display = "flex"; + styles.flexDirection = stackMode === "HORIZONTAL" ? "row" : "column"; + + // Gap + const gap = node.stackSpacing ?? node.itemSpacing ?? 0; + if (gap > 0) styles.gap = `${gap}px`; + + // Padding — kiwi format uses stackHorizontalPadding/stackVerticalPadding, plus + // stackPaddingRight/stackPaddingBottom for independent padding + const pl = node.stackHorizontalPadding ?? node.paddingLeft ?? 0; + const pt = node.stackVerticalPadding ?? node.paddingTop ?? 0; + const pr = node.stackPaddingRight ?? node.paddingRight ?? pl; + const pb = node.stackPaddingBottom ?? node.paddingBottom ?? pt; + if (pl || pt || pr || pb) { + styles.padding = `${pt}px ${pr}px ${pb}px ${pl}px`; + } + + // Alignment + const primaryAlign = node.stackPrimaryAlignItems ?? node.primaryAxisAlignItems; + const counterAlign = node.stackCounterAlignItems ?? node.counterAxisAlignItems; + styles.justifyContent = mapPrimaryAlign(primaryAlign); + styles.alignItems = mapCounterAlign(counterAlign); + } else if (node.children?.length) { + // Non-auto-layout: children positioned absolutely + styles.position = "relative"; + } + + // Auto-layout child sizing + if (node.stackChildAlignSelf === "STRETCH") { + styles.alignSelf = "stretch"; + styles.width = "100%"; + } + if (node.stackChildPrimaryGrow > 0) { + styles.flexGrow = node.stackChildPrimaryGrow; + } + + // Render children + const childrenHtml = (node.children || []) + .map((child) => { + if (!autoLayout) { + // Absolute positioning for non-auto-layout children + const childPos = getNodePosition(child); + const wrapStyle = `position: absolute; left: ${childPos.x}px; top: ${childPos.y}px`; + const childHtml = convertNode(child, false); + if (!childHtml) return ""; + return `
${childHtml}
`; + } + return convertNode(child, false); + }) + .filter(Boolean) + .join("\n"); + + const inlineStyle = stylesToString(styles); + return `
\n${childrenHtml}\n
`; +} + +function convertShapeNode(node) { + const { width, height } = getNodeSize(node); + const styles = {}; + + styles.width = `${Math.ceil(width)}px`; + styles.height = `${Math.ceil(height)}px`; + styles.boxSizing = "border-box"; + + // Background fills + const fills = node.fillPaints ?? node.fills; + Object.assign(styles, convertFillsToCss(fills)); + + // Strokes + const strokes = node.strokePaints ?? node.strokes; + const strokeWeight = node.strokeWeight ?? 0; + const strokeAlign = node.strokeAlign ?? "INSIDE"; + Object.assign(styles, convertStrokesToCss(strokes, strokeWeight, strokeAlign)); + + // Corner radius + Object.assign(styles, convertCornerRadiusToCss(node)); + + // Ellipse → border-radius: 50% + if (node.type === "ELLIPSE") { + styles.borderRadius = "50%"; + } + + // LINE → thin horizontal/vertical element + if (node.type === "LINE") { + if (height <= 1) styles.height = "1px"; + if (width <= 1) styles.width = "1px"; + } + + // Opacity + const opacity = node.opacity; + if (opacity != null && opacity < 1) { + styles.opacity = opacity.toFixed(3); + } + + // Effects + const effects = node.effects; + if (effects) Object.assign(styles, convertEffectsToCss(effects)); + + const inlineStyle = stylesToString(styles); + return `
`; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Convert a Figma node tree (from _figFile) to a full HTML document string. + * + * @param {Object} node - Root node from doc._figFile.pages[].rootNodes[] + * @param {Object} [options] + * @param {number} [options.width] - Override viewport width + * @param {number} [options.height] - Override viewport height + * @returns {string} Full HTML document + */ +export function figmaNodeToHtml(node, options = {}) { + const { width: w, height: h } = getNodeSize(node); + const width = options.width || w || 393; + const height = options.height || h || 852; + + const bodyHtml = convertNode(node, true); + + return ` + + + + + + + +${bodyHtml} + +`; +} + +/** + * Render HTML in a hidden iframe and capture it as a PNG data URL. + * + * @param {string} html - Full HTML document to render + * @param {number} width - Viewport width in CSS pixels + * @param {number} height - Viewport height in CSS pixels + * @param {number} [scale=2] - Device pixel ratio for retina + * @returns {Promise} data:image/png;base64,... URL + */ +// eslint-disable-next-line no-unused-vars +export async function renderHtmlToImage(html, width, height, scale = 2) { + const iframe = document.createElement("iframe"); + iframe.style.cssText = + `position:fixed;left:-9999px;top:-9999px;` + + `width:${width}px;height:${height}px;` + + `border:none;visibility:hidden;pointer-events:none;`; + document.body.appendChild(iframe); + + try { + const doc = iframe.contentDocument || iframe.contentWindow.document; + doc.open(); + doc.write(html); + doc.close(); + + // Wait for layout to stabilize + await new Promise((resolve) => { + const deadline = setTimeout(resolve, 2000); + const iwin = iframe.contentWindow; + + function check() { + const body = doc.body; + if (body && body.children.length > 0) { + const rect = body.children[0].getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + clearTimeout(deadline); + resolve(); + return; + } + } + iwin.requestAnimationFrame(check); + } + + setTimeout(() => iwin.requestAnimationFrame(check), 50); + }); + + // Capture to canvas using SVG foreignObject + const svgNs = "http://www.w3.org/2000/svg"; + // eslint-disable-next-line no-undef + const serializedHtml = new XMLSerializer().serializeToString(doc.documentElement); + + const svgString = + `` + + `` + + `${serializedHtml.replace(/]*>/, "").replace(/<\/html>$/, "")}` + + ``; + + // SVG foreignObject always taints the canvas (browser security restriction), + // so we return the SVG as a base64 data URL directly — it works as an src. + const svgBase64 = btoa(unescape(encodeURIComponent(svgString))); + const imageDataUrl = `data:image/svg+xml;base64,${svgBase64}`; + + return imageDataUrl; + } finally { + document.body.removeChild(iframe); + } +} diff --git a/src/utils/figmaToHtml.test.js b/src/utils/figmaToHtml.test.js new file mode 100644 index 0000000..ecb7abc --- /dev/null +++ b/src/utils/figmaToHtml.test.js @@ -0,0 +1,242 @@ +import { describe, it, expect } from "vitest"; +import { figmaNodeToHtml } from "./figmaToHtml.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Build a minimal FRAME node in _figFile format (REST API property names). */ +function makeFrame(overrides = {}) { + return { + type: "FRAME", + name: "Frame", + id: "1:1", + visible: true, + opacity: 1, + size: { x: 390, y: 844 }, + relativeTransform: [[1, 0, 0], [0, 1, 0]], + fills: [], + strokes: [], + strokeWeight: 0, + strokeAlign: "INSIDE", + cornerRadius: 0, + clipsContent: false, + effects: [], + children: [], + ...overrides, + }; +} + +/** Build a minimal TEXT node in _figFile format. */ +function makeText(characters, overrides = {}) { + return { + type: "TEXT", + name: characters, + id: "1:99", + visible: true, + opacity: 1, + size: { x: 200, y: 24 }, + relativeTransform: [[1, 0, 10], [0, 1, 20]], + fills: [{ type: "SOLID", visible: true, opacity: 1, blendMode: "NORMAL", color: { r: 1, g: 1, b: 1, a: 1 } }], + strokes: [], + strokeWeight: 0, + strokeAlign: "OUTSIDE", + characters, + style: { + fontFamily: "Inter", + fontWeight: 400, + italic: false, + fontSize: 16, + textAlignHorizontal: "LEFT", + textAlignVertical: "CENTER", + letterSpacing: 0, + lineHeightPx: 24, + textDecoration: "NONE", + }, + effects: [], + ...overrides, + }; +} + +// ─── Auto-layout (kiwi props augmented by parseFigmaClipboard) ─────────────── + +describe("figmaNodeToHtml — auto-layout", () => { + it("converts a VERTICAL auto-layout frame to flex column", () => { + const node = makeFrame({ + stackMode: "VERTICAL", + stackSpacing: 16, + stackHorizontalPadding: 24, + stackVerticalPadding: 32, + stackPaddingRight: 24, + stackPaddingBottom: 48, + stackPrimaryAlignItems: "CENTER", + stackCounterAlignItems: "CENTER", + fills: [{ type: "SOLID", visible: true, opacity: 1, color: { r: 0, g: 0, b: 0, a: 1 } }], + children: [makeText("Hello")], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("flex-direction: column"); + expect(html).toContain("gap: 16px"); + expect(html).toContain("padding: 32px 24px 48px 24px"); + expect(html).toContain("justify-content: center"); + expect(html).toContain("align-items: center"); + expect(html).toContain("display: flex"); + }); + + it("converts a HORIZONTAL auto-layout frame to flex row", () => { + const node = makeFrame({ + stackMode: "HORIZONTAL", + stackSpacing: 8, + children: [makeText("A"), makeText("B")], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("flex-direction: row"); + expect(html).toContain("gap: 8px"); + }); + + it("applies stackChildAlignSelf STRETCH to children", () => { + const child = makeText("Stretch me", { stackChildAlignSelf: "STRETCH" }); + const node = makeFrame({ + stackMode: "VERTICAL", + children: [child], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("align-self: stretch"); + expect(html).toContain("width: 100%"); + }); + + it("applies stackChildPrimaryGrow to children", () => { + const child = makeFrame({ + name: "Spacer", + size: { x: 100, y: 10 }, + stackChildPrimaryGrow: 1, + }); + const node = makeFrame({ + stackMode: "VERTICAL", + children: [child], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("flex-grow: 1"); + }); +}); + +// ─── Position extraction from relativeTransform ────────────────────────────── + +describe("figmaNodeToHtml — position", () => { + it("positions children absolutely using relativeTransform when no auto-layout", () => { + const child = makeFrame({ + name: "Box", + size: { x: 100, y: 50 }, + relativeTransform: [[1, 0, 30], [0, 1, 60]], + }); + const node = makeFrame({ children: [child] }); + const html = figmaNodeToHtml(node); + expect(html).toContain("left: 30px"); + expect(html).toContain("top: 60px"); + }); +}); + +// ─── Gradient fills ────────────────────────────────────────────────────────── + +describe("figmaNodeToHtml — gradients", () => { + it("handles kiwi gradient format (stops + transform)", () => { + const node = makeFrame({ + fills: [{ + type: "GRADIENT_LINEAR", + visible: true, + opacity: 1, + stops: [ + { color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 }, + { color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 }, + ], + transform: { m00: 0, m01: 0, m02: 0.5, m10: -1, m11: 0, m12: 1 }, + }], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("linear-gradient"); + expect(html).toContain("rgb(255, 0, 0)"); + expect(html).toContain("rgb(0, 0, 255)"); + }); + + it("handles REST API gradient format (gradientStops + gradientHandlePositions)", () => { + const node = makeFrame({ + fills: [{ + type: "GRADIENT_LINEAR", + visible: true, + opacity: 1, + gradientStops: [ + { color: { r: 0.71, g: 0.63, b: 1, a: 1 }, position: 0 }, + { color: { r: 0.49, g: 0.32, b: 1, a: 1 }, position: 1 }, + ], + gradientHandlePositions: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + ], + }], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("linear-gradient"); + expect(html).toMatch(/rgb\(/); + expect(html).toContain("0%"); + expect(html).toContain("100%"); + }); +}); + +// ─── Text nodes ────────────────────────────────────────────────────────────── + +describe("figmaNodeToHtml — text", () => { + it("renders text with font properties from style object", () => { + const node = makeFrame({ + children: [ + makeText("Hello World", { + style: { + fontFamily: "Manrope", + fontWeight: 800, + italic: false, + fontSize: 36, + textAlignHorizontal: "LEFT", + letterSpacing: -0.9, + lineHeightPx: 40, + textDecoration: "NONE", + }, + }), + ], + stackMode: "VERTICAL", + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("Hello World"); + expect(html).toContain("font-size: 36px"); + expect(html).toContain("font-weight: 800"); + expect(html).toContain("line-height: 40px"); + expect(html).toContain("letter-spacing: -0.9px"); + }); + + it("uses text color from fills", () => { + const node = makeFrame({ + children: [makeText("Colored text")], + stackMode: "VERTICAL", + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("color: rgb(255, 255, 255)"); + }); +}); + +// ─── Solid fills and corner radius ─────────────────────────────────────────── + +describe("figmaNodeToHtml — fills and corners", () => { + it("renders solid fill background", () => { + const node = makeFrame({ + fills: [{ type: "SOLID", visible: true, opacity: 1, color: { r: 0.05, g: 0.05, b: 0.05, a: 1 } }], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("background-color: rgb(13, 13, 13)"); + }); + + it("renders corner radius from rectangleCornerRadii array", () => { + const node = makeFrame({ + cornerRadius: 0, + rectangleCornerRadii: [12, 12, 0, 0], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("border-radius: 12px 12px 0px 0px"); + }); +}); diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js index 11df6b6..f946cec 100644 --- a/src/utils/parseFigmaClipboard.js +++ b/src/utils/parseFigmaClipboard.js @@ -3,6 +3,7 @@ import { FigmaDocument, FigmaRenderer } from "@grida/refig/browser"; // iofigma is exported from the chunk but not re-exported from @grida/refig/browser. // Pin the dependency version to keep this import stable. import { iofigma } from "@grida/refig/dist/chunk-INJ5F2RK.mjs"; +import { figmaNodeToHtml, renderHtmlToImage } from "./figmaToHtml"; // --------------------------------------------------------------------------- // Shared-component capture: monkey-patch factory.node to intercept @@ -12,6 +13,25 @@ import { iofigma } from "@grida/refig/dist/chunk-INJ5F2RK.mjs"; const origFactoryNode = iofigma.kiwi.factory.node; let captureState = null; +// Auto-layout property names that exist on raw kiwi nodeChanges but are +// dropped by the factory's REST-API conversion. We copy them onto the +// factory output so figmaToHtml.js can read them directly. +const KIWI_LAYOUT_PROPS = [ + "stackMode", + "stackSpacing", + "stackHorizontalPadding", + "stackVerticalPadding", + "stackPaddingRight", + "stackPaddingBottom", + "stackPrimaryAlignItems", + "stackCounterAlignItems", + "stackChildAlignSelf", + "stackChildPrimaryGrow", + "stackPositioning", + "stackCounterSizing", + "stackPrimarySizing", +]; + iofigma.kiwi.factory.node = function (nc, message) { if (captureState) { captureState.message = message; @@ -19,7 +39,19 @@ iofigma.kiwi.factory.node = function (nc, message) { captureState.derived.set(iofigma.kiwi.guid(nc.guid), nc); } } - return origFactoryNode.call(this, nc, message); + const node = origFactoryNode.call(this, nc, message); + + // Augment with auto-layout properties from the raw kiwi nodeChange. + // The factory converts to REST API format but drops all layout props. + if (node) { + for (const prop of KIWI_LAYOUT_PROPS) { + if (nc[prop] != null) { + node[prop] = nc[prop]; + } + } + } + + return node; }; function beginCapture() { @@ -332,3 +364,66 @@ export async function renderFigmaBuffer(buffer) { return { frameName: firstFrame.name, imageDataUrl, frameCount: frames.length }; } + +// --------------------------------------------------------------------------- +// HTML-based rendering: convert Figma node tree → HTML → PNG. +// This bypasses the WASM renderer entirely, producing higher-fidelity output +// by leveraging the browser's own layout and text rendering. +// --------------------------------------------------------------------------- + +/** + * Convert a parsed Figma frame to an HTML document string. + * + * @param {FigmaDocument} doc - parsed Figma document (from parseFigmaFrames) + * @param {string} frameId - the frame node ID to convert + * @returns {{ html: string, frameName: string, width: number, height: number } | null} + */ +export function figmaFrameToHtml(doc, frameId) { + const node = findNodeInFigFile(doc._figFile, frameId); + if (!node) return null; + + const width = node.size?.x ?? 393; + const height = node.size?.y ?? 852; + const html = figmaNodeToHtml(node, { width, height }); + + return { + html, + frameName: node.name || "Figma Frame", + width, + height, + }; +} + +/** + * High-level entry point: parse a Figma clipboard buffer, convert each frame + * to HTML, and render to PNG using the browser's own rendering engine. + * + * @param {Uint8Array} buffer - decoded fig-kiwi binary from clipboard + * @returns {Promise>} + */ +export async function convertFigmaBuffer(buffer) { + const { frames, document: doc } = parseFigmaFrames(buffer); + if (frames.length === 0) throw new Error("No frames found in Figma clipboard data"); + + const results = []; + for (const frame of frames) { + const converted = figmaFrameToHtml(doc, frame.id); + if (!converted) continue; + + const imageDataUrl = await renderHtmlToImage( + converted.html, + Math.ceil(converted.width), + Math.ceil(converted.height), + ); + + results.push({ + frameName: converted.frameName, + imageDataUrl, + html: converted.html, + width: converted.width, + height: converted.height, + }); + } + + return results; +} From e3e87b901f1ccd15550f3ab0359b6c8ebc8f21af Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:00:34 +0700 Subject: [PATCH 2/2] fix: add SVG rendering support for vector nodes and z-index handling for absolute-positioned children --- src/utils/figmaToHtml.js | 116 ++++++++++++++++++++-- src/utils/figmaToHtml.test.js | 129 ++++++++++++++++++++++++ src/utils/parseFigmaClipboard.js | 165 +++++++++++++++++++++++-------- 3 files changed, 360 insertions(+), 50 deletions(-) diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js index 91846e0..bb6bccd 100644 --- a/src/utils/figmaToHtml.js +++ b/src/utils/figmaToHtml.js @@ -300,6 +300,62 @@ function hasAutoLayout(node) { return mode === "HORIZONTAL" || mode === "VERTICAL"; } +// ─── Vector network → SVG path ────────────────────────────────────────────── + +/** + * Convert a Figma vectorNetwork (vertices + segments) to an SVG path `d` string. + * Mirrors the logic of @grida/refig's vn.toSVGPathData but without importing + * the private module-scoped variable. + */ +function vectorNetworkToSvgPath(network) { + const { vertices, segments } = network; + if (!segments?.length || !vertices?.length) return ""; + + const parts = []; + let currentStart = null; + let previousEnd = null; + + for (const seg of segments) { + const { a, b, ta, tb } = seg; + const start = vertices[a]; + const end = vertices[b]; + if (!start || !end) continue; + + if (previousEnd !== a) { + parts.push(`M${fmt(start[0])} ${fmt(start[1])}`); + currentStart = a; + } + + const noTangents = + (ta[0] === 0 && ta[1] === 0 && tb[0] === 0 && tb[1] === 0); + + if (noTangents) { + parts.push(`L${fmt(end[0])} ${fmt(end[1])}`); + } else { + const c1x = start[0] + ta[0]; + const c1y = start[1] + ta[1]; + const c2x = end[0] + tb[0]; + const c2y = end[1] + tb[1]; + parts.push( + `C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end[0])} ${fmt(end[1])}` + ); + } + + previousEnd = b; + if (currentStart !== null && b === currentStart) { + parts.push("Z"); + previousEnd = null; + currentStart = null; + } + } + + return parts.join(""); +} + +function fmt(n) { + return Math.round(n * 100) / 100; +} + // ─── Main converter ───────────────────────────────────────────────────────── function convertNode(node, isRoot) { @@ -494,11 +550,11 @@ function convertFrameNode(node, isRoot) { // Render children const childrenHtml = (node.children || []) - .map((child) => { + .map((child, i) => { if (!autoLayout) { // Absolute positioning for non-auto-layout children const childPos = getNodePosition(child); - const wrapStyle = `position: absolute; left: ${childPos.x}px; top: ${childPos.y}px`; + const wrapStyle = `position: absolute; left: ${childPos.x}px; top: ${childPos.y}px; z-index: ${i}`; const childHtml = convertNode(child, false); if (!childHtml) return ""; return `
${childHtml}
`; @@ -514,10 +570,56 @@ function convertFrameNode(node, isRoot) { function convertShapeNode(node) { const { width, height } = getNodeSize(node); - const styles = {}; + const w = Math.ceil(width); + const h = Math.ceil(height); + + // Try to render as inline SVG from vectorNetwork path data + const svgPath = node.vectorNetwork + ? vectorNetworkToSvgPath(node.vectorNetwork) + : ""; + + if (svgPath) { + const styles = {}; + styles.width = `${w}px`; + styles.height = `${h}px`; + styles.flexShrink = "0"; + + // Opacity + const opacity = node.opacity; + if (opacity != null && opacity < 1) { + styles.opacity = opacity.toFixed(3); + } + + // Fill color + const fills = node.fillPaints ?? node.fills; + let fillColor = "currentColor"; + if (fills?.length) { + const solidFill = fills.find((f) => f.type === "SOLID" && f.visible !== false); + if (solidFill) { + fillColor = figmaColorToCss(solidFill.color, solidFill.opacity); + } + } - styles.width = `${Math.ceil(width)}px`; - styles.height = `${Math.ceil(height)}px`; + // Stroke color + const strokes = node.strokePaints ?? node.strokes; + const strokeWeight = node.strokeWeight ?? 0; + let strokeAttr = ""; + if (strokes?.length && strokeWeight > 0) { + const solidStroke = strokes.find((s) => s.type === "SOLID" && s.visible !== false); + if (solidStroke) { + const strokeColor = figmaColorToCss(solidStroke.color, solidStroke.opacity); + strokeAttr = ` stroke="${strokeColor}" stroke-width="${strokeWeight}"`; + } + } + + const wrapStyle = stylesToString(styles); + return `
`; + } + + // Fallback: render as a colored div (no vector path data available) + const styles = {}; + styles.width = `${w}px`; + styles.height = `${h}px`; styles.boxSizing = "border-box"; // Background fills @@ -540,8 +642,8 @@ function convertShapeNode(node) { // LINE → thin horizontal/vertical element if (node.type === "LINE") { - if (height <= 1) styles.height = "1px"; - if (width <= 1) styles.width = "1px"; + if (h <= 1) styles.height = "1px"; + if (w <= 1) styles.width = "1px"; } // Opacity diff --git a/src/utils/figmaToHtml.test.js b/src/utils/figmaToHtml.test.js index ecb7abc..92b6c28 100644 --- a/src/utils/figmaToHtml.test.js +++ b/src/utils/figmaToHtml.test.js @@ -240,3 +240,132 @@ describe("figmaNodeToHtml — fills and corners", () => { expect(html).toContain("border-radius: 12px 12px 0px 0px"); }); }); + +// ─── Vector / shape SVG rendering ──────────────────────────────────────────── + +/** Build a minimal VECTOR node with optional vectorNetwork. */ +function makeVector(overrides = {}) { + return { + type: "VECTOR", + name: "Vector", + id: "1:50", + visible: true, + opacity: 1, + size: { x: 24, y: 24 }, + relativeTransform: [[1, 0, 0], [0, 1, 0]], + fills: [{ type: "SOLID", visible: true, opacity: 1, color: { r: 0, g: 0, b: 0, a: 1 } }], + strokes: [], + strokeWeight: 0, + strokeAlign: "INSIDE", + cornerRadius: 0, + effects: [], + ...overrides, + }; +} + +describe("figmaNodeToHtml — vector SVG rendering", () => { + it("renders a vector node with vectorNetwork as inline SVG", () => { + const node = makeFrame({ + children: [ + makeVector({ + vectorNetwork: { + vertices: [[0, 12], [12, 0], [24, 12], [12, 24]], + segments: [ + { a: 0, b: 1, ta: [0, 0], tb: [0, 0] }, + { a: 1, b: 2, ta: [0, 0], tb: [0, 0] }, + { a: 2, b: 3, ta: [0, 0], tb: [0, 0] }, + { a: 3, b: 0, ta: [0, 0], tb: [0, 0] }, + ], + }, + }), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain(" { + const node = makeFrame({ + children: [ + makeVector({ + vectorNetwork: { + vertices: [[0, 12], [24, 12]], + segments: [ + { a: 0, b: 1, ta: [6, -8], tb: [-6, -8] }, + ], + }, + }), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain(" { + const node = makeFrame({ + children: [ + makeVector({ + fills: [{ type: "SOLID", visible: true, opacity: 1, color: { r: 1, g: 0, b: 0, a: 1 } }], + }), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).not.toContain(" { + const node = makeFrame({ + children: [ + makeVector({ + vectorNetwork: { + vertices: [[0, 0], [24, 24]], + segments: [{ a: 0, b: 1, ta: [0, 0], tb: [0, 0] }], + }, + fills: [], + strokes: [{ type: "SOLID", visible: true, opacity: 1, color: { r: 0, g: 0, b: 1, a: 1 } }], + strokeWeight: 2, + }), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain('stroke="rgb(0, 0, 255)"'); + expect(html).toContain('stroke-width="2"'); + }); +}); + +// ─── Z-index for absolute-positioned children ──────────────────────────────── + +describe("figmaNodeToHtml — z-index stacking", () => { + it("assigns incremental z-index to non-auto-layout children", () => { + const node = makeFrame({ + children: [ + makeText("Behind", { relativeTransform: [[1, 0, 0], [0, 1, 0]] }), + makeText("In front", { relativeTransform: [[1, 0, 0], [0, 1, 50]] }), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).toContain("z-index: 0"); + expect(html).toContain("z-index: 1"); + }); + + it("does NOT add z-index to auto-layout children", () => { + const node = makeFrame({ + stackMode: "VERTICAL", + children: [ + makeText("A"), + makeText("B"), + ], + }); + const html = figmaNodeToHtml(node); + expect(html).not.toContain("z-index"); + }); +}); diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js index f946cec..5d3c0c3 100644 --- a/src/utils/parseFigmaClipboard.js +++ b/src/utils/parseFigmaClipboard.js @@ -88,61 +88,140 @@ function findNodeInFigFile(figFile, nodeId) { return null; } -function resolveSharedComponents(doc, captured) { - if (!captured?.derived?.size) return 0; +/** + * Build a REST-like node tree from an array of raw kiwi nodeChanges. + * Uses the monkey-patched factory so auto-layout / vector props are preserved. + * + * @param {Array} derivedNCs - raw kiwi nodeChanges (e.g. from derivedSymbolData) + * @param {object} message - the kiwi message object (needed for blob decoding) + * @param {string} parentGuid - guid of the logical parent (for root-child detection) + * @returns {{ rootChildren: Array, guidToNode: Map, guidToKiwi: Map }} + */ +function buildDerivedTree(derivedNCs, message, parentGuid) { + // Use the patched factory (preserves auto-layout props + vector data) + const nodes = derivedNCs + .map((nc) => iofigma.kiwi.factory.node(nc, message)) + .filter(Boolean); + + const guidToNode = new Map(); + nodes.forEach((n) => guidToNode.set(n.id, n)); + + const guidToKiwi = new Map(); + derivedNCs.forEach((nc) => { + if (nc.guid) guidToKiwi.set(iofigma.kiwi.guid(nc.guid), nc); + }); - const { derived, message } = captured; - let patchCount = 0; + // Build parent-child relationships (mirrors buildChildrenRelationsInPlace) + nodes.forEach((node) => { + const kiwi = guidToKiwi.get(node.id); + if (!kiwi?.parentIndex?.guid) return; + const pGuid = iofigma.kiwi.guid(kiwi.parentIndex.guid); + const parent = guidToNode.get(pGuid); + if (parent && "children" in parent) { + if (!parent.children) parent.children = []; + parent.children.push(node); + } + }); - for (const [instanceGuid, kiwiInstance] of derived) { - const derivedNCs = kiwiInstance.derivedSymbolData; + // Sort children by fractional position index + guidToNode.forEach((parent) => { + if (!parent.children?.length) return; + parent.children.sort((a, b) => { + const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? ""; + const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? ""; + return aPos.localeCompare(bPos); + }); + }); - // Convert derived nodeChanges to REST-like nodes using the same factory - const nodes = derivedNCs - .map((nc) => origFactoryNode(nc, message)) - .filter(Boolean); - if (nodes.length === 0) continue; + // Root children: nodes whose kiwi parent is the specified parentGuid + const rootChildren = nodes.filter((node) => { + const kiwi = guidToKiwi.get(node.id); + if (!kiwi?.parentIndex?.guid) return false; + return iofigma.kiwi.guid(kiwi.parentIndex.guid) === parentGuid; + }); - // Build lookup maps: guid → REST-like node, guid → raw kiwi nodeChange - const guidToNode = new Map(); - nodes.forEach((n) => guidToNode.set(n.id, n)); + return { rootChildren, guidToNode, guidToKiwi }; +} - const guidToKiwi = new Map(); - derivedNCs.forEach((nc) => { - if (nc.guid) guidToKiwi.set(iofigma.kiwi.guid(nc.guid), nc); - }); +/** + * Apply symbol overrides (text content, visibility, opacity) from an + * INSTANCE's symbolData onto the resolved derived tree. + */ +function applySymbolOverrides(rootChildren, kiwiInstance) { + const overrides = kiwiInstance.symbolData?.symbolOverrides; + if (!Array.isArray(overrides) || overrides.length === 0) return; + + // Build a flat map of all nodes in the tree for quick lookup + const flatMap = new Map(); + const collect = (nodes) => { + for (const n of nodes) { + flatMap.set(n.id, n); + if (n.children?.length) collect(n.children); + } + }; + collect(rootChildren); - // Build parent-child relationships (mirrors buildChildrenRelationsInPlace) - nodes.forEach((node) => { - const kiwi = guidToKiwi.get(node.id); - if (!kiwi?.parentIndex?.guid) return; - const parentGuid = iofigma.kiwi.guid(kiwi.parentIndex.guid); - const parent = guidToNode.get(parentGuid); - if (parent && "children" in parent) { - if (!parent.children) parent.children = []; - parent.children.push(node); + for (const ov of overrides) { + if (!ov.guid) continue; + const targetId = iofigma.kiwi.guid(ov.guid); + const target = flatMap.get(targetId); + if (!target) continue; + + // Text content override + if (target.type === "TEXT" && typeof ov.textData?.characters === "string") { + target.characters = ov.textData.characters; + } + if (ov.visible !== undefined) target.visible = ov.visible; + if (ov.opacity !== undefined) target.opacity = ov.opacity; + } +} + +/** + * Recursively resolve nested INSTANCE nodes within a derived tree. + * When a shared component itself contains instances of other shared components, + * those nested instances also carry their own derivedSymbolData. + */ +function resolveNestedInstances(nodes, guidToKiwi, message, depth = 0) { + if (depth > 10) return; // guard against circular references + for (const node of nodes) { + if (node.type === "INSTANCE") { + const kiwiNC = guidToKiwi.get(node.id); + if (kiwiNC?.derivedSymbolData?.length && (!node.children || node.children.length === 0)) { + const { rootChildren, guidToKiwi: nestedKiwiMap } = + buildDerivedTree(kiwiNC.derivedSymbolData, message, node.id); + if (rootChildren.length > 0) { + applySymbolOverrides(rootChildren, kiwiNC); + node.children = rootChildren; + // Recurse into the newly resolved children + resolveNestedInstances(rootChildren, nestedKiwiMap, message, depth + 1); + } } - }); + } + if (node.children?.length) { + resolveNestedInstances(node.children, guidToKiwi, message, depth + 1); + } + } +} - // Sort children by fractional position index - guidToNode.forEach((parent) => { - if (!parent.children?.length) return; - parent.children.sort((a, b) => { - const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? ""; - const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? ""; - return aPos.localeCompare(bPos); - }); - }); +function resolveSharedComponents(doc, captured) { + if (!captured?.derived?.size) return 0; - // Find root children — direct children of the INSTANCE node - const rootChildren = nodes.filter((node) => { - const kiwi = guidToKiwi.get(node.id); - if (!kiwi?.parentIndex?.guid) return false; - return iofigma.kiwi.guid(kiwi.parentIndex.guid) === instanceGuid; - }); + const { derived, message } = captured; + let patchCount = 0; + for (const [instanceGuid, kiwiInstance] of derived) { + const derivedNCs = kiwiInstance.derivedSymbolData; + + const { rootChildren, guidToKiwi } = + buildDerivedTree(derivedNCs, message, instanceGuid); if (rootChildren.length === 0) continue; + // Apply text / visibility overrides from the instance + applySymbolOverrides(rootChildren, kiwiInstance); + + // Recursively resolve nested shared component instances + resolveNestedInstances(rootChildren, guidToKiwi, message); + // Patch the INSTANCE node in _figFile with resolved children const instanceNode = findNodeInFigFile(doc._figFile, instanceGuid); if (instanceNode) {