diff --git a/apps/debug/next-env.d.ts b/apps/debug/next-env.d.ts index 1b3be084..c4b7818f 100644 --- a/apps/debug/next-env.d.ts +++ b/apps/debug/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/debug/package.json b/apps/debug/package.json index 7dc11aa1..0cee8e2d 100644 --- a/apps/debug/package.json +++ b/apps/debug/package.json @@ -11,21 +11,21 @@ }, "dependencies": { "backend": "workspace:*", - "next": "^15.3.4", + "next": "^16.2.6", "plugin-ui": "workspace:*", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.2.6", + "react-dom": "^19.2.6" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/node": "^24.0.4", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@tailwindcss/postcss": "^4.3.0", + "@types/node": "^25.7.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "eslint-config-custom": "workspace:*", - "postcss": "^8.5.6", - "tailwindcss": "4.0.14", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.8.3" + "typescript": "^6.0.3" } } diff --git a/apps/plugin/package.json b/apps/plugin/package.json index 22995335..00783aab 100644 --- a/apps/plugin/package.json +++ b/apps/plugin/package.json @@ -10,38 +10,38 @@ "dev": "pnpm build:watch" }, "dependencies": { - "@figma/plugin-typings": "^1.114.0", + "@figma/plugin-typings": "^1.125.0", "backend": "workspace:*", "clsx": "^2.1.1", - "copy-to-clipboard": "^3.3.3", - "lucide-react": "^0.483.0", - "motion": "^12.19.1", - "nanoid": "^5.1.5", + "copy-to-clipboard": "^4.0.2", + "lucide-react": "^1.14.0", + "motion": "^12.38.0", + "nanoid": "^5.1.11", "plugin-ui": "workspace:*", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "tailwind-merge": "^3.3.1" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/node": "^24.0.4", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@typescript-eslint/eslint-plugin": "^8.35.0", - "@typescript-eslint/parser": "^8.35.0", - "@vitejs/plugin-react": "^4.6.0", - "@vitejs/plugin-react-swc": "^3.10.2", - "concurrently": "^9.2.0", - "esbuild": "^0.25.5", + "@tailwindcss/postcss": "^4.3.0", + "@types/node": "^25.7.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.59.3", + "@typescript-eslint/parser": "^8.59.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react-swc": "^4.3.0", + "concurrently": "^9.2.1", + "esbuild": "^0.28.0", "eslint-config-custom": "workspace:*", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "postcss": "^8.5.6", - "tailwindcss": "4.0.14", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.8.3", - "vite": "^5.4.19", - "vite-plugin-singlefile": "^2.2.0" + "typescript": "^6.0.3", + "vite": "^8.0.12", + "vite-plugin-singlefile": "^2.3.3" } } diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index b9aa12e7..1a4bd3da 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -35,9 +35,10 @@ export const defaultPluginSettings: PluginSettings = { htmlGenerationMode: "html", tailwindGenerationMode: "jsx", baseFontSize: 16, - useTailwind4: false, + useTailwind4: true, thresholdPercent: 15, baseFontFamily: "", + fontFamilyCustomConfig: {}, }; // A helper type guard to ensure the key belongs to the PluginSettings type @@ -88,8 +89,8 @@ const safeRun = async (settings: PluginSettings) => { console.log( "[DEBUG] safeRun - Called with isLoading =", isLoading, - "selection =", - figma.currentPage.selection, + "selectionCount =", + figma.currentPage.selection.length, ); if (isLoading === false) { try { @@ -133,13 +134,20 @@ const safeRun = async (settings: PluginSettings) => { const standardMode = async () => { console.log("[DEBUG] standardMode - Starting standard mode initialization"); figma.showUI(__html__, { width: 450, height: 700, themeColors: true }); - await initSettings(); + let initialized = false; + const initializeOnce = async () => { + if (initialized) { + return; + } + initialized = true; + await initSettings(); + }; // Listen for selection changes figma.on("selectionchange", () => { console.log( - "[DEBUG] selectionchange event - New selection:", - figma.currentPage.selection, + "[DEBUG] selectionchange event - New selection count:", + figma.currentPage.selection.length, ); safeRun(userPluginSettings); }); @@ -156,9 +164,14 @@ const standardMode = async () => { }); figma.ui.onmessage = async (msg) => { - console.log("[DEBUG] figma.ui.onmessage", msg); + console.log( + "[DEBUG] figma.ui.onmessage", + msg?.type ? `type=${msg.type}` : "unknown type", + ); - if (msg.type === "pluginSettingWillChange") { + if (msg.type === "ui-ready") { + await initializeOnce(); + } else if (msg.type === "pluginSettingWillChange") { const { key, value } = msg as SettingWillChangeMessage; console.log(`[DEBUG] Setting changed: ${key} = ${value}`); (userPluginSettings as any)[key] = value; @@ -214,7 +227,11 @@ const standardMode = async () => { const nodeJson = result; - console.log("[DEBUG] Exported node JSON:", nodeJson); + console.log( + "[DEBUG] Exported node JSON:", + `jsonCount=${result.json?.length ?? 0}`, + `newConversionCount=${result.newConversion?.length ?? 0}`, + ); // Send the JSON data back to the UI figma.ui.postMessage({ @@ -234,14 +251,13 @@ const codegenMode = async () => { "generate", async ({ language, node }: CodegenEvent): Promise => { console.log( - `[DEBUG] codegen.generate - Language: ${language}, Node:`, - node, + `[DEBUG] codegen.generate - Language: ${language}, Node: id=${node.id}, type=${node.type}`, ); const convertedSelection = await nodesToJSON([node], userPluginSettings); console.log( - "[DEBUG] codegen.generate - Converted selection:", - convertedSelection, + "[DEBUG] codegen.generate - Converted selection count:", + convertedSelection.length, ); switch (language) { diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 80d1ebf4..56451aba 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -27,12 +27,23 @@ interface AppState { } const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; +const isDarkFigmaBackground = (background: string) => { + const value = background.trim().toLowerCase(); + + return Boolean( + value && + value !== "#fff" && + value !== "#ffffff" && + value !== "rgb(255, 255, 255)" && + value !== "rgba(255, 255, 255, 1)", + ); +}; export default function App() { const [state, setState] = useState({ code: "", selectedFramework: "HTML", - isLoading: false, + isLoading: true, htmlPreview: emptyPreview, settings: null, colors: [], @@ -69,7 +80,7 @@ export default function App() { })); break; - case "pluginSettingChanged": + case "pluginSettingsChanged": const settingsMessage = untypedMessage as SettingsChangedMessage; setState((prevState) => ({ ...prevState, @@ -117,6 +128,10 @@ export default function App() { }; }, []); + useEffect(() => { + parent.postMessage({ pluginMessage: { type: "ui-ready" } }, "*"); + }, []); + const handleFrameworkChange = (updatedFramework: Framework) => { if (updatedFramework !== state.selectedFramework) { setState((prevState) => ({ @@ -131,7 +146,7 @@ export default function App() { }; const handlePreferencesChange = ( key: keyof PluginSettings, - value: boolean | string | number, + value: PluginSettings[keyof PluginSettings], ) => { if (state.settings && state.settings[key] === value) { // do nothing @@ -140,10 +155,12 @@ export default function App() { } }; - const darkMode = figmaColorBgValue !== "#ffffff"; + const darkMode = isDarkFigmaBackground(figmaColorBgValue); return ( -
+
0) { + console.log("[debug] initial node summary", { + id: nodes[0].id, + type: nodes[0].type, + name: nodes[0].name, + }); + } console.log( `[benchmark][inside nodesToJSON] JSON_REST_V1 export: ${Date.now() - exportJsonStart}ms`, diff --git a/packages/backend/src/altNodes/oldAltConversion.ts b/packages/backend/src/altNodes/oldAltConversion.ts index 3cfdf5c2..02a1244a 100644 --- a/packages/backend/src/altNodes/oldAltConversion.ts +++ b/packages/backend/src/altNodes/oldAltConversion.ts @@ -42,6 +42,13 @@ const canBeFlattened = isTypeOrGroupOfTypes([ export const convertNodeToAltNode = (parent: ParentNode | null) => (node: SceneNode): SceneNode => { + if ((node as any).type === "SLOT") { + const slotNode = node as SceneNode & ChildrenMixin; + const group = cloneNode(slotNode, parent); + const groupChildren = oldConvertNodesToAltNodes(slotNode.children, group); + return assignChildren(groupChildren, group); + } + const type = node.type; switch (type) { // Standard nodes @@ -143,8 +150,6 @@ export const cloneNode = ( altNode.styledTextSegments = globalTextStyleSegments[node.id]; } - console.log("altnode:", altNode.parent, cloned.parent); - return altNode; }; diff --git a/packages/backend/src/api_types.ts b/packages/backend/src/api_types.ts index b5657707..46327862 100644 --- a/packages/backend/src/api_types.ts +++ b/packages/backend/src/api_types.ts @@ -732,6 +732,7 @@ export type IsLayerTrait = { | SectionNode | ShapeWithTextNode | SliceNode + | SlotNode | StarNode | StickyNode | TableNode @@ -798,6 +799,7 @@ export type IsLayerTrait = { | SectionNode | ShapeWithTextNode | SliceNode + | SlotNode | StarNode | StickyNode | TableNode @@ -960,6 +962,13 @@ export type IsLayerTrait = { */ type: 'SLICE' } & IsLayerTrait + + export type SlotNode = { + /** + * The type of this node, represented by the string literal "SLOT" + */ + type: 'SLOT' + } & FrameTraits export type InstanceNode = { /** @@ -2067,7 +2076,12 @@ export type IsLayerTrait = { /** * Component property type. */ - export type ComponentPropertyType = 'BOOLEAN' | 'INSTANCE_SWAP' | 'TEXT' | 'VARIANT' + export type ComponentPropertyType = + | 'BOOLEAN' + | 'INSTANCE_SWAP' + | 'TEXT' + | 'VARIANT' + | 'SLOT' /** * Instance swap preferred value. @@ -2096,7 +2110,7 @@ export type IsLayerTrait = { /** * Initial value of this property for instances. */ - defaultValue: boolean | string + defaultValue: boolean | string | string[] /** * All possible values for this property. Only exists on VARIANT properties. @@ -2121,7 +2135,7 @@ export type IsLayerTrait = { /** * Value of the property for this component instance. */ - value: boolean | string + value: boolean | string | string[] /** * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. @@ -6896,4 +6910,4 @@ export type IsLayerTrait = { * A dimension to group returned analytics data by. */ group_by: 'variable' | 'file' - } \ No newline at end of file + } diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 38054965..9b4518fb 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -7,7 +7,7 @@ import { clearWarnings, warnings, } from "./common/commonConversionWarnings"; -import { postConversionComplete, postEmptyMessage } from "./messaging"; +import { postConversionComplete, postEmptyMessage, postError } from "./messaging"; import { PluginSettings } from "types"; import { convertToCode } from "./common/retrieveUI/convertToCode"; import { generateHTMLPreview } from "./html/htmlMain"; @@ -35,17 +35,54 @@ export const run = async (settings: PluginSettings) => { return; } + const MAX_NODE_COUNT_PREVIEW = 1200; + const MAX_NODE_COUNT_HARD = 4000; + const countNodes = (nodes: ReadonlyArray) => { + let count = 0; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop()!; + count += 1; + if ("children" in node && Array.isArray(node.children)) { + for (const child of node.children) { + stack.push(child); + } + } + } + return count; + }; + + const nodeCount = countNodes(selection); + if (nodeCount > MAX_NODE_COUNT_HARD) { + postError( + `Selection too large (${nodeCount} nodes). Please select a smaller frame.`, + ); + return; + } + const skipHeavyUI = nodeCount > MAX_NODE_COUNT_PREVIEW; + if (skipHeavyUI) { + addWarning( + `Large selection (${nodeCount} nodes). HTML preview and colors are disabled to avoid memory issues.`, + ); + } + // Timing with Date.now() instead of console.time const nodeToJSONStart = Date.now(); let convertedSelection: any; if (useOldPluginVersion2025) { convertedSelection = oldConvertNodesToAltNodes(selection, null); - console.log("convertedSelection", convertedSelection); + console.log( + "[debug] convertedSelection count (old conversion):", + convertedSelection.length, + ); } else { convertedSelection = await nodesToJSON(selection, settings); console.log(`[benchmark] nodesToJSON: ${Date.now() - nodeToJSONStart}ms`); - console.log("nodeJson", convertedSelection); + console.log( + "[debug] convertedSelection count:", + convertedSelection.length, + ); // const removeParentRecursive = (obj: any): any => { // if (Array.isArray(obj)) { // return obj.map(removeParentRecursive); @@ -63,7 +100,14 @@ export const run = async (settings: PluginSettings) => { // console.log("nodeJson without parent refs:", removeParentRecursive(convertedSelection)); } - console.log("[debug] convertedSelection", { ...convertedSelection[0] }); + if (convertedSelection.length > 0) { + console.log("[debug] first convertedSelection summary:", { + id: convertedSelection[0]?.id, + type: convertedSelection[0]?.type, + name: convertedSelection[0]?.name, + childCount: convertedSelection[0]?.children?.length ?? 0, + }); + } // ignore when nothing was selected // If the selection was empty, the converted selection will also be empty. @@ -78,18 +122,24 @@ export const run = async (settings: PluginSettings) => { `[benchmark] convertToCode: ${Date.now() - convertToCodeStart}ms`, ); - const generatePreviewStart = Date.now(); - const htmlPreview = await generateHTMLPreview(convertedSelection, settings); - console.log( - `[benchmark] generateHTMLPreview: ${Date.now() - generatePreviewStart}ms`, - ); + let htmlPreview = { size: { width: 0, height: 0 }, content: "" }; + let colors: Awaited> = []; + let gradients: Awaited> = []; - const colorPanelStart = Date.now(); - const colors = await retrieveGenericSolidUIColors(framework); - const gradients = await retrieveGenericLinearGradients(framework); - console.log( - `[benchmark] color and gradient panel: ${Date.now() - colorPanelStart}ms`, - ); + if (!skipHeavyUI) { + const generatePreviewStart = Date.now(); + htmlPreview = await generateHTMLPreview(convertedSelection, settings); + console.log( + `[benchmark] generateHTMLPreview: ${Date.now() - generatePreviewStart}ms`, + ); + + const colorPanelStart = Date.now(); + colors = await retrieveGenericSolidUIColors(framework); + gradients = await retrieveGenericLinearGradients(framework); + console.log( + `[benchmark] color and gradient panel: ${Date.now() - colorPanelStart}ms`, + ); + } console.log( `[benchmark] total generation time: ${Date.now() - nodeToJSONStart}ms`, ); diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts index e71fd588..3f858262 100644 --- a/packages/backend/src/common/commonFormatAttributes.ts +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -20,6 +20,9 @@ export const formatStyleAttribute = ( export const formatDataAttribute = (label: string, value?: string) => ` data-${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; +export const formatTwigAttribute = (label: string, value?: string) => + ['.', '_'].includes(label.charAt(0)) ? '' : (` ${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`); + export const formatClassAttribute = ( classes: string[], isJSX: boolean, diff --git a/packages/backend/src/common/images.ts b/packages/backend/src/common/images.ts index 0d91d792..89d510c1 100644 --- a/packages/backend/src/common/images.ts +++ b/packages/backend/src/common/images.ts @@ -7,7 +7,6 @@ export const PLACEHOLDER_IMAGE_DOMAIN = "https://placehold.co"; const createCanvasImageUrl = (width: number, height: number): string => { // Check if we're in a browser environment - console.log("typeof document", typeof document); if (typeof document === "undefined" || typeof window === "undefined") { // Fallback for non-browser environments return `${PLACEHOLDER_IMAGE_DOMAIN}/${width}x${height}`; diff --git a/packages/backend/src/common/numToAutoFixed.ts b/packages/backend/src/common/numToAutoFixed.ts index 2f10a4c7..d18f2f71 100644 --- a/packages/backend/src/common/numToAutoFixed.ts +++ b/packages/backend/src/common/numToAutoFixed.ts @@ -45,7 +45,6 @@ export const generateWidgetCode = ( properties: Record, positionedValues?: string[], ): string => { - console.log("properties", properties); const propertiesArray = Object.entries(properties) .filter(([, value]) => { if (Array.isArray(value)) { diff --git a/packages/backend/src/common/parseJSX.ts b/packages/backend/src/common/parseJSX.ts index f18fd091..5445679d 100644 --- a/packages/backend/src/common/parseJSX.ts +++ b/packages/backend/src/common/parseJSX.ts @@ -1,3 +1,4 @@ +import { encode } from "html-entities"; import { numberToFixedString } from "./numToAutoFixed"; export const formatWithJSX = ( @@ -40,3 +41,10 @@ export const formatMultipleJSX = ( .filter(([key, value]) => value) .map(([key, value]) => formatWithJSX(key, isJsx, value!)) .join(isJsx ? ", " : "; "); + +export const escapeJSXText = (text: string): string => { + return encode(text, { level: "html5" }) + // process JSX curly braces + .replace(/\{/g, "{") + .replace(/\}/g, "}"); +}; diff --git a/packages/backend/src/compose/composeMain.ts b/packages/backend/src/compose/composeMain.ts index d5e97a7e..ebef0efd 100644 --- a/packages/backend/src/compose/composeMain.ts +++ b/packages/backend/src/compose/composeMain.ts @@ -153,7 +153,7 @@ const composeWidgetGenerator = ( const visibleSceneNode = getVisibleNodes(sceneNode); visibleSceneNode.forEach((node) => { - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": case "STAR": @@ -168,6 +168,7 @@ const composeWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(composeFrame(node)); break; case "SECTION": diff --git a/packages/backend/src/flutter/flutterMain.ts b/packages/backend/src/flutter/flutterMain.ts index 12291f37..26a472bd 100644 --- a/packages/backend/src/flutter/flutterMain.ts +++ b/packages/backend/src/flutter/flutterMain.ts @@ -96,7 +96,7 @@ const flutterWidgetGenerator = ( const visibleSceneNode = getVisibleNodes(sceneNode); visibleSceneNode.forEach((node) => { - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": case "STAR": @@ -111,6 +111,7 @@ const flutterWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(flutterFrame(node)); break; case "SECTION": diff --git a/packages/backend/src/html/builderImpl/htmlShadow.ts b/packages/backend/src/html/builderImpl/htmlShadow.ts index 3f13b0df..9f829bdd 100644 --- a/packages/backend/src/html/builderImpl/htmlShadow.ts +++ b/packages/backend/src/html/builderImpl/htmlShadow.ts @@ -16,29 +16,34 @@ export const htmlShadow = (node: BlendMixin): string => { ); // simple shadow from tailwind if (shadowEffects.length > 0) { - const shadow = shadowEffects[0]; - let x = 0; - let y = 0; - let blur = 0; - let spread = ""; - let inner = ""; - let color = ""; + const shadows: string[] = []; - if (shadow.type === "DROP_SHADOW" || shadow.type === "INNER_SHADOW") { - x = shadow.offset.x; - y = shadow.offset.y; - blur = shadow.radius; - spread = shadow.spread ? `${shadow.spread}px ` : ""; - inner = shadow.type === "INNER_SHADOW" ? " inset" : ""; - color = htmlColor(shadow.color, shadow.color.a); - } else if (shadow.type === "LAYER_BLUR") { - x = shadow.radius; - y = shadow.radius; - blur = shadow.radius; - } + shadowEffects.forEach((shadow) => { + let x = 0; + let y = 0; + let blur = 0; + let spread = ""; + let inner = ""; + let color = ""; + + if (shadow.type === "DROP_SHADOW" || shadow.type === "INNER_SHADOW") { + x = shadow.offset.x; + y = shadow.offset.y; + blur = shadow.radius; + spread = shadow.spread ? `${shadow.spread}px ` : ""; + inner = shadow.type === "INNER_SHADOW" ? " inset" : ""; + color = htmlColor(shadow.color, shadow.color.a); + } else if (shadow.type === "LAYER_BLUR") { + x = shadow.radius; + y = shadow.radius; + blur = shadow.radius; + } + + shadows.push(`${x}px ${y}px ${blur}px ${spread}${color}${inner}`); + }); // Return box-shadow in the desired format - return `${x}px ${y}px ${blur}px ${spread}${color}${inner}`; + return shadows.join(", "); } } return ""; diff --git a/packages/backend/src/html/htmlDefaultBuilder.ts b/packages/backend/src/html/htmlDefaultBuilder.ts index 96d6538f..e2ff6a3c 100644 --- a/packages/backend/src/html/htmlDefaultBuilder.ts +++ b/packages/backend/src/html/htmlDefaultBuilder.ts @@ -32,6 +32,7 @@ import { cssCollection, generateUniqueClassName, stylesToCSS, + getComponentName, } from "./htmlMain"; export class HtmlDefaultBuilder { @@ -62,6 +63,11 @@ export class HtmlDefaultBuilder { return this.settings.htmlGenerationMode === "svelte"; } + get needsJSXTextEscaping() { + const mode = this.settings.htmlGenerationMode; + return mode === "jsx" || mode === "styled-components" || mode === "svelte"; + } + get useStyledComponents() { return this.settings.htmlGenerationMode === "styled-components"; } @@ -518,14 +524,15 @@ export class HtmlDefaultBuilder { element = "img"; } + const nodeName = (this.node as any).uniqueName || this.node.name; + + const componentName = getComponentName(nodeName, this.cssClassName, element); + cssCollection[this.cssClassName] = { styles: cssStyles, - nodeName: - (this.node as any).uniqueName || - this.node.name?.replace(/[^a-zA-Z0-9]/g, "") || - undefined, nodeType: this.node.type, element: element, + componentName: componentName, }; } } diff --git a/packages/backend/src/html/htmlMain.ts b/packages/backend/src/html/htmlMain.ts index 77c2022f..d91d82c7 100644 --- a/packages/backend/src/html/htmlMain.ts +++ b/packages/backend/src/html/htmlMain.ts @@ -42,9 +42,9 @@ export type HtmlGenerationMode = interface CSSCollection { [className: string]: { styles: string[]; - nodeName?: string; nodeType?: string; element?: string; // Base HTML element to use + componentName: string; // Required for type safety, only used in styled-components mode }; } @@ -101,16 +101,13 @@ export function stylesToCSS(styles: string[], isJSX: boolean): string[] { // Get proper component name from node info export function getComponentName( - node: any, - className?: string, - nodeType = "div", + nodeName: string | undefined, + className: string, + nodeType: string, ): string { // Start with Styled prefix let name = "Styled"; - // Use uniqueName if available, otherwise use name - const nodeName: string = node.uniqueName || node.name; - // Try to use node name first if (nodeName && nodeName.length > 0) { // Clean up the node name and capitalize first letter @@ -157,17 +154,12 @@ export function generateStyledComponents(): string { const components: string[] = []; Object.entries(cssCollection).forEach( - ([className, { styles, nodeName, nodeType, element }]) => { + ([className, { styles, componentName, element, nodeType }]) => { // Skip if no styles if (!styles.length) return; // Determine base HTML element - defaults to div const baseElement = element || (nodeType === "TEXT" ? "p" : "div"); - const componentName = getComponentName( - { name: nodeName }, - className, - baseElement, - ); const styledComponent = `const ${componentName} = styled.${baseElement}\` ${styles.join(";\n ")}${styles.length ? ";" : ""} @@ -401,7 +393,7 @@ const convertNode = (settings: HTMLSettings) => async (node: SceneNode) => { } } - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": return await htmlContainer(node, "", [], settings); @@ -411,6 +403,7 @@ const convertNode = (settings: HTMLSettings) => async (node: SceneNode) => { case "COMPONENT": case "INSTANCE": case "COMPONENT_SET": + case "SLOT": return await htmlFrame(node, settings); case "SECTION": return await htmlSection(node, settings); @@ -489,31 +482,29 @@ const htmlText = (node: TextNode, settings: HTMLSettings): string => { // For styled-components mode if (mode === "styled-components") { - const componentName = layoutBuilder.cssClassName - ? getComponentName(node, layoutBuilder.cssClassName, "p") - : getComponentName(node, undefined, "p"); + // Build wrapper to store in cssCollection + layoutBuilder.build(); - if (styledHtml.length === 1) { - return `\n<${componentName}>${styledHtml[0].text}`; - } else { - const content = styledHtml - .map((style) => { - const tag = - style.openTypeFeatures.SUBS === true - ? "sub" - : style.openTypeFeatures.SUPS === true - ? "sup" - : "span"; - - if (style.componentName) { - return `<${style.componentName}>${style.text}`; - } - return `<${tag}>${style.text}`; - }) - .join(""); - - return `\n<${componentName}>${content}`; - } + const wrapperComponentName = + cssCollection[layoutBuilder.cssClassName!]?.componentName || "div"; + + const content = styledHtml + .map((style) => { + const tag = + style.openTypeFeatures.SUBS === true + ? "sub" + : style.openTypeFeatures.SUPS === true + ? "sup" + : "span"; + + if (style.componentName) { + return `<${style.componentName}>${style.text}`; + } + return `<${tag}>${style.text}`; + }) + .join(""); + + return `\n<${wrapperComponentName}>${content}`; } // Standard HTML/CSS approach for HTML, React or Svelte @@ -618,7 +609,6 @@ const htmlContainer = async ( imgUrl = (await exportNodeAsBase64PNG(altNode, hasChildren)) ?? ""; } else { imgUrl = getPlaceholderImage(node.width, node.height); - console.log("imgUrl", imgUrl); } if (hasChildren) { @@ -640,13 +630,16 @@ const htmlContainer = async ( // For styled-components mode if (mode === "styled-components" && builder.cssClassName) { - const componentName = getComponentName(node, builder.cssClassName); + const componentName = cssCollection[builder.cssClassName].componentName; - if (children) { - return `\n<${componentName}>${indentString(children)}\n`; - } else { - return `\n<${componentName} ${src}/>`; + if (componentName) { + if (children) { + return `\n<${componentName}>${indentString(children)}\n`; + } else { + return `\n<${componentName} ${src}/>`; + } } + // fallback to standard HTML if no component was created } // Standard HTML approach for HTML, React, or Svelte diff --git a/packages/backend/src/html/htmlTextBuilder.ts b/packages/backend/src/html/htmlTextBuilder.ts index 2181cc4f..ebb62794 100644 --- a/packages/backend/src/html/htmlTextBuilder.ts +++ b/packages/backend/src/html/htmlTextBuilder.ts @@ -1,4 +1,4 @@ -import { formatMultipleJSX, formatWithJSX } from "../common/parseJSX"; +import { formatMultipleJSX, formatWithJSX, escapeJSXText } from "../common/parseJSX"; import { HtmlDefaultBuilder } from "./htmlDefaultBuilder"; import { htmlColorFromFills } from "./builderImpl/htmlColor"; import { @@ -70,7 +70,11 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { this.isJSX, ); - const charsWithLineBreak = segment.characters.split("\n").join("
"); + let chars = segment.characters; + if (this.needsJSXTextEscaping) { + chars = escapeJSXText(chars); + } + const charsWithLineBreak = chars.split("\n").join("
"); const result: any = { style: styleAttributes, text: charsWithLineBreak, @@ -104,20 +108,18 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { // In both modes, use span for text segments to avoid selector conflicts const elementTag = "span"; + const componentName = getComponentName(segmentName, className, elementTag); + // Store in cssCollection with consistent metadata cssCollection[className] = { styles: cssStyles, - nodeName: segmentName, nodeType: "TEXT", element: elementTag, + componentName: componentName, }; if (mode === "styled-components") { - result.componentName = getComponentName( - { name: segmentName }, - className, - elementTag, - ); + result.componentName = componentName; } } diff --git a/packages/backend/src/messaging.ts b/packages/backend/src/messaging.ts index 0747a435..3476ed3b 100644 --- a/packages/backend/src/messaging.ts +++ b/packages/backend/src/messaging.ts @@ -7,7 +7,16 @@ import { SettingsChangedMessage, } from "types"; -export const postBackendMessage = figma.ui.postMessage; +const safePostMessage = (message: unknown) => { + try { + figma.ui.postMessage(message); + } catch (error) { + // Avoid crashing in codegen/no-UI environments. + console.warn("[backend] postMessage failed (no UI?)"); + } +}; + +export const postBackendMessage = safePostMessage; export const postEmptyMessage = () => postBackendMessage({ type: "empty" } as EmptyMessage); diff --git a/packages/backend/src/swiftui/swiftuiMain.ts b/packages/backend/src/swiftui/swiftuiMain.ts index 28b2b7db..b864df34 100644 --- a/packages/backend/src/swiftui/swiftuiMain.ts +++ b/packages/backend/src/swiftui/swiftuiMain.ts @@ -70,7 +70,7 @@ const swiftuiWidgetGenerator = ( let comp: string[] = []; visibleSceneNode.forEach((node) => { - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": case "LINE": @@ -84,6 +84,7 @@ const swiftuiWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(swiftuiFrame(node, indentLevel)); break; case "TEXT": @@ -249,7 +250,7 @@ const widgetGeneratorWithLimits = ( let strBuilder = ""; const slicedChildren = node.children.slice(0, 100); - // I believe no one should have more than 100 items in a single nesting level. If you do, please email me. + // I believe no one should have more than 100 items in a single nesting level. if (node.children.length > 100) { strBuilder += `\n// SwiftUI has a 10 item limit in Stacks. By grouping them, it can grow even more. // It seems, however, that you have more than 100 items at the same level. Wow! diff --git a/packages/backend/src/tailwind/builderImpl/tailwindColor.ts b/packages/backend/src/tailwind/builderImpl/tailwindColor.ts index 72f85b79..158bd976 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindColor.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindColor.ts @@ -81,7 +81,15 @@ export const tailwindSolidColor = ( } // Original implementation for non-variable colors or when not using var syntax - const { colorName } = getColorInfo(fill); + const { colorName, colorType } = getColorInfo(fill); + + // Don't add opacity modifier for variable colors - the alpha is already baked + // into the variable definition. Adding /50 to a variable that's already + // defined with alpha would incorrectly compound the opacity. + if (colorType === "variable") { + return `${kind}-${colorName}`; + } + const effectiveOpacity = calculateEffectiveOpacity(fill); const opacity = effectiveOpacity !== 1.0 ? `/${nearestOpacity(effectiveOpacity)}` : ""; @@ -101,7 +109,14 @@ export const tailwindGradientStop = ( stop: ColorStop, parentOpacity: number = 1.0, ): string => { - const { colorName } = getColorInfo(stop); + const { colorName, colorType } = getColorInfo(stop); + + // Don't add opacity modifier for variable colors - the alpha is already baked + // into the variable definition + if (colorType === "variable") { + return colorName; + } + const effectiveOpacity = calculateEffectiveOpacity(stop, parentOpacity); const opacity = effectiveOpacity !== 1.0 ? `/${nearestOpacity(effectiveOpacity)}` : ""; diff --git a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts index 8c5ef14e..eae314c6 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -27,6 +27,7 @@ import { import { pxToBlur } from "./conversionTables"; import { formatDataAttribute, + formatTwigAttribute, getClassLabel, } from "../common/commonFormatAttributes"; import { TailwindColorType, TailwindSettings } from "types"; @@ -53,6 +54,14 @@ export class TailwindDefaultBuilder { return this.settings.tailwindGenerationMode === "jsx"; } + get needsJSXTextEscaping() { + return this.isJSX; + } + + get isTwigComponent() { + return this.settings.tailwindGenerationMode === "twig" && this.node.type === "INSTANCE" + } + constructor(node: SceneNode, settings: TailwindSettings) { this.node = node; this.settings = settings; @@ -272,13 +281,15 @@ export class TailwindDefaultBuilder { if ("componentProperties" in this.node && this.node.componentProperties) { Object.entries(this.node.componentProperties) ?.map((prop) => { - if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN") { + if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN" || (this.isTwigComponent && prop[1].type === "TEXT")) { const cleanName = prop[0] .split("#")[0] .replace(/\s+/g, "-") .toLowerCase(); - return formatDataAttribute(cleanName, String(prop[1].value)); + return this.isTwigComponent + ? formatTwigAttribute(cleanName, String(prop[1].value)) + : formatDataAttribute(cleanName, String(prop[1].value)); } return ""; }) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index b668ef1a..ea4243c3 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -54,7 +54,7 @@ const convertNode = } } - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": return tailwindContainer(node, "", "", settings); @@ -64,6 +64,7 @@ const convertNode = case "COMPONENT": case "INSTANCE": case "COMPONENT_SET": + case "SLOT": return tailwindFrame(node, settings); case "TEXT": return tailwindText(node, settings); @@ -172,6 +173,11 @@ const tailwindFrame = async ( node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, settings: TailwindSettings, ): Promise => { + // Check if this is an instance and should be rendered as a Twig component + if (node.type === "INSTANCE" && isTwigComponentNode(node)) { + return tailwindTwigComponentInstance(node, settings); + } + const childrenStr = await tailwindWidgetGenerator(node.children, settings); const clipsContentClass = @@ -192,6 +198,57 @@ const tailwindFrame = async ( return tailwindContainer(node, childrenStr, combinedProps, settings); }; + +// Helper function to generate Twig component syntax for component instances +const tailwindTwigComponentInstance = async ( + node: InstanceNode, + settings: TailwindSettings, +): Promise => { + // Extract component name from the instance + const componentName = extractComponentName(node); + + // Get component properties if needed + const builder = new TailwindDefaultBuilder(node, settings) + // .commonPositionStyles() + // .commonShapeStyles() + ; + + const attributes = builder.build(); + + // If we have children, process them + let childrenStr = ""; + + const embeddableChildren = node.children ? node.children.filter((n) => isTwigContentNode(n)) : []; + + if (embeddableChildren.length > 0) { + // We keep embedded components and Frame named "TwigContent" + childrenStr = await tailwindWidgetGenerator(embeddableChildren, settings); + return `\n${indentString(childrenStr)}\n`; + } else { + // Self-closing tag if no children + return `\n`; + } +}; + +const isTwigComponentNode = (node: SceneNode): boolean => { + return localTailwindSettings.tailwindGenerationMode === "twig" && node.type === "INSTANCE" && !extractComponentName(node).startsWith("HTML:") && !isTwigContentNode(node); +} + +const isTwigContentNode = (node: SceneNode): boolean => { + return node.type === "INSTANCE" && node.name.startsWith("TwigContent"); +} + +// Helper function to extract component name from an instance +const extractComponentName = (node: InstanceNode): string => { + // Try to get name from mainComponent if available + if (node.mainComponent) { + return node.mainComponent.name; + } + + // Fallback to node name if mainComponent is not available + return node.name; +}; + export const tailwindContainer = ( node: SceneNode & SceneNodeMixin & diff --git a/packages/backend/src/tailwind/tailwindTextBuilder.ts b/packages/backend/src/tailwind/tailwindTextBuilder.ts index 6c106d07..750ba8f8 100644 --- a/packages/backend/src/tailwind/tailwindTextBuilder.ts +++ b/packages/backend/src/tailwind/tailwindTextBuilder.ts @@ -2,6 +2,7 @@ import { commonLetterSpacing, commonLineHeight, } from "../common/commonTextHeightSpacing"; +import { escapeJSXText } from "../common/parseJSX"; import { tailwindColorFromFills } from "./builderImpl/tailwindColor"; import { pxToFontSize, @@ -60,7 +61,11 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { .filter(Boolean) .join(" "); - const charsWithLineBreak = segment.characters.split("\n").join("
"); + let chars = segment.characters; + if (this.needsJSXTextEscaping) { + chars = escapeJSXText(chars); + } + const charsWithLineBreak = chars.split("\n").join("
"); return { style: styleClasses, text: charsWithLineBreak, @@ -115,17 +120,29 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { if (baseFontFamily && fontName.family.toLowerCase() === baseFontFamily.toLowerCase()) { return ""; } - - // Check if the font is in one of the Tailwind default font stacks - if (config.fontFamily.sans.includes(fontName.family)) { - return "font-sans"; - } - if (config.fontFamily.serif.includes(fontName.family)) { - return "font-serif"; - } - if (config.fontFamily.mono.includes(fontName.family)) { - return "font-mono"; + + const fontFamilyCustomConfig = localTailwindSettings.fontFamilyCustomConfig; + + if (fontFamilyCustomConfig) { + // Check if current font is part of custom tailwind config + for (const family in fontFamilyCustomConfig) { + if (fontFamilyCustomConfig[family].includes(fontName.family)) { + return `font-${family}` + } + } + } else { + // Check if the font is in one of the Tailwind default font stacks + if (config.fontFamily.sans.includes(fontName.family)) { + return "font-sans"; + } + if (config.fontFamily.serif.includes(fontName.family)) { + return "font-serif"; + } + if (config.fontFamily.mono.includes(fontName.family)) { + return "font-mono"; + } } + const underscoreFontName = fontName.family.replace(/\s/g, "_"); return "font-['" + underscoreFontName + "']"; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b7f01d7e..0e3521d1 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -4,10 +4,10 @@ "main": "index.js", "license": "MIT", "dependencies": { - "eslint-config-next": "^15.3.4", - "eslint-config-prettier": "^10.1.5", - "eslint-config-turbo": "^2.5.4", - "eslint-plugin-react": "7.37.4" + "eslint-config-next": "^16.2.6", + "eslint-config-prettier": "^10.1.8", + "eslint-config-turbo": "^2.9.12", + "eslint-plugin-react": "7.37.5" }, "publishConfig": { "access": "public" diff --git a/packages/plugin-ui/package.json b/packages/plugin-ui/package.json index c97b914b..a2de4ef2 100644 --- a/packages/plugin-ui/package.json +++ b/packages/plugin-ui/package.json @@ -10,22 +10,24 @@ "lint": "eslint \"src/**/*.ts*\"" }, "dependencies": { - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", + "@base-ui/react": "^1.4.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "15.5.13", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "copy-to-clipboard": "^3.3.3", - "lucide-react": "^0.483.0", - "react": "^19.1.0", - "react-syntax-highlighter": "^15.6.1", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11" + "copy-to-clipboard": "^4.0.2", + "lucide-react": "^1.14.0", + "react": "^19.2.6", + "react-syntax-highlighter": "^16.1.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0" }, "devDependencies": { - "eslint": "^9.29.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.8.3" + "typescript": "^6.0.3" } } diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 89ceaaf3..abbfa24f 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -3,6 +3,7 @@ import Preview from "./components/Preview"; import GradientsPanel from "./components/GradientsPanel"; import ColorsPanel from "./components/ColorsPanel"; import CodePanel from "./components/CodePanel"; +import EmptyState from "./components/EmptyState"; import About from "./components/About"; import WarningsPanel from "./components/WarningsPanel"; import { @@ -18,9 +19,12 @@ import { selectPreferenceOptions, } from "./codegenPreferenceOptions"; import Loading from "./components/Loading"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { InfoIcon } from "lucide-react"; import React from "react"; +import { Button } from "./components/ui/button"; +import { ScrollArea } from "./components/ui/scroll-area"; +import { TooltipProvider } from "./components/ui/tooltip"; type PluginUIProps = { code: string; @@ -31,7 +35,7 @@ type PluginUIProps = { settings: PluginSettings | null; onPreferenceChanged: ( key: keyof PluginSettings, - value: boolean | string | number, + value: PluginSettings[keyof PluginSettings], ) => void; colors: SolidColorConversion[]; gradients: LinearGradientConversion[]; @@ -39,6 +43,7 @@ type PluginUIProps = { }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; +const LOADING_INDICATOR_DELAY_MS = 250; type FrameworkTabsProps = { frameworks: Framework[]; @@ -58,12 +63,14 @@ const FrameworkTabs = ({ return (
{frameworks.map((tab) => ( - + ))}
); @@ -79,6 +86,8 @@ const FrameworkTabs = ({ export const PluginUI = (props: PluginUIProps) => { const [showAbout, setShowAbout] = useState(false); + const [showLoading, setShowLoading] = useState(false); + const [hasHandledInitialLoad, setHasHandledInitialLoad] = useState(false); const [previewExpanded, setPreviewExpanded] = useState(false); const [previewViewMode, setPreviewViewMode] = useState< @@ -88,93 +97,128 @@ export const PluginUI = (props: PluginUIProps) => { "white", ); - if (props.isLoading) return ; + useEffect(() => { + if (!props.isLoading) { + setShowLoading(false); + setHasHandledInitialLoad(true); + return; + } + + if (hasHandledInitialLoad) { + setShowLoading(true); + return; + } + + // On plugin startup, the UI waits for a ready handshake before the first conversion. + // Delay the loader only for that initial pass to avoid a one-frame loading flash. + const timer = window.setTimeout(() => { + setShowLoading(true); + }, LOADING_INDICATOR_DELAY_MS); + + return () => window.clearTimeout(timer); + }, [props.isLoading]); + + if (props.isLoading) return showLoading ? : null; const isEmpty = props.code === ""; const warnings = props.warnings ?? []; return ( -
-
-
- - -
-
-
-
- {showAbout ? ( - - ) : ( -
- {isEmpty === false && props.htmlPreview && ( - - )} - - {warnings.length > 0 && } - - +
+
+
+ + +
+
+
+ + {showAbout ? ( + + ) : isEmpty ? ( +
+ +
+ ) : ( +
+ {props.htmlPreview && ( + + )} - {props.colors.length > 0 && ( - { - copy(value); - }} - /> - )} - - {props.gradients.length > 0 && ( - { - copy(value); - }} + {warnings.length > 0 && } + + - )} -
- )} + + {props.colors.length > 0 && ( +
+ { + copy(value); + }} + /> +
+ )} + + {props.gradients.length > 0 && ( +
+ { + copy(value); + }} + /> +
+ )} +
+ )} +
-
+ ); }; diff --git a/packages/plugin-ui/src/codegenPreferenceOptions.ts b/packages/plugin-ui/src/codegenPreferenceOptions.ts index 4b6a98a9..fed6d2d9 100644 --- a/packages/plugin-ui/src/codegenPreferenceOptions.ts +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -6,7 +6,7 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ propertyName: "useTailwind4", label: "Tailwind 4", description: "Enable Tailwind CSS version 4 features and syntax.", - isDefault: false, + isDefault: true, includedLanguages: ["Tailwind"], }, { @@ -83,6 +83,7 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ options: [ { label: "HTML", value: "html" }, { label: "React (JSX)", value: "jsx" }, + { label: "Twig", value: "twig" }, ], includedLanguages: ["Tailwind"], }, diff --git a/packages/plugin-ui/src/components/About.tsx b/packages/plugin-ui/src/components/About.tsx index e3bea967..25f28872 100644 --- a/packages/plugin-ui/src/components/About.tsx +++ b/packages/plugin-ui/src/components/About.tsx @@ -2,10 +2,8 @@ import { useState } from "react"; import { ArrowRightIcon, Code, - Github, Heart, Lock, - Mail, MessageCircle, Star, Zap, @@ -15,12 +13,15 @@ import { ToggleRight, } from "lucide-react"; import { PluginSettings } from "types"; +import { Button, buttonVariants } from "./ui/button"; +import { Card, CardContent } from "./ui/card"; +import { cn } from "../lib/utils"; type AboutProps = { useOldPluginVersion?: boolean; onPreferenceChanged: ( key: keyof PluginSettings, - value: boolean | string | number, + value: PluginSettings[keyof PluginSettings], ) => void; }; @@ -70,7 +71,7 @@ const About = ({ className="p-2 rounded-full bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" aria-label="GitHub Profile" > - + - - -
{/* Cards Section */}
{/* Privacy Policy Card */} -
-
-
- + + +
+
+ +
+

Privacy Policy

-

Privacy Policy

-
-

- This plugin is completely private. All of your design data is - processed locally in your browser and never leaves your computer. No - analytics, no data collection, no tracking. -

-
+

+ This plugin is completely private. All of your design data is + processed locally in your browser and never leaves your computer. + No analytics, no data collection, no tracking. +

+ + {/* Open Source Card */} -
-
-
- -
-

Open Source

-
-

- Figma to Code is completely open-source. Contributions, bug reports, - and feature requests are welcome! -

- - - View on GitHub - -
- - {/* Features Card */} -
-
-
- -
-

Features

-
-
    -
  • -
    - + + +
    +
    +
    - - Convert Figma designs to HTML, Tailwind, Flutter, and SwiftUI - -
  • -
  • -
    - -
    - Extract colors and gradients from your designs -
  • -
  • -
    - -
    - Get responsive code that matches your design -
  • -
-
- - {/* Contact Card */} -
-
-
- +

Open Source

-

Get in Touch

-
-

- Have feedback, questions, or need help? I'd love to hear from you! - Feel free to reach out through any of these channels: -

-
- - - bernaferrari2@gmail.com - +

+ Figma to Code is completely open-source. Contributions, bug + reports, and feature requests are welcome! +

- - Report an issue on GitHub + + View on GitHub -
-
+ + - {/* Debug Helper Card */} -
-
-
- + {/* Features Card */} + + +
+
+ +
+

Features

-

Debug Helper

-
-

- Having an issue? Help me debug by copying the JSON of your selected - elements. This can be attached when reporting issues. -

- +
    +
  • +
    + +
    + + Convert Figma designs to HTML, Tailwind, Flutter, and SwiftUI + +
  • +
  • +
    + +
    + Extract colors and gradients from your designs +
  • +
  • +
    + +
    + Get responsive code that matches your design +
  • +
+ + - {/* Hidden setting for using old plugin version */} -
- -

- The new version is up to 100x faster, but might still cause some - issues. If you encounter problems, you can switch to the old - version (and please report issues so they can be fixed). -

-
-
+ + + {/* Hidden setting for using old plugin version */} +
+ +

+ The new version is up to 100x faster, but might still cause some + issues. If you encounter problems, you can switch to the old + version (and please report issues so they can be fixed). +

+
+ +
{/* Footer */} @@ -266,6 +258,30 @@ const About = ({ ); }; +function GithubLogo({ + width = 18, + height = 18, + className, +}: { + width?: number; + height?: number; + className?: string; +}) { + return ( + + ); +} + function XLogo() { return ( void; } @@ -91,6 +91,7 @@ const CodePanel = (props: CodePanelProps) => { ? truncateCode(prefixedCode, initialLinesToShow) : prefixedCode; const showMoreButton = lineCount > initialLinesToShow; + const showCodeCopyButton = lineCount > 5; const handleButtonHover = () => setSyntaxHovered(true); const handleButtonLeave = () => setSyntaxHovered(false); @@ -132,10 +133,13 @@ const CodePanel = (props: CodePanelProps) => { }; }, [preferenceOptions, selectPreferenceOptions, selectedFramework]); + const hasSettingsBeforeStyling = + essentialPreferences.length > 0 || selectableSettingsFiltered.length > 0; + return (
-

+

Code

{!isCodeEmpty && ( @@ -160,7 +164,7 @@ const CodePanel = (props: CodePanelProps) => { {/* Framework-specific options */} {selectableSettingsFiltered.length > 0 && ( -
+

{selectedFramework} Options

@@ -187,25 +191,27 @@ const CodePanel = (props: CodePanelProps) => { {/* Styling preferences with custom prefix for Tailwind */} {(stylingPreferences.length > 0 || selectedFramework === "Tailwind") && ( - - {selectedFramework === "Tailwind" && ( - - )} - +
+ + {selectedFramework === "Tailwind" && ( + + )} + +
)}
)}
@@ -213,6 +219,17 @@ const CodePanel = (props: CodePanelProps) => { ) : ( <> + {showCodeCopyButton && ( +
+ +
+ )} +

diff --git a/packages/plugin-ui/src/components/CopyButton.tsx b/packages/plugin-ui/src/components/CopyButton.tsx index 666bb988..13b44a1f 100644 --- a/packages/plugin-ui/src/components/CopyButton.tsx +++ b/packages/plugin-ui/src/components/CopyButton.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Copy, Check } from "lucide-react"; import copy from "copy-to-clipboard"; import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; interface CopyButtonProps { value: string; @@ -18,77 +19,65 @@ export function CopyButton({ value, className, showLabel = true, - successDuration = 750, + successDuration = 1500, onMouseEnter, onMouseLeave, }: CopyButtonProps) { const [isCopied, setIsCopied] = useState(false); useEffect(() => { - if (isCopied) { - const timer = setTimeout(() => { - setIsCopied(false); - }, successDuration); - - return () => clearTimeout(timer); - } + if (!isCopied) return; + const timer = setTimeout(() => setIsCopied(false), successDuration); + return () => clearTimeout(timer); }, [isCopied, successDuration]); - const handleCopy = async () => { + const handleCopy = useCallback(() => { try { copy(value); setIsCopied(true); } catch (error) { console.error("Failed to copy text: ", error); } - }; + }, [value]); return ( - + {showLabel && {"Copy"}} + ); } diff --git a/packages/plugin-ui/src/components/CustomPrefixInput.tsx b/packages/plugin-ui/src/components/CustomPrefixInput.tsx index d397a4dd..43549176 100644 --- a/packages/plugin-ui/src/components/CustomPrefixInput.tsx +++ b/packages/plugin-ui/src/components/CustomPrefixInput.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; import { HelpCircle, Check } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface FormFieldProps { // Common props @@ -10,7 +11,7 @@ interface FormFieldProps { helpText?: string; // Validation props - type?: "text" | "number"; + type?: "text" | "number" | "json"; min?: number; max?: number; suffix?: string; @@ -50,6 +51,7 @@ const FormField = React.memo( const [hasError, setHasError] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const inputRef = useRef(null); + const textareaRef = useRef(null); // Update internal state when initialValue changes (from parent) useEffect(() => { @@ -106,6 +108,58 @@ const FormField = React.memo( return true; } + if (type === "json") { + // Check if the string is empty skip validation + if (!value.trim()) { + setHasError(false); + setErrorMessage(""); + return true; + } + + try { + // Try to parse the JSON + const config = JSON.parse(value); + + // Validate that the config is an object + if ( + typeof config !== "object" || + Array.isArray(config) || + config === null + ) { + throw new Error("Configuration must be a valid JSON object"); + } + + for (const item in config) { + if (!Array.isArray(config[item])) { + throw new Error( + `Key ${item} is not valid and should be an array`, + ); + } + config[item].forEach((val) => { + if (typeof val !== "string") { + throw new Error(`Values from Key ${item} should be string`); + } + }); + } + + // Additional validation could be added here based on expected structure + // For example, checking specific properties or types + + // If valid, update the preference + setHasError(false); + setErrorMessage(""); + return true; + } catch (error) { + // Handle parsing errors + console.error("Invalid JSON configuration:", error); + setHasError(true); + setErrorMessage(`Invalid JSON configuration: ${error}`); + // You could show an error message to the user here + // Or reset to default/previous value + return false; + } + } + return true; }; @@ -116,6 +170,15 @@ const FormField = React.memo( setHasChanges(newValue !== String(initialValue)); }; + const handleTextareaChange = ( + e: React.ChangeEvent, + ) => { + const newValue = e.target.value; + setInputValue(newValue); + validateInput(newValue); + setHasChanges(newValue !== String(initialValue)); + }; + const applyChanges = () => { if (hasError) return; @@ -147,6 +210,17 @@ const FormField = React.memo( } }; + const handleTextareaKeyDown = ( + e: React.KeyboardEvent, + ) => { + // Only apply changes on Ctrl+Enter or Command+Enter for textarea + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + applyChanges(); + textareaRef.current?.blur(); + } + }; + // Default preview transform for text prefixes const defaultPreviewTransform = (value: string, example: string) => (
@@ -171,13 +245,14 @@ const FormField = React.memo( {helpText && ( -
- -
- {helpText} -
-
-
+ + } + > + + + {helpText} + )} {showSuccess && ( @@ -190,25 +265,46 @@ const FormField = React.memo(
- setIsFocused(true)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - placeholder={placeholder} - className={`p-1.5 px-2.5 text-sm w-full transition-all focus:outline-hidden ${ - suffix ? "rounded-l-md" : "rounded-md" - } ${ - hasError - ? "border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20" - : isFocused - ? "border border-green-400 dark:border-green-600 ring-1 ring-green-300 dark:ring-green-800 bg-white dark:bg-neutral-800" - : "border border-gray-300 dark:border-gray-600 bg-white dark:bg-neutral-800 hover:border-gray-400 dark:hover:border-gray-500" - }`} - /> + {type === "json" ? ( +