diff --git a/apps/plugin/package.json b/apps/plugin/package.json index b1a1a685..8b958016 100644 --- a/apps/plugin/package.json +++ b/apps/plugin/package.json @@ -14,6 +14,7 @@ "backend": "workspace:*", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", + "jszip": "^3.10.1", "lucide-react": "^0.483.0", "motion": "^12.23.25", "nanoid": "^5.1.6", diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index fafd2569..dfccd848 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -47,13 +47,8 @@ function isKeyOfPluginSettings(key: string): key is keyof PluginSettings { } const getUserSettings = async () => { - console.log("[DEBUG] getUserSettings - Starting to fetch user settings"); const possiblePluginSrcSettings = (await figma.clientStorage.getAsync("userPluginSettings")) ?? {}; - console.log( - "[DEBUG] getUserSettings - Raw settings from storage:", - possiblePluginSrcSettings, - ); const updatedPluginSrcSettings = { ...defaultPluginSettings, @@ -71,103 +66,86 @@ const getUserSettings = async () => { }; userPluginSettings = updatedPluginSrcSettings as PluginSettings; - console.log("[DEBUG] getUserSettings - Final settings:", userPluginSettings); return userPluginSettings; }; const initSettings = async () => { - console.log("[DEBUG] initSettings - Initializing plugin settings"); await getUserSettings(); postSettingsChanged(userPluginSettings); - console.log("[DEBUG] initSettings - Calling safeRun with settings"); safeRun(userPluginSettings); }; // Used to prevent running from happening again. let isLoading = false; const safeRun = async (settings: PluginSettings) => { - console.log( - "[DEBUG] safeRun - Called with isLoading =", - isLoading, - "selection =", - figma.currentPage.selection, - ); if (isLoading === false) { try { isLoading = true; - console.log("[DEBUG] safeRun - Starting run execution"); await run(settings); - console.log("[DEBUG] safeRun - Run execution completed"); // hack to make it not immediately set to false when complete. (executes on next frame) setTimeout(() => { - console.log("[DEBUG] safeRun - Resetting isLoading to false"); isLoading = false; }, 1); } catch (e) { - console.log("[DEBUG] safeRun - Error caught in execution"); - isLoading = false; // Make sure to reset the flag on error + isLoading = false; if (e && typeof e === "object" && "message" in e) { const error = e as Error; - console.log("error: ", error.stack); + console.error("Plugin error:", error.stack); figma.ui.postMessage({ type: "error", error: error.message }); } else { - // Handle non-standard errors or unknown error types const errorMessage = String(e); - console.log("Unknown error: ", errorMessage); + console.error("Plugin error:", errorMessage); figma.ui.postMessage({ type: "error", error: errorMessage || "Unknown error occurred", }); } - // Send a message to reset the UI state figma.ui.postMessage({ type: "conversion-complete", success: false }); } - } else { - console.log( - "[DEBUG] safeRun - Skipping execution because isLoading =", - isLoading, - ); } }; const standardMode = async () => { - console.log("[DEBUG] standardMode - Starting standard mode initialization"); figma.showUI(__html__, { width: 450, height: 700, themeColors: true }); await initSettings(); - // Listen for selection changes figma.on("selectionchange", () => { - console.log( - "[DEBUG] selectionchange event - New selection:", - figma.currentPage.selection, - ); safeRun(userPluginSettings); }); - // Listen for page changes figma.loadAllPagesAsync(); figma.on("documentchange", () => { - console.log("[DEBUG] documentchange event triggered"); - // Node: This was causing an infinite load when you try to export a background image from a group that contains children. - // The reason for this is that the code will temporarily hide the children of the group in order to export a clean image - // then restores the visibility of the children. This constitutes a document change so it's restarting the whole conversion. - // In order to stop this, we disable safeRun() when doing conversions (while isLoading === true). safeRun(userPluginSettings); }); figma.ui.onmessage = async (msg) => { - console.log("[DEBUG] figma.ui.onmessage", msg); - if (msg.type === "pluginSettingWillChange") { const { key, value } = msg as SettingWillChangeMessage; - console.log(`[DEBUG] Setting changed: ${key} = ${value}`); (userPluginSettings as any)[key] = value; figma.clientStorage.setAsync("userPluginSettings", userPluginSettings); safeRun(userPluginSettings); + } else if (msg.type === "export-selection-png") { + const nodes = figma.currentPage.selection; + if (nodes.length === 0) { + figma.ui.postMessage({ type: "export-png-result", data: null }); + return; + } + try { + const node = nodes[0]; + const pngBytes = await node.exportAsync({ + format: "PNG", + constraint: { type: "SCALE", value: 2 }, + }); + figma.ui.postMessage({ + type: "export-png-result", + data: Array.from(pngBytes), + }); + } catch (error) { + console.error("Error exporting PNG:", error); + figma.ui.postMessage({ type: "export-png-result", data: null }); + } } else if (msg.type === "get-selection-json") { - console.log("[DEBUG] get-selection-json message received"); - const nodes = figma.currentPage.selection; if (nodes.length === 0) { figma.ui.postMessage({ @@ -215,9 +193,6 @@ const standardMode = async () => { const nodeJson = result; - console.log("[DEBUG] Exported node JSON:", nodeJson); - - // Send the JSON data back to the UI figma.ui.postMessage({ type: "selection-json", data: nodeJson, @@ -227,23 +202,12 @@ const standardMode = async () => { }; const codegenMode = async () => { - console.log("[DEBUG] codegenMode - Starting codegen mode initialization"); - // figma.showUI(__html__, { visible: false }); await getUserSettings(); figma.codegen.on( "generate", async ({ language, node }: CodegenEvent): Promise => { - console.log( - `[DEBUG] codegen.generate - Language: ${language}, Node:`, - node, - ); - const convertedSelection = await nodesToJSON([node], userPluginSettings); - console.log( - "[DEBUG] codegen.generate - Converted selection:", - convertedSelection, - ); switch (language) { case "html": @@ -329,37 +293,31 @@ const codegenMode = async () => { ]; case "tailwind": - case "tailwind_jsx": return [ { title: "Code", code: await tailwindMain(convertedSelection, { ...userPluginSettings, - tailwindGenerationMode: - language === "tailwind_jsx" ? "jsx" : "html", + tailwindGenerationMode: "html", }), language: "HTML", }, - // { - // title: "Style", - // code: tailwindMain(convertedSelection, defaultPluginSettings), - // language: "HTML", - // }, { - title: "Tailwind Colors", - code: (await retrieveGenericSolidUIColors("Tailwind")) - .map((d) => { - let str = `${d.hex};`; - if (d.colorName !== d.hex) { - str += ` // ${d.colorName}`; - } - if (d.meta) { - str += ` (${d.meta})`; - } - return str; - }) - .join("\n"), - language: "JAVASCRIPT", + title: "Text Styles", + code: tailwindCodeGenTextStyles(), + language: "HTML", + }, + ]; + + case "tailwind_jsx": + return [ + { + title: "Code", + code: await tailwindMain(convertedSelection, { + ...userPluginSettings, + tailwindGenerationMode: "jsx", + }), + language: "HTML", }, { title: "Text Styles", @@ -367,75 +325,76 @@ const codegenMode = async () => { language: "HTML", }, ]; + case "flutter": return [ { title: "Code", - code: flutterMain(convertedSelection, { - ...userPluginSettings, - flutterGenerationMode: "snippet", - }), - language: "SWIFT", + code: await flutterMain(convertedSelection, userPluginSettings), + language: "PLAINTEXT", }, { title: "Text Styles", code: flutterCodeGenTextStyles(), - language: "SWIFT", + language: "PLAINTEXT", }, ]; + case "swiftUI": return [ { - title: "SwiftUI", - code: swiftuiMain(convertedSelection, { - ...userPluginSettings, - swiftUIGenerationMode: "snippet", - }), - language: "SWIFT", + title: "Code", + code: await swiftuiMain(convertedSelection, userPluginSettings), + language: "PLAINTEXT", }, { title: "Text Styles", code: swiftUICodeGenTextStyles(), - language: "SWIFT", + language: "PLAINTEXT", }, ]; + // case "compose": // return [ // { - // title: "Jetpack Compose", - // code: composeMain(convertedSelection, { - // ...userPluginSettings, - // composeGenerationMode: "snippet", - // }), - // language: "KOTLIN", + // title: "Code", + // code: composeMain(convertedSelection, userPluginSettings), + // language: "PLAINTEXT", // }, // { // title: "Text Styles", // code: composeCodeGenTextStyles(), - // language: "KOTLIN", + // language: "PLAINTEXT", // }, // ]; + default: - break; + return []; } - - const blocks: CodegenResult[] = []; - return blocks; }, ); + + figma.codegen.on("preferenceschange", async (event: CodegenPreferencesEvent) => { + const { propertyName, newValue } = event; + if (isKeyOfPluginSettings(propertyName)) { + const typedValue = + typeof defaultPluginSettings[propertyName] === "boolean" + ? newValue === "true" + : newValue; + (userPluginSettings as any)[propertyName] = typedValue; + figma.clientStorage.setAsync("userPluginSettings", userPluginSettings); + } + }); }; switch (figma.mode) { case "default": case "inspect": - console.log("[DEBUG] Starting plugin in", figma.mode, "mode"); standardMode(); break; case "codegen": - console.log("[DEBUG] Starting plugin in codegen mode"); codegenMode(); break; default: - console.log("[DEBUG] Unknown plugin mode:", figma.mode); break; } diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 80d1ebf4..dcf74a0a 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { PluginUI } from "plugin-ui"; import { Framework, @@ -12,11 +12,14 @@ import { SettingsChangedMessage, Warning, } from "types"; -import { postUISettingsChangingMessage } from "./messaging"; +import { postUIMessage, postUISettingsChangingMessage } from "./messaging"; import copy from "copy-to-clipboard"; interface AppState { code: string; + textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; selectedFramework: Framework; isLoading: boolean; htmlPreview: HTMLPreview; @@ -31,6 +34,7 @@ const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; export default function App() { const [state, setState] = useState({ code: "", + textStyles: "", selectedFramework: "HTML", isLoading: false, htmlPreview: emptyPreview, @@ -40,6 +44,24 @@ export default function App() { warnings: [], }); + const pngCallbackRef = useRef<((data: number[] | null) => void) | null>(null); + + const requestExportPng = useCallback((): Promise => { + return new Promise((resolve) => { + pngCallbackRef.current = resolve; + postUIMessage( + { type: "export-selection-png" }, + { targetOrigin: "*" }, + ); + setTimeout(() => { + if (pngCallbackRef.current) { + pngCallbackRef.current = null; + resolve(null); + } + }, 10000); + }); + }, []); + const rootStyles = getComputedStyle(document.documentElement); const figmaColorBgValue = rootStyles .getPropertyValue("--figma-color-bg") @@ -47,8 +69,18 @@ export default function App() { useEffect(() => { window.onmessage = (event: MessageEvent) => { - const untypedMessage = event.data.pluginMessage as Message; - console.log("[ui] message received:", untypedMessage); + const msg = event.data.pluginMessage; + if (!msg) return; + const untypedMessage = msg as Message; + + // Handle PNG export result + if (untypedMessage.type === "export-png-result") { + if (pngCallbackRef.current) { + pngCallbackRef.current((msg as any).data); + pngCallbackRef.current = null; + } + return; + } switch (untypedMessage.type) { case "conversionStart": @@ -79,10 +111,11 @@ export default function App() { break; case "empty": - // const emptyMessage = untypedMessage as EmptyMessage; setState((prevState) => ({ ...prevState, code: "", + selectedNodeName: undefined, + selectedNodeSize: undefined, htmlPreview: emptyPreview, warnings: [], colors: [], @@ -93,7 +126,6 @@ export default function App() { case "error": const errorMessage = untypedMessage as ErrorMessage; - setState((prevState) => ({ ...prevState, colors: [], @@ -121,7 +153,6 @@ export default function App() { if (updatedFramework !== state.selectedFramework) { setState((prevState) => ({ ...prevState, - // code: "// Loading...", selectedFramework: updatedFramework, })); postUISettingsChangingMessage("framework", updatedFramework, { @@ -147,6 +178,9 @@ export default function App() { ); diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 38054965..e3bcd8db 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -10,7 +10,10 @@ import { import { postConversionComplete, postEmptyMessage } from "./messaging"; import { PluginSettings } from "types"; import { convertToCode } from "./common/retrieveUI/convertToCode"; -import { generateHTMLPreview } from "./html/htmlMain"; +import { generateHTMLPreview, htmlCodeGenTextStyles } from "./html/htmlMain"; +import { tailwindCodeGenTextStyles } from "./tailwind/tailwindMain"; +import { flutterCodeGenTextStyles } from "./flutter/flutterMain"; +import { swiftUICodeGenTextStyles } from "./swiftui/swiftuiMain"; import { oldConvertNodesToAltNodes } from "./altNodes/oldAltConversion"; import { getNodeByIdAsyncCalls, @@ -113,8 +116,36 @@ export const run = async (settings: PluginSettings) => { }ms)`, ); + // Retrieve text styles based on framework + let textStyles = ""; + switch (settings.framework) { + case "Tailwind": + textStyles = tailwindCodeGenTextStyles(); + break; + case "Flutter": + textStyles = flutterCodeGenTextStyles(); + break; + case "SwiftUI": + textStyles = swiftUICodeGenTextStyles(); + break; + case "HTML": + default: + textStyles = htmlCodeGenTextStyles(settings); + break; + } + + // Extract selected node metadata + const firstNode = selection[0]; + const selectedNodeName = firstNode?.name; + const selectedNodeSize = firstNode + ? { width: Math.round(firstNode.width), height: Math.round(firstNode.height) } + : undefined; + postConversionComplete({ code, + textStyles, + selectedNodeName, + selectedNodeSize, htmlPreview, colors, gradients, diff --git a/packages/plugin-ui/package.json b/packages/plugin-ui/package.json index de2cfa13..024c598b 100644 --- a/packages/plugin-ui/package.json +++ b/packages/plugin-ui/package.json @@ -15,6 +15,7 @@ "@types/react-syntax-highlighter": "15.5.13", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", + "jszip": "^3.10.1", "lucide-react": "^0.483.0", "react": "^19.2.1", "react-syntax-highlighter": "^15.6.6", diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 3a83ef8b..d39aed17 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -25,6 +25,9 @@ import React from "react"; type PluginUIProps = { code: string; + textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; htmlPreview: HTMLPreview; warnings: Warning[]; selectedFramework: Framework; @@ -37,6 +40,7 @@ type PluginUIProps = { colors: SolidColorConversion[]; gradients: LinearGradientConversion[]; isLoading: boolean; + onRequestExportPng?: () => Promise; }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; @@ -175,11 +179,15 @@ export const PluginUI = (props: PluginUIProps) => { {props.colors.length > 0 && ( diff --git a/packages/plugin-ui/src/components/CodePanel.tsx b/packages/plugin-ui/src/components/CodePanel.tsx index f2e68f92..e7a4083a 100644 --- a/packages/plugin-ui/src/components/CodePanel.tsx +++ b/packages/plugin-ui/src/components/CodePanel.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coldarkDark as theme } from "react-syntax-highlighter/dist/esm/styles/prism"; import { CopyButton } from "./CopyButton"; +import { DownloadButton } from "./DownloadButton"; import EmptyState from "./EmptyState"; import SettingsGroup from "./SettingsGroup"; import FrameworkTabs from "./FrameworkTabs"; @@ -15,6 +16,9 @@ import { TailwindSettings } from "./TailwindSettings"; interface CodePanelProps { code: string; + textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; selectedFramework: Framework; settings: PluginSettings | null; preferenceOptions: LocalCodegenPreferenceOptions[]; @@ -23,6 +27,7 @@ interface CodePanelProps { key: keyof PluginSettings, value: boolean | string | number, ) => void; + onRequestExportPng?: () => Promise; } const CodePanel = (props: CodePanelProps) => { @@ -31,6 +36,7 @@ const CodePanel = (props: CodePanelProps) => { const initialLinesToShow = 25; const { code, + textStyles, preferenceOptions, selectPreferenceOptions, selectedFramework, @@ -38,6 +44,9 @@ const CodePanel = (props: CodePanelProps) => { onPreferenceChanged, } = props; const isCodeEmpty = code === ""; + const hasTextStyles = + textStyles !== "" && + textStyles !== "// No text styles in this selection"; // Helper function to add the prefix before every class (or className) in the code. // It finds every occurrence of class="..." or className="..." and, for each class, @@ -139,11 +148,23 @@ const CodePanel = (props: CodePanelProps) => { Code

{!isCodeEmpty && ( - +
+ + +
)} @@ -255,6 +276,32 @@ const CodePanel = (props: CodePanelProps) => { )} + + {hasTextStyles && ( +
+
+

+ Text Styles +

+ +
+
+ + {textStyles} + +
+
+ )} ); }; diff --git a/packages/plugin-ui/src/components/DownloadButton.tsx b/packages/plugin-ui/src/components/DownloadButton.tsx new file mode 100644 index 00000000..f4c3b68c --- /dev/null +++ b/packages/plugin-ui/src/components/DownloadButton.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { + Download, + Check, + Loader2, + Folder, + FolderOpen, + AlertCircle, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import type { Framework } from "types"; +import JSZip from "jszip"; + +// File System Access API types (not in default TS lib) +declare global { + interface Window { + showDirectoryPicker?: (options?: { + mode?: "read" | "readwrite"; + }) => Promise; + } + interface FileSystemDirectoryHandle { + name: string; + getFileHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + } + interface FileSystemFileHandle { + createWritable(): Promise; + } + interface FileSystemWritableFileStream { + write(data: BufferSource | Blob | string): Promise; + close(): Promise; + } +} + +interface DownloadButtonProps { + code: string; + textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; + selectedFramework: Framework; + className?: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onRequestExportPng?: () => Promise; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function sanitizeFilename(name: string): string { + return name + .replace(/[^a-zA-Z0-9\u4e00-\u9fff\u3400-\u4dbf_-]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase(); +} + +function generateReportHtml( + title: string, + code: string, + textStyles: string, + pngFilename: string | null, + framework: string, + nodeSize?: { width: number; height: number }, +): string { + const now = new Date(); + const exportTime = now.toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + const metaItems = [ + `
  • Framework: ${escapeHtml(framework)}
  • `, + `
  • Exported: ${escapeHtml(exportTime)}
  • `, + nodeSize + ? `
  • Size: ${nodeSize.width} x ${nodeSize.height} px
  • ` + : "", + ] + .filter(Boolean) + .join("\n "); + + return ` + + + + +${escapeHtml(title)} + + + +

    ${escapeHtml(title)}

    +
    +
      + ${metaItems} +
    +
    + +

    Layout

    +
    ${escapeHtml(code)}
    +${ + textStyles + ? ` +

    Text Styles

    +
    ${escapeHtml(textStyles)}
    ` + : "" +} +${ + pngFilename + ? ` +

    Snapshot

    +
    + ${escapeHtml(title)} snapshot +
    ` + : "" +} + +`; +} + +export function DownloadButton({ + code, + textStyles, + selectedNodeName, + selectedNodeSize, + selectedFramework, + className, + onMouseEnter, + onMouseLeave, + onRequestExportPng, +}: DownloadButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [filename, setFilename] = useState(""); + const [isDownloaded, setIsDownloaded] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [dirHandle, setDirHandle] = + useState(null); + const [folderSupported] = useState( + () => typeof window !== "undefined" && !!window.showDirectoryPicker, + ); + const popoverRef = useRef(null); + const inputRef = useRef(null); + + // Auto-fill filename from selected node name + useEffect(() => { + if (selectedNodeName) { + setFilename(sanitizeFilename(selectedNodeName)); + } + }, [selectedNodeName]); + + useEffect(() => { + if (isDownloaded) { + const timer = setTimeout(() => setIsDownloaded(false), 1500); + return () => clearTimeout(timer); + } + }, [isDownloaded]); + + useEffect(() => { + if (errorMsg) { + const timer = setTimeout(() => setErrorMsg(null), 5000); + return () => clearTimeout(timer); + } + }, [errorMsg]); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isOpen]); + + // Close popover when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + const handleSelectFolder = async () => { + try { + const handle = await window.showDirectoryPicker!({ mode: "readwrite" }); + setDirHandle(handle); + } catch (e: any) { + if (e?.name !== "AbortError") { + setErrorMsg("Failed to select folder. This feature may not be supported in Figma."); + } + } + }; + + const saveToFolder = async ( + sanitizedName: string, + htmlContent: string, + pngData: number[] | null, + ) => { + if (!dirHandle) return; + + const htmlFile = await dirHandle.getFileHandle(`${sanitizedName}.html`, { + create: true, + }); + const htmlWritable = await htmlFile.createWritable(); + await htmlWritable.write(htmlContent); + await htmlWritable.close(); + + if (pngData) { + const pngFile = await dirHandle.getFileHandle(`${sanitizedName}.png`, { + create: true, + }); + const pngWritable = await pngFile.createWritable(); + await pngWritable.write(new Uint8Array(pngData)); + await pngWritable.close(); + } + }; + + const saveAsZip = async ( + sanitizedName: string, + htmlContent: string, + pngData: number[] | null, + ) => { + const zip = new JSZip(); + zip.file(`${sanitizedName}.html`, htmlContent); + if (pngData) { + zip.file(`${sanitizedName}.png`, new Uint8Array(pngData)); + } + const zipBlob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(zipBlob); + const a = document.createElement("a"); + a.href = url; + a.download = `${sanitizedName}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleDownload = async () => { + const sanitizedName = filename.trim() || "figma-export"; + setIsExporting(true); + setErrorMsg(null); + + try { + // Request PNG export + let pngData: number[] | null = null; + if (onRequestExportPng) { + pngData = await onRequestExportPng(); + if (!pngData) { + setErrorMsg("PNG export failed or timed out. Saving without snapshot."); + } + } + + // Generate HTML report + const pngFilename = pngData ? `${sanitizedName}.png` : null; + const htmlContent = generateReportHtml( + sanitizedName, + code, + textStyles, + pngFilename, + selectedFramework, + selectedNodeSize, + ); + + if (dirHandle) { + try { + await saveToFolder(sanitizedName, htmlContent, pngData); + } catch (e: any) { + // Folder permission might have been revoked, fallback to ZIP + setDirHandle(null); + setErrorMsg("Folder access lost. Downloading as ZIP instead."); + await saveAsZip(sanitizedName, htmlContent, pngData); + } + } else { + await saveAsZip(sanitizedName, htmlContent, pngData); + } + + setIsDownloaded(true); + setIsOpen(false); + } catch (e: any) { + setErrorMsg(`Download failed: ${e?.message || "Unknown error"}`); + } finally { + setIsExporting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isExporting) { + handleDownload(); + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + return ( +
    + + + {isOpen && ( +
    + {/* Error message */} + {errorMsg && ( +
    + + {errorMsg} +
    + )} + + {/* Folder selector */} + {folderSupported && ( +
    + + + {!dirHandle && ( +

    + Or leave empty to download as ZIP +

    + )} +
    + )} + + {/* Filename input */} +
    + +
    + setFilename(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 min-w-0 px-2 py-1.5 text-sm border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="figma-export" + disabled={isExporting} + /> + + {dirHandle ? "" : ".zip"} + +
    +

    + {dirHandle + ? `${dirHandle.name}/${filename.trim() || "figma-export"}.html + .png` + : `Contains: .html (report) + .png (snapshot)`} +

    +
    + + {/* Download button */} + +
    + )} +
    + ); +} diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 8d9998ba..56f67fd2 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -43,6 +43,9 @@ export interface PluginSettings // Messaging export interface ConversionData { code: string; + textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; settings: PluginSettings; htmlPreview: HTMLPreview; colors: SolidColorConversion[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d755a1ae..4143ea05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.483.0 version: 0.483.0(react@19.2.1) @@ -248,6 +251,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.483.0 version: 0.483.0(react@19.2.1) @@ -1832,6 +1838,9 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2292,6 +2301,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2300,6 +2312,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2423,6 +2438,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2478,6 +2496,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2492,6 +2513,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -2740,6 +2764,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2826,6 +2853,9 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2869,6 +2899,9 @@ packages: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2927,6 +2960,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -2962,6 +2998,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3039,6 +3078,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3234,6 +3276,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-plugin-singlefile@2.3.0: resolution: {integrity: sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==} engines: {node: '>18.0.0'} @@ -4548,6 +4593,8 @@ snapshots: dependencies: toggle-selection: 1.0.6 + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5206,6 +5253,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5213,6 +5262,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -5344,6 +5395,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -5390,6 +5443,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5405,6 +5465,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -5633,6 +5697,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5698,6 +5764,8 @@ snapshots: prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -5740,6 +5808,16 @@ snapshots: react@19.2.1: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} reflect.getprototypeof@1.0.10: @@ -5834,6 +5912,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -5875,6 +5955,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -6012,6 +6094,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6231,6 +6317,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite-plugin-singlefile@2.3.0(rollup@4.53.3)(vite@5.4.21(@types/node@24.10.2)(lightningcss@1.30.2)): dependencies: micromatch: 4.0.8