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 && (
+
+
+
+
+ {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)}
+
+
+Layout
+${escapeHtml(code)}
+${
+ textStyles
+ ? `
+Text Styles
+${escapeHtml(textStyles)}
`
+ : ""
+}
+${
+ pngFilename
+ ? `
+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 && (
+
+ )}
+
+ {/* 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