From b4555fadc16b2e7371990daead23492b285e9f27 Mon Sep 17 00:00:00 2001 From: Ching Date: Tue, 24 Mar 2026 16:44:34 +0800 Subject: [PATCH 1/5] feat: add download ZIP with PNG export, text styles panel, and custom filename input - Add DownloadButton component with filename input and JSZip packaging - Export selected Figma node as 2x PNG alongside code file in ZIP - Display Text Styles panel below Code panel with syntax highlighting - Pass text styles data from backend through UI pipeline - Handle PNG export via message passing between plugin main thread and UI Co-Authored-By: Claude Sonnet 4.6 --- apps/plugin/package.json | 1 + apps/plugin/plugin-src/code.ts | 26 +++ apps/plugin/ui-src/App.tsx | 44 +++- packages/backend/src/code.ts | 24 +- packages/plugin-ui/package.json | 1 + packages/plugin-ui/src/PluginUI.tsx | 4 + .../plugin-ui/src/components/CodePanel.tsx | 56 ++++- .../src/components/DownloadButton.tsx | 209 ++++++++++++++++++ packages/types/src/types.ts | 1 + pnpm-lock.yaml | 88 ++++++++ 10 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 packages/plugin-ui/src/components/DownloadButton.tsx 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..10976daf 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -165,6 +165,32 @@ const standardMode = async () => { (userPluginSettings as any)[key] = value; figma.clientStorage.setAsync("userPluginSettings", userPluginSettings); safeRun(userPluginSettings); + } else if (msg.type === "export-selection-png") { + console.log("[DEBUG] export-selection-png received"); + const nodes = figma.currentPage.selection; + console.log("[DEBUG] selection count:", nodes.length); + if (nodes.length === 0) { + console.log("[DEBUG] no selection, sending null"); + figma.ui.postMessage({ type: "export-png-result", data: null }); + return; + } + try { + const node = nodes[0]; + console.log("[DEBUG] exporting node:", node.name, node.type); + const pngBytes = await node.exportAsync({ + format: "PNG", + constraint: { type: "SCALE", value: 2 }, + }); + console.log("[DEBUG] export success, bytes:", pngBytes.length); + figma.ui.postMessage({ + type: "export-png-result", + data: Array.from(pngBytes), + }); + console.log("[DEBUG] postMessage sent with PNG data"); + } catch (error) { + console.error("[DEBUG] 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"); diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 80d1ebf4..4aecd163 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,12 @@ 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; selectedFramework: Framework; isLoading: boolean; htmlPreview: HTMLPreview; @@ -31,6 +32,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 +42,28 @@ export default function App() { warnings: [], }); + const pngCallbackRef = useRef<((data: number[] | null) => void) | null>(null); + + const requestExportPng = useCallback((): Promise => { + console.log("[UI] requestExportPng called"); + return new Promise((resolve) => { + pngCallbackRef.current = resolve; + postUIMessage( + { type: "export-selection-png" }, + { targetOrigin: "*" }, + ); + console.log("[UI] export-selection-png message sent to plugin"); + // Timeout after 10 seconds + setTimeout(() => { + if (pngCallbackRef.current) { + console.log("[UI] PNG export timed out after 10s"); + pngCallbackRef.current = null; + resolve(null); + } + }, 10000); + }); + }, []); + const rootStyles = getComputedStyle(document.documentElement); const figmaColorBgValue = rootStyles .getPropertyValue("--figma-color-bg") @@ -47,9 +71,21 @@ export default function App() { useEffect(() => { window.onmessage = (event: MessageEvent) => { - const untypedMessage = event.data.pluginMessage as Message; + const msg = event.data.pluginMessage; + if (!msg) return; + const untypedMessage = msg as Message; console.log("[ui] message received:", untypedMessage); + // Handle PNG export result + if (untypedMessage.type === "export-png-result") { + console.log("[UI] export-png-result received, data exists:", !!(msg as any).data, "callback exists:", !!pngCallbackRef.current); + if (pngCallbackRef.current) { + pngCallbackRef.current((msg as any).data); + pngCallbackRef.current = null; + } + return; + } + switch (untypedMessage.type) { case "conversionStart": setState((prevState) => ({ @@ -147,6 +183,7 @@ export default function App() { ); diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 38054965..6ea03127 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,27 @@ 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; + } + postConversionComplete({ code, + textStyles, 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..b0d1afaa 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -25,6 +25,7 @@ import React from "react"; type PluginUIProps = { code: string; + textStyles: string; htmlPreview: HTMLPreview; warnings: Warning[]; selectedFramework: Framework; @@ -37,6 +38,7 @@ type PluginUIProps = { colors: SolidColorConversion[]; gradients: LinearGradientConversion[]; isLoading: boolean; + onRequestExportPng?: () => Promise; }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; @@ -175,11 +177,13 @@ 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..2d61f2d5 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,7 @@ import { TailwindSettings } from "./TailwindSettings"; interface CodePanelProps { code: string; + textStyles: string; selectedFramework: Framework; settings: PluginSettings | null; preferenceOptions: LocalCodegenPreferenceOptions[]; @@ -23,6 +25,7 @@ interface CodePanelProps { key: keyof PluginSettings, value: boolean | string | number, ) => void; + onRequestExportPng?: () => Promise; } const CodePanel = (props: CodePanelProps) => { @@ -31,6 +34,7 @@ const CodePanel = (props: CodePanelProps) => { const initialLinesToShow = 25; const { code, + textStyles, preferenceOptions, selectPreferenceOptions, selectedFramework, @@ -38,6 +42,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 +146,24 @@ const CodePanel = (props: CodePanelProps) => { Code

{!isCodeEmpty && ( - +
+ + +
)} @@ -255,6 +275,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..c4a4f93f --- /dev/null +++ b/packages/plugin-ui/src/components/DownloadButton.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Download, Check, Loader2 } from "lucide-react"; +import { cn } from "../lib/utils"; +import type { Framework } from "types"; +import JSZip from "jszip"; + +interface DownloadButtonProps { + value: string; + selectedFramework: Framework; + className?: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onRequestExportPng?: () => Promise; +} + +const frameworkExtensions: Record = { + HTML: ".html", + Tailwind: ".html", + Flutter: ".dart", + SwiftUI: ".swift", + Compose: ".kt", +}; + +export function DownloadButton({ + value, + selectedFramework, + className, + onMouseEnter, + onMouseLeave, + onRequestExportPng, +}: DownloadButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [filename, setFilename] = useState("figma-export"); + const [isDownloaded, setIsDownloaded] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const popoverRef = useRef(null); + const inputRef = useRef(null); + + const extension = frameworkExtensions[selectedFramework] ?? ".html"; + + useEffect(() => { + if (isDownloaded) { + const timer = setTimeout(() => setIsDownloaded(false), 1500); + return () => clearTimeout(timer); + } + }, [isDownloaded]); + + 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 handleDownload = async () => { + const sanitizedName = filename.trim() || "figma-export"; + setIsExporting(true); + + try { + // Request PNG export + let pngData: number[] | null = null; + if (onRequestExportPng) { + pngData = await onRequestExportPng(); + console.log("[UI] PNG data received:", pngData ? pngData.length + " bytes" : "null"); + } + + // Create ZIP with both files + const zip = new JSZip(); + zip.file(`${sanitizedName}${extension}`, value); + + if (pngData) { + const pngBytes = new Uint8Array(pngData); + zip.file(`${sanitizedName}.png`, pngBytes); + } + + const zipBlob = await zip.generateAsync({ type: "blob" }); + + // Download ZIP + 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); + + setIsDownloaded(true); + setIsOpen(false); + } catch (e) { + console.error("[UI] Download error:", e); + } finally { + setIsExporting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isExporting) { + handleDownload(); + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + return ( +
+ + + {isOpen && ( +
+ +
+ 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} + /> + + .zip + +
+

+ Contains: {filename.trim() || "figma-export"}{extension} + .png +

+ +
+ )} +
+ ); +} diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 8d9998ba..5d003b40 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -43,6 +43,7 @@ export interface PluginSettings // Messaging export interface ConversionData { code: string; + textStyles: string; 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 From 7116687ee0c00162a9c1bfb16b86707c444de811 Mon Sep 17 00:00:00 2001 From: Ching Date: Tue, 24 Mar 2026 18:02:15 +0800 Subject: [PATCH 2/5] feat: add File System Access API support for saving files to local folder - Add showDirectoryPicker integration to DownloadButton - Allow users to select a target folder once and save files directly - Write code file and PNG separately to the selected folder - Fall back to ZIP download when no folder is selected or API is unsupported - Show folder name in UI after selection with FolderOpen icon --- .../src/components/DownloadButton.tsx | 197 +++++++++++++----- 1 file changed, 147 insertions(+), 50 deletions(-) diff --git a/packages/plugin-ui/src/components/DownloadButton.tsx b/packages/plugin-ui/src/components/DownloadButton.tsx index c4a4f93f..7c8180ea 100644 --- a/packages/plugin-ui/src/components/DownloadButton.tsx +++ b/packages/plugin-ui/src/components/DownloadButton.tsx @@ -1,11 +1,33 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Download, Check, Loader2 } from "lucide-react"; +import { Download, Check, Loader2, Folder, FolderOpen } 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 { + getFileHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + } + interface FileSystemFileHandle { + createWritable(): Promise; + } + interface FileSystemWritableFileStream { + write(data: BufferSource | Blob | string): Promise; + close(): Promise; + } +} + interface DownloadButtonProps { value: string; selectedFramework: Framework; @@ -35,6 +57,8 @@ export function DownloadButton({ const [filename, setFilename] = useState("figma-export"); const [isDownloaded, setIsDownloaded] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [dirHandle, setDirHandle] = useState(null); + const [folderSupported] = useState(() => typeof window !== "undefined" && !!window.showDirectoryPicker); const popoverRef = useRef(null); const inputRef = useRef(null); @@ -71,39 +95,79 @@ export function DownloadButton({ } }, [isOpen]); + const handleSelectFolder = async () => { + try { + const handle = await window.showDirectoryPicker!({ mode: "readwrite" }); + setDirHandle(handle); + console.log("[UI] Folder selected:", handle.name); + } catch (e: any) { + // User cancelled picker + if (e?.name !== "AbortError") { + console.error("[UI] showDirectoryPicker error:", e); + } + } + }; + + const saveToFolder = async ( + sanitizedName: string, + pngData: number[] | null, + ) => { + if (!dirHandle) return; + + // Write code file + const codeFile = await dirHandle.getFileHandle(`${sanitizedName}${extension}`, { create: true }); + const codeWritable = await codeFile.createWritable(); + await codeWritable.write(value); + await codeWritable.close(); + console.log("[UI] Code file saved to folder"); + + // Write PNG file + 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(); + console.log("[UI] PNG file saved to folder"); + } + }; + + const saveAsZip = async (sanitizedName: string, pngData: number[] | null) => { + const zip = new JSZip(); + zip.file(`${sanitizedName}${extension}`, value); + 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); try { - // Request PNG export + // Request PNG export first let pngData: number[] | null = null; if (onRequestExportPng) { pngData = await onRequestExportPng(); console.log("[UI] PNG data received:", pngData ? pngData.length + " bytes" : "null"); } - // Create ZIP with both files - const zip = new JSZip(); - zip.file(`${sanitizedName}${extension}`, value); - - if (pngData) { - const pngBytes = new Uint8Array(pngData); - zip.file(`${sanitizedName}.png`, pngBytes); + if (dirHandle) { + // Save directly to folder + await saveToFolder(sanitizedName, pngData); + } else { + // Fallback: ZIP download + await saveAsZip(sanitizedName, pngData); } - const zipBlob = await zip.generateAsync({ type: "blob" }); - - // Download ZIP - 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); - setIsDownloaded(true); setIsOpen(false); } catch (e) { @@ -140,51 +204,84 @@ export function DownloadButton({
- {isDownloaded ? "Downloaded" : "Download"} + {isDownloaded ? "Saved!" : "Download"} {isOpen && ( -
- -
- 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} - /> - - .zip - +
+ {/* Folder selector (only shown if API is supported) */} + {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 ? extension : ".zip"} + +
+

+ {dirHandle + ? `Saves: ${filename.trim() || "figma-export"}${extension} + .png → ${dirHandle.name}/` + : `Contains: ${filename.trim() || "figma-export"}${extension} + .png`} +

-

- Contains: {filename.trim() || "figma-export"}{extension} + .png -

+ + {/* Download button */} From 73ada786b2a11f88ba0cfb22f6dcc9983daf88b8 Mon Sep 17 00:00:00 2001 From: Ching Date: Tue, 24 Mar 2026 18:46:20 +0800 Subject: [PATCH 3/5] feat: generate single HTML report with layout, text styles, and snapshot - Restructure download output as a report document with 4 sections: title, layout code, text styles, and snapshot image - PNG referenced via relative path instead of base64 to keep file lean and AI-token friendly - Pass code and textStyles as separate props to DownloadButton - ZIP contains report.html + snapshot.png; folder mode saves both files --- .../plugin-ui/src/components/CodePanel.tsx | 7 +- .../src/components/DownloadButton.tsx | 156 ++++++++++++++---- 2 files changed, 130 insertions(+), 33 deletions(-) diff --git a/packages/plugin-ui/src/components/CodePanel.tsx b/packages/plugin-ui/src/components/CodePanel.tsx index 2d61f2d5..45b409eb 100644 --- a/packages/plugin-ui/src/components/CodePanel.tsx +++ b/packages/plugin-ui/src/components/CodePanel.tsx @@ -153,11 +153,8 @@ const CodePanel = (props: CodePanelProps) => { onMouseLeave={handleButtonLeave} /> void; @@ -45,8 +46,92 @@ const frameworkExtensions: Record = { Compose: ".kt", }; +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function generateReportHtml( + title: string, + code: string, + textStyles: string, + pngFilename: string | null, +): string { + return ` + + + + +${escapeHtml(title)} + + + +

${escapeHtml(title)}

+ +

Layout

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

Text Styles

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

Snapshot

+
+ ${escapeHtml(title)} snapshot +
` + : "" +} + +`; +} + export function DownloadButton({ - value, + code, + textStyles, selectedFramework, className, onMouseEnter, @@ -57,13 +142,14 @@ export function DownloadButton({ const [filename, setFilename] = useState("figma-export"); const [isDownloaded, setIsDownloaded] = useState(false); const [isExporting, setIsExporting] = useState(false); - const [dirHandle, setDirHandle] = useState(null); - const [folderSupported] = useState(() => typeof window !== "undefined" && !!window.showDirectoryPicker); + const [dirHandle, setDirHandle] = + useState(null); + const [folderSupported] = useState( + () => typeof window !== "undefined" && !!window.showDirectoryPicker, + ); const popoverRef = useRef(null); const inputRef = useRef(null); - const extension = frameworkExtensions[selectedFramework] ?? ".html"; - useEffect(() => { if (isDownloaded) { const timer = setTimeout(() => setIsDownloaded(false), 1500); @@ -101,7 +187,6 @@ export function DownloadButton({ setDirHandle(handle); console.log("[UI] Folder selected:", handle.name); } catch (e: any) { - // User cancelled picker if (e?.name !== "AbortError") { console.error("[UI] showDirectoryPicker error:", e); } @@ -110,30 +195,37 @@ export function DownloadButton({ const saveToFolder = async ( sanitizedName: string, + htmlContent: string, pngData: number[] | null, ) => { if (!dirHandle) return; - // Write code file - const codeFile = await dirHandle.getFileHandle(`${sanitizedName}${extension}`, { create: true }); - const codeWritable = await codeFile.createWritable(); - await codeWritable.write(value); - await codeWritable.close(); - console.log("[UI] Code file saved to folder"); + // Write HTML report + const htmlFile = await dirHandle.getFileHandle(`${sanitizedName}.html`, { + create: true, + }); + const htmlWritable = await htmlFile.createWritable(); + await htmlWritable.write(htmlContent); + await htmlWritable.close(); // Write PNG file if (pngData) { - const pngFile = await dirHandle.getFileHandle(`${sanitizedName}.png`, { create: true }); + const pngFile = await dirHandle.getFileHandle(`${sanitizedName}.png`, { + create: true, + }); const pngWritable = await pngFile.createWritable(); await pngWritable.write(new Uint8Array(pngData)); await pngWritable.close(); - console.log("[UI] PNG file saved to folder"); } }; - const saveAsZip = async (sanitizedName: string, pngData: number[] | null) => { + const saveAsZip = async ( + sanitizedName: string, + htmlContent: string, + pngData: number[] | null, + ) => { const zip = new JSZip(); - zip.file(`${sanitizedName}${extension}`, value); + zip.file(`${sanitizedName}.html`, htmlContent); if (pngData) { zip.file(`${sanitizedName}.png`, new Uint8Array(pngData)); } @@ -153,19 +245,25 @@ export function DownloadButton({ setIsExporting(true); try { - // Request PNG export first + // Request PNG export let pngData: number[] | null = null; if (onRequestExportPng) { pngData = await onRequestExportPng(); - console.log("[UI] PNG data received:", pngData ? pngData.length + " bytes" : "null"); } + // Generate HTML report + const pngFilename = pngData ? `${sanitizedName}.png` : null; + const htmlContent = generateReportHtml( + sanitizedName, + code, + textStyles, + pngFilename, + ); + if (dirHandle) { - // Save directly to folder - await saveToFolder(sanitizedName, pngData); + await saveToFolder(sanitizedName, htmlContent, pngData); } else { - // Fallback: ZIP download - await saveAsZip(sanitizedName, pngData); + await saveAsZip(sanitizedName, htmlContent, pngData); } setIsDownloaded(true); @@ -224,7 +322,7 @@ export function DownloadButton({ {isOpen && (
- {/* Folder selector (only shown if API is supported) */} + {/* Folder selector */} {folderSupported && (

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

From 4379b7aca66d9f115fbcbe88ae564138e3bed5ea Mon Sep 17 00:00:00 2001 From: Ching Date: Tue, 24 Mar 2026 19:20:54 +0800 Subject: [PATCH 4/5] refactor: optimize download with auto-filename, metadata, error feedback, and cleanup debug logs - Auto-fill filename from selected Figma node name with sanitization - Add metadata to HTML report: framework, export time, element size - Show error messages for PNG export failure and folder access issues - Remove all debug console.log statements from plugin and UI code - Pass selectedNodeName and selectedNodeSize through the full prop chain --- apps/plugin/plugin-src/code.ts | 177 +++++------------- apps/plugin/ui-src/App.tsx | 15 +- packages/backend/src/code.ts | 9 + packages/plugin-ui/src/PluginUI.tsx | 4 + .../plugin-ui/src/components/CodePanel.tsx | 4 + .../src/components/DownloadButton.tsx | 120 ++++++++++-- packages/types/src/types.ts | 2 + 7 files changed, 180 insertions(+), 151 deletions(-) diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index 10976daf..2da123a0 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,129 +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") { - console.log("[DEBUG] export-selection-png received"); const nodes = figma.currentPage.selection; - console.log("[DEBUG] selection count:", nodes.length); if (nodes.length === 0) { - console.log("[DEBUG] no selection, sending null"); figma.ui.postMessage({ type: "export-png-result", data: null }); return; } try { const node = nodes[0]; - console.log("[DEBUG] exporting node:", node.name, node.type); const pngBytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 }, }); - console.log("[DEBUG] export success, bytes:", pngBytes.length); figma.ui.postMessage({ type: "export-png-result", data: Array.from(pngBytes), }); - console.log("[DEBUG] postMessage sent with PNG data"); } catch (error) { - console.error("[DEBUG] Error exporting PNG:", 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({ @@ -241,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, @@ -253,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": @@ -355,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", @@ -393,75 +325,70 @@ 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; +if (figma.editorType === "dev" || figma.mode === "codegen") { + codegenMode(); +} else { + standardMode(); } diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 4aecd163..dcf74a0a 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -18,6 +18,8 @@ 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; @@ -45,18 +47,14 @@ export default function App() { const pngCallbackRef = useRef<((data: number[] | null) => void) | null>(null); const requestExportPng = useCallback((): Promise => { - console.log("[UI] requestExportPng called"); return new Promise((resolve) => { pngCallbackRef.current = resolve; postUIMessage( { type: "export-selection-png" }, { targetOrigin: "*" }, ); - console.log("[UI] export-selection-png message sent to plugin"); - // Timeout after 10 seconds setTimeout(() => { if (pngCallbackRef.current) { - console.log("[UI] PNG export timed out after 10s"); pngCallbackRef.current = null; resolve(null); } @@ -74,11 +72,9 @@ export default function App() { const msg = event.data.pluginMessage; if (!msg) return; const untypedMessage = msg as Message; - console.log("[ui] message received:", untypedMessage); // Handle PNG export result if (untypedMessage.type === "export-png-result") { - console.log("[UI] export-png-result received, data exists:", !!(msg as any).data, "callback exists:", !!pngCallbackRef.current); if (pngCallbackRef.current) { pngCallbackRef.current((msg as any).data); pngCallbackRef.current = null; @@ -115,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: [], @@ -129,7 +126,6 @@ export default function App() { case "error": const errorMessage = untypedMessage as ErrorMessage; - setState((prevState) => ({ ...prevState, colors: [], @@ -157,7 +153,6 @@ export default function App() { if (updatedFramework !== state.selectedFramework) { setState((prevState) => ({ ...prevState, - // code: "// Loading...", selectedFramework: updatedFramework, })); postUISettingsChangingMessage("framework", updatedFramework, { @@ -184,6 +179,8 @@ export default function App() { isLoading={state.isLoading} code={state.code} textStyles={state.textStyles} + selectedNodeName={state.selectedNodeName} + selectedNodeSize={state.selectedNodeSize} warnings={state.warnings} selectedFramework={state.selectedFramework} setSelectedFramework={handleFrameworkChange} diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 6ea03127..e3bcd8db 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -134,9 +134,18 @@ export const run = async (settings: PluginSettings) => { 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/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index b0d1afaa..d39aed17 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -26,6 +26,8 @@ import React from "react"; type PluginUIProps = { code: string; textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; htmlPreview: HTMLPreview; warnings: Warning[]; selectedFramework: Framework; @@ -178,6 +180,8 @@ export const PluginUI = (props: PluginUIProps) => { { Promise; } interface FileSystemDirectoryHandle { + name: string; getFileHandle( name: string, options?: { create?: boolean }, @@ -31,6 +39,8 @@ declare global { interface DownloadButtonProps { code: string; textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; selectedFramework: Framework; className?: string; onMouseEnter?: () => void; @@ -38,14 +48,6 @@ interface DownloadButtonProps { onRequestExportPng?: () => Promise; } -const frameworkExtensions: Record = { - HTML: ".html", - Tailwind: ".html", - Flutter: ".dart", - SwiftUI: ".swift", - Compose: ".kt", -}; - function escapeHtml(str: string): string { return str .replace(/&/g, "&") @@ -54,12 +56,43 @@ function escapeHtml(str: string): string { .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 ` @@ -80,8 +113,21 @@ function generateReportHtml( font-weight: 700; border-bottom: 2px solid #e5e5e5; padding-bottom: 12px; + margin-bottom: 16px; + } + .meta { + font-size: 13px; + color: #888; margin-bottom: 32px; } + .meta ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 24px; + flex-wrap: wrap; + } h2 { font-size: 18px; font-weight: 600; @@ -106,6 +152,11 @@ function generateReportHtml(

    ${escapeHtml(title)}

    +
    +
      + ${metaItems} +
    +

    Layout

    ${escapeHtml(code)}
    @@ -132,6 +183,8 @@ ${ export function DownloadButton({ code, textStyles, + selectedNodeName, + selectedNodeSize, selectedFramework, className, onMouseEnter, @@ -139,9 +192,10 @@ export function DownloadButton({ onRequestExportPng, }: DownloadButtonProps) { const [isOpen, setIsOpen] = useState(false); - const [filename, setFilename] = useState("figma-export"); + 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( @@ -150,6 +204,13 @@ export function DownloadButton({ 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); @@ -157,6 +218,13 @@ export function DownloadButton({ } }, [isDownloaded]); + useEffect(() => { + if (errorMsg) { + const timer = setTimeout(() => setErrorMsg(null), 5000); + return () => clearTimeout(timer); + } + }, [errorMsg]); + useEffect(() => { if (isOpen && inputRef.current) { inputRef.current.focus(); @@ -185,10 +253,9 @@ export function DownloadButton({ try { const handle = await window.showDirectoryPicker!({ mode: "readwrite" }); setDirHandle(handle); - console.log("[UI] Folder selected:", handle.name); } catch (e: any) { if (e?.name !== "AbortError") { - console.error("[UI] showDirectoryPicker error:", e); + setErrorMsg("Failed to select folder. This feature may not be supported in Figma."); } } }; @@ -200,7 +267,6 @@ export function DownloadButton({ ) => { if (!dirHandle) return; - // Write HTML report const htmlFile = await dirHandle.getFileHandle(`${sanitizedName}.html`, { create: true, }); @@ -208,7 +274,6 @@ export function DownloadButton({ await htmlWritable.write(htmlContent); await htmlWritable.close(); - // Write PNG file if (pngData) { const pngFile = await dirHandle.getFileHandle(`${sanitizedName}.png`, { create: true, @@ -243,12 +308,16 @@ export function DownloadButton({ 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 @@ -258,18 +327,27 @@ export function DownloadButton({ code, textStyles, pngFilename, + selectedFramework, + selectedNodeSize, ); if (dirHandle) { - await saveToFolder(sanitizedName, htmlContent, pngData); + 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) { - console.error("[UI] Download error:", e); + } catch (e: any) { + setErrorMsg(`Download failed: ${e?.message || "Unknown error"}`); } finally { setIsExporting(false); } @@ -322,6 +400,14 @@ export function DownloadButton({ {isOpen && (
    + {/* Error message */} + {errorMsg && ( +
    + + {errorMsg} +
    + )} + {/* Folder selector */} {folderSupported && (
    diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 5d003b40..56f67fd2 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -44,6 +44,8 @@ export interface PluginSettings export interface ConversionData { code: string; textStyles: string; + selectedNodeName?: string; + selectedNodeSize?: { width: number; height: number }; settings: PluginSettings; htmlPreview: HTMLPreview; colors: SolidColorConversion[]; From e96586946c5dee84276d187d4ec67f098b1943bb Mon Sep 17 00:00:00 2001 From: Ching Date: Tue, 24 Mar 2026 19:31:50 +0800 Subject: [PATCH 5/5] fix: restore switch(figma.mode) to prevent loading hang in Dev Mode --- apps/plugin/plugin-src/code.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index 2da123a0..dfccd848 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -387,8 +387,14 @@ const codegenMode = async () => { }); }; -if (figma.editorType === "dev" || figma.mode === "codegen") { - codegenMode(); -} else { - standardMode(); +switch (figma.mode) { + case "default": + case "inspect": + standardMode(); + break; + case "codegen": + codegenMode(); + break; + default: + break; }