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 48f981cf..0cee8e2d 100644 --- a/apps/debug/package.json +++ b/apps/debug/package.json @@ -11,21 +11,21 @@ }, "dependencies": { "backend": "workspace:*", - "next": "^15.2.4", + "next": "^16.2.6", "plugin-ui": "workspace:*", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.2.6", + "react-dom": "^19.2.6" }, "devDependencies": { - "@tailwindcss/postcss": "^4.0.17", - "@types/node": "^22.13.14", - "@types/react": "^19.0.12", - "@types/react-dom": "^19.0.4", + "@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.3", - "tailwindcss": "4.0.14", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.8.2" + "typescript": "^6.0.3" } } diff --git a/apps/plugin/package.json b/apps/plugin/package.json index 9eab29fa..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.109.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.6.2", - "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.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^3.0.2" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.0.17", - "@types/node": "^22.13.14", - "@types/react": "^19.0.12", - "@types/react-dom": "^19.0.4", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitejs/plugin-react-swc": "^3.8.1", - "concurrently": "^9.1.2", - "esbuild": "^0.25.1", + "@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.19", - "postcss": "^8.5.3", - "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.2", - "vite": "^5.4.15", - "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 60add1f9..1a4bd3da 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -5,6 +5,7 @@ import { tailwindMain, swiftuiMain, htmlMain, + composeMain, postSettingsChanged, } from "backend"; import { nodesToJSON } from "backend/src/altNodes/jsonNodeConversion"; @@ -12,6 +13,7 @@ import { retrieveGenericSolidUIColors } from "backend/src/common/retrieveUI/retr import { flutterCodeGenTextStyles } from "backend/src/flutter/flutterMain"; import { htmlCodeGenTextStyles } from "backend/src/html/htmlMain"; import { swiftUICodeGenTextStyles } from "backend/src/swiftui/swiftuiMain"; +import { composeCodeGenTextStyles } from "backend/src/compose/composeMain"; import { PluginSettings, SettingWillChangeMessage } from "types"; let userPluginSettings: PluginSettings; @@ -23,6 +25,7 @@ export const defaultPluginSettings: PluginSettings = { responsiveRoot: false, flutterGenerationMode: "snippet", swiftUIGenerationMode: "snippet", + composeGenerationMode: "snippet", roundTailwindValues: true, roundTailwindColors: true, useColorVariables: true, @@ -32,7 +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 @@ -83,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 { @@ -128,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); }); @@ -151,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; @@ -209,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({ @@ -229,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) { @@ -393,6 +414,22 @@ const codegenMode = async () => { language: "SWIFT", }, ]; + // case "compose": + // return [ + // { + // title: "Jetpack Compose", + // code: composeMain(convertedSelection, { + // ...userPluginSettings, + // composeGenerationMode: "snippet", + // }), + // language: "KOTLIN", + // }, + // { + // title: "Text Styles", + // code: composeCodeGenTextStyles(), + // language: "KOTLIN", + // }, + // ]; default: break; } 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 dcf61200..89d510c1 100644 --- a/packages/backend/src/common/images.ts +++ b/packages/backend/src/common/images.ts @@ -5,9 +5,52 @@ import { exportAsyncProxy } from "./exportAsyncProxy"; export const PLACEHOLDER_IMAGE_DOMAIN = "https://placehold.co"; +const createCanvasImageUrl = (width: number, height: number): string => { + // Check if we're in a browser environment + if (typeof document === "undefined" || typeof window === "undefined") { + // Fallback for non-browser environments + return `${PLACEHOLDER_IMAGE_DOMAIN}/${width}x${height}`; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + // Fallback if canvas context is not available + return `${PLACEHOLDER_IMAGE_DOMAIN}/${width}x${height}`; + } + + const fontSize = Math.max(12, Math.floor(width * 0.15)); + ctx.font = `bold ${fontSize}px Inter, Arial, Helvetica, sans-serif`; + ctx.fillStyle = "#888888"; + + const text = `${width} x ${height}`; + const textWidth = ctx.measureText(text).width; + const x = (width - textWidth) / 2; + const y = (height + fontSize) / 2; + + ctx.fillText(text, x, y); + + const image = canvas.toDataURL(); + const base64 = image.substring(22); + const byteCharacters = atob(base64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const file = new Blob([byteArray], { + type: "image/png;base64", + }); + return URL.createObjectURL(file); +}; + export const getPlaceholderImage = (w: number, h = -1) => { const _w = w.toFixed(0); const _h = (h < 0 ? w : h).toFixed(0); + return `${PLACEHOLDER_IMAGE_DOMAIN}/${_w}x${_h}`; }; 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/common/retrieveUI/convertToCode.ts b/packages/backend/src/common/retrieveUI/convertToCode.ts index fc96319e..e5c9c80f 100644 --- a/packages/backend/src/common/retrieveUI/convertToCode.ts +++ b/packages/backend/src/common/retrieveUI/convertToCode.ts @@ -1,4 +1,5 @@ import { PluginSettings } from "types"; +import { composeMain } from "../../compose/composeMain"; import { flutterMain } from "../../flutter/flutterMain"; import { htmlMain } from "../../html/htmlMain"; import { swiftuiMain } from "../../swiftui/swiftuiMain"; @@ -15,6 +16,8 @@ export const convertToCode = async ( return await flutterMain(nodes, settings); case "SwiftUI": return await swiftuiMain(nodes, settings); + case "Compose": + return composeMain(nodes, settings); case "HTML": default: return (await htmlMain(nodes, settings)).html; diff --git a/packages/backend/src/compose/builderImpl/composeAutoLayout.ts b/packages/backend/src/compose/builderImpl/composeAutoLayout.ts new file mode 100644 index 00000000..75d80fcf --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeAutoLayout.ts @@ -0,0 +1,111 @@ +export const getMainAxisAlignment = ( + node: InferredAutoLayoutResult, +): string => { + switch (node.primaryAxisAlignItems) { + case undefined: + case "MIN": + return "Arrangement.Start"; + case "CENTER": + return "Arrangement.Center"; + case "MAX": + return "Arrangement.End"; + case "SPACE_BETWEEN": + return "Arrangement.SpaceBetween"; + default: + return "Arrangement.Start"; + } +}; + +export const getCrossAxisAlignment = ( + node: InferredAutoLayoutResult, +): string => { + // For Row (horizontal layout), cross axis is vertical + // For Column (vertical layout), cross axis is horizontal + if (node.layoutMode === "HORIZONTAL") { + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "Alignment.Top"; + case "CENTER": + return "Alignment.CenterVertically"; + case "MAX": + return "Alignment.Bottom"; + case "BASELINE": + return "Alignment.CenterVertically"; // Compose doesn't have baseline alignment for Row + default: + return "Alignment.Top"; + } + } else { + // VERTICAL layout mode + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "Alignment.Start"; + case "CENTER": + return "Alignment.CenterHorizontally"; + case "MAX": + return "Alignment.End"; + case "BASELINE": + return "Alignment.CenterHorizontally"; // Baseline not applicable for Column + default: + return "Alignment.Start"; + } + } +}; + +export const getWrapAlignment = ( + node: InferredAutoLayoutResult, +): string => { + switch (node.primaryAxisAlignItems) { + case undefined: + case "MIN": + return "Arrangement.Start"; + case "CENTER": + return "Arrangement.Center"; + case "MAX": + return "Arrangement.End"; + case "SPACE_BETWEEN": + return "Arrangement.SpaceBetween"; + default: + return "Arrangement.Start"; + } +}; + +export const getWrapRunAlignment = ( + node: InferredAutoLayoutResult, +): string => { + if (node.counterAxisAlignContent === "SPACE_BETWEEN") { + return "Arrangement.SpaceBetween"; + } + + // For FlowRow/FlowColumn, the cross axis alignment depends on layout mode + if (node.layoutMode === "HORIZONTAL") { + // FlowRow - vertical alignment + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "Arrangement.Top"; + case "CENTER": + case "BASELINE": + return "Arrangement.Center"; + case "MAX": + return "Arrangement.Bottom"; + default: + return "Arrangement.Top"; + } + } else { + // FlowColumn - horizontal alignment + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "Arrangement.Start"; + case "CENTER": + case "BASELINE": + return "Arrangement.Center"; + case "MAX": + return "Arrangement.End"; + default: + return "Arrangement.Start"; + } + } +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composeBlend.ts b/packages/backend/src/compose/builderImpl/composeBlend.ts new file mode 100644 index 00000000..086a464c --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeBlend.ts @@ -0,0 +1,104 @@ +import { AltNode } from "../../alt_api_types"; +import { numberToFixedString } from "../../common/numToAutoFixed"; + +/** + * Handles opacity transformations for Jetpack Compose + * Maps to Modifier.alpha() in Compose + */ +export const composeOpacity = ( + node: MinimalBlendMixin, + child: string, +): string => { + if (node.opacity !== undefined && node.opacity !== 1 && child !== "") { + const opacity = numberToFixedString(node.opacity); + return `Box( + modifier = Modifier.alpha(${opacity}f) +) { + ${child} +}`; + } + return child; +}; + +/** + * Handles visibility transformations for Jetpack Compose + * Uses conditional rendering or alpha(0f) based on visibility + */ +export const composeVisibility = (node: SceneNode, child: string): string => { + // [when testing] node.visible can be undefined + if (node.visible !== undefined && !node.visible && child !== "") { + // In Compose, we can either use conditional rendering or set alpha to 0 + // Using alpha(0f) to maintain layout space (similar to visibility: hidden in CSS) + return `Box( + modifier = Modifier.alpha(0f) +) { + ${child} +}`; + } + return child; +}; + +/** + * Handles rotation transformations for Jetpack Compose + * Maps to Modifier.rotate() in Compose + * Converts angles from degrees to the format expected by Compose + */ +export const composeRotation = (node: AltNode, child: string): string => { + if ( + node.rotation !== undefined && + child !== "" && + Math.round(node.rotation) !== 0 + ) { + const totalRotation = (node.rotation || 0) + (node.cumulativeRotation || 0); + + if (Math.round(totalRotation) === 0) { + return child; + } + + const rotationDegrees = numberToFixedString(totalRotation); + return `Box( + modifier = Modifier.rotate(${rotationDegrees}f) +) { + ${child} +}`; + } + return child; +}; + +/** + * Combines multiple blend transformations into a single modifier chain + * This is more efficient than nesting multiple Box composables + */ +export const composeBlendModifiers = (node: AltNode, child: string): string => { + const modifiers: string[] = []; + + // Add opacity modifier + if (node.opacity !== undefined && node.opacity !== 1) { + const opacity = numberToFixedString(node.opacity); + modifiers.push(`alpha(${opacity}f)`); + } + + // Add visibility modifier (using alpha for invisible elements) + if (node.visible !== undefined && !node.visible) { + modifiers.push(`alpha(0f)`); + } + + // Add rotation modifier + const totalRotation = (node.rotation || 0) + (node.cumulativeRotation || 0); + if (Math.round(totalRotation) !== 0) { + const rotationDegrees = numberToFixedString(totalRotation); + modifiers.push(`rotate(${rotationDegrees}f)`); + } + + // If we have modifiers, wrap in Box with combined modifier chain + if (modifiers.length > 0 && child !== "") { + const modifierChain = `Modifier.${modifiers.join(".")}`; + return `Box( + modifier = ${modifierChain} +) { + ${child} +}`; + } + + return child; +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composeBorder.ts b/packages/backend/src/compose/builderImpl/composeBorder.ts new file mode 100644 index 00000000..988a37dc --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeBorder.ts @@ -0,0 +1,177 @@ +import { commonStroke } from "../../common/commonStroke"; +import { rgbTo6hex } from "../../common/color"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { retrieveTopFill } from "../../common/retrieveFill"; + +/** + * Helper function to convert RGBA paint to hex format for Compose + */ +const rgbaToHex = (fill: Paint): string => { + if (fill.type === "SOLID") { + return rgbTo6hex(fill.color); + } + return "000000"; // fallback +}; + +/** + * Get stroke alignment string for Compose border + * Compose doesn't have perfect equivalents for all stroke alignments, + * but we can approximate with different approaches + */ +const getStrokeAlignment = (node: SceneNode): "inside" | "center" | "outside" => { + if ("strokeAlign" in node) { + switch (node.strokeAlign) { + case "INSIDE": + return "inside"; + case "CENTER": + return "center"; + case "OUTSIDE": + return "outside"; + default: + return "inside"; + } + } + return "inside"; +}; + +/** + * Generate Compose border modifier string + * @param node - The scene node with stroke properties + * @returns Compose border modifier string + */ +export const composeBorder = (node: SceneNode, shape?: string | null): string => { + if (!("strokes" in node)) { + return ""; + } + + const stroke = commonStroke(node); + if (!stroke) { + return ""; + } + + const strokeFill = retrieveTopFill(node.strokes); + if (!strokeFill) { + return ""; + } + + const strokeAlignment = getStrokeAlignment(node); + + // Handle uniform border (all sides same) + if ("all" in stroke) { + if (stroke.all === 0) { + return ""; + } + + return generateBorderModifier(stroke.all, strokeFill, strokeAlignment, shape); + } else { + // Handle non-uniform borders + // Compose doesn't have direct support for different border widths per side + // We'll use the maximum width and add a warning + const maxWidth = Math.max( + stroke.left, + stroke.top, + stroke.right, + stroke.bottom + ); + + if (maxWidth === 0) { + return ""; + } + + // For now, use uniform border with max width + // TODO: Consider using Canvas or custom drawing for true non-uniform borders + return generateBorderModifier(maxWidth, strokeFill, strokeAlignment, shape); + } +}; + +/** + * Generate the actual border modifier string + */ +const generateBorderModifier = ( + width: number, + fill: Paint, + alignment: "inside" | "center" | "outside", + shape?: string | null +): string => { + const widthDp = `${numberToFixedString(width)}.dp`; + + if (fill.type === "SOLID") { + const color = rgbaToHex(fill); + const opacity = fill.opacity ?? 1.0; + + let colorValue: string; + if (opacity < 1) { + const alpha = Math.round(opacity * 255).toString(16).padStart(2, '0').toUpperCase(); + colorValue = `Color(0x${alpha}${color.toUpperCase()})`; + } else { + colorValue = `Color(0xFF${color.toUpperCase()})`; + } + + // For alignment, we note that Compose doesn't have built-in stroke alignment + // All borders are essentially "inside" by default + // For outside borders, we might need custom drawing or padding adjustments + const shapeParam = shape ? `, shape = ${shape}` : ""; + if (alignment === "outside") { + // Add comment about limitation + return `border(width = ${widthDp}, color = ${colorValue}${shapeParam}) // Note: Compose borders are always inside`; + } else { + return `border(width = ${widthDp}, color = ${colorValue}${shapeParam})`; + } + } else if (fill.type === "GRADIENT_LINEAR") { + // Convert gradient to Compose Brush + const stops = fill.gradientStops.map(stop => { + const stopColor = rgbTo6hex(stop.color); + const stopOpacity = stop.color.a ?? 1.0; + let colorValue: string; + + if (stopOpacity < 1) { + const alpha = Math.round(stopOpacity * 255).toString(16).padStart(2, '0').toUpperCase(); + colorValue = `Color(0x${alpha}${stopColor.toUpperCase()})`; + } else { + colorValue = `Color(0xFF${stopColor.toUpperCase()})`; + } + + return `${numberToFixedString(stop.position)}f to ${colorValue}`; + }).join(", "); + + const brush = `Brush.linearGradient( + listOf(${stops}) + )`; + + const shapeParam = shape ? `, shape = ${shape}` : ""; + if (alignment === "outside") { + return `border(width = ${widthDp}, brush = ${brush}${shapeParam}) // Note: Compose borders are always inside`; + } else { + return `border(width = ${widthDp}, brush = ${brush}${shapeParam})`; + } + } else if (fill.type === "GRADIENT_RADIAL") { + // Convert radial gradient to Compose Brush + const stops = fill.gradientStops.map(stop => { + const stopColor = rgbTo6hex(stop.color); + const stopOpacity = stop.color.a ?? 1.0; + let colorValue: string; + + if (stopOpacity < 1) { + const alpha = Math.round(stopOpacity * 255).toString(16).padStart(2, '0').toUpperCase(); + colorValue = `Color(0x${alpha}${stopColor.toUpperCase()})`; + } else { + colorValue = `Color(0xFF${stopColor.toUpperCase()})`; + } + + return `${numberToFixedString(stop.position)}f to ${colorValue}`; + }).join(", "); + + const brush = `Brush.radialGradient( + listOf(${stops}) + )`; + + const shapeParam = shape ? `, shape = ${shape}` : ""; + if (alignment === "outside") { + return `border(width = ${widthDp}, brush = ${brush}${shapeParam}) // Note: Compose borders are always inside`; + } else { + return `border(width = ${widthDp}, brush = ${brush}${shapeParam})`; + } + } + + return ""; +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composeColor.ts b/packages/backend/src/compose/builderImpl/composeColor.ts new file mode 100644 index 00000000..56d5b53b --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeColor.ts @@ -0,0 +1,33 @@ +import { rgbTo6hex, rgbTo8hex } from "../../common/color"; + +export const composeColor = (fill: Paint): string | null => { + if (fill.type === "SOLID") { + const color = rgbTo6hex(fill.color); + if (fill.opacity !== undefined && fill.opacity < 1) { + const alpha = Math.round(fill.opacity * 255).toString(16).padStart(2, '0').toUpperCase(); + return `background(Color(0x${alpha}${color.toUpperCase()}))`; + } + return `background(Color(0xFF${color.toUpperCase()}))`; + } else if (fill.type === "GRADIENT_LINEAR") { + // Convert gradient to Compose Brush + const stops = fill.gradientStops.map(stop => { + const color = rgbTo6hex(stop.color); + return `${stop.position}f to Color(0xFF${color.toUpperCase()})`; + }).join(", "); + + return `background(Brush.linearGradient( + listOf(${stops}) + ))`; + } else if (fill.type === "GRADIENT_RADIAL") { + const stops = fill.gradientStops.map(stop => { + const color = rgbTo6hex(stop.color); + return `${stop.position}f to Color(0xFF${color.toUpperCase()})`; + }).join(", "); + + return `background(Brush.radialGradient( + listOf(${stops}) + ))`; + } + + return null; +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composePadding.ts b/packages/backend/src/compose/builderImpl/composePadding.ts new file mode 100644 index 00000000..8deef761 --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composePadding.ts @@ -0,0 +1,71 @@ +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { commonPadding } from "../../common/commonPadding"; + +/** + * Generates Jetpack Compose padding modifiers based on node padding properties. + * + * Returns appropriate padding modifiers: + * - padding(all = X.dp) for uniform padding + * - padding(horizontal = X.dp, vertical = Y.dp) for symmetric padding + * - padding(start = X.dp, end = Y.dp, top = Z.dp, bottom = W.dp) for individual padding + */ +export const composePadding = (node: InferredAutoLayoutResult): string => { + if (!("layoutMode" in node)) { + return ""; + } + + const padding = commonPadding(node); + if (!padding) { + return ""; + } + + if ("all" in padding) { + if (padding.all === 0) { + return ""; + } + return `padding(${numberToFixedString(padding.all)}.dp)`; + } + + if ("horizontal" in padding) { + const modifiers: string[] = []; + + if (padding.horizontal !== 0) { + modifiers.push(`horizontal = ${numberToFixedString(padding.horizontal)}.dp`); + } + + if (padding.vertical !== 0) { + modifiers.push(`vertical = ${numberToFixedString(padding.vertical)}.dp`); + } + + if (modifiers.length === 0) { + return ""; + } + + return `padding(${modifiers.join(", ")})`; + } + + // Individual padding values + const modifiers: string[] = []; + + if (padding.left !== 0) { + modifiers.push(`start = ${numberToFixedString(padding.left)}.dp`); + } + + if (padding.right !== 0) { + modifiers.push(`end = ${numberToFixedString(padding.right)}.dp`); + } + + if (padding.top !== 0) { + modifiers.push(`top = ${numberToFixedString(padding.top)}.dp`); + } + + if (padding.bottom !== 0) { + modifiers.push(`bottom = ${numberToFixedString(padding.bottom)}.dp`); + } + + if (modifiers.length === 0) { + return ""; + } + + return `padding(${modifiers.join(", ")})`; +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composeShadow.ts b/packages/backend/src/compose/builderImpl/composeShadow.ts new file mode 100644 index 00000000..c251cb55 --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeShadow.ts @@ -0,0 +1,128 @@ +import { rgbTo8hex } from "../../common/color"; +import { numberToFixedString } from "../../common/numToAutoFixed"; + +/** + * Converts Figma shadow effects to Jetpack Compose shadow modifiers + * @param effects Array of effects from a Figma node + * @returns Compose shadow modifier string + */ +export const composeShadow = (effects: readonly Effect[]): string => { + if (!effects || effects.length === 0) { + return ""; + } + + const shadowEffects = effects.filter( + (effect) => + (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") && + effect.visible !== false + ); + + if (shadowEffects.length === 0) { + return ""; + } + + const shadowModifiers: string[] = []; + + shadowEffects.forEach((effect) => { + if (effect.type === "DROP_SHADOW") { + const offsetX = numberToFixedString(effect.offset.x); + const offsetY = numberToFixedString(effect.offset.y); + const blurRadius = numberToFixedString(effect.radius); + const spreadRadius = effect.spread ? numberToFixedString(effect.spread) : "0"; + + // Convert Figma color to Compose Color + const color = rgbTo8hex(effect.color, effect.color.a); + + // For simple shadows with no spread and small blur, use elevation + if (effect.spread === 0 && effect.radius <= 8 && effect.offset.x === 0) { + const elevation = Math.abs(effect.offset.y); + if (elevation > 0 && elevation <= 24) { + shadowModifiers.push(`shadow(${numberToFixedString(elevation)}.dp)`); + return; + } + } + + // For complex shadows, use drawBehind with custom drawing + if (effect.offset.x !== 0 || effect.offset.y !== 0 || effect.spread !== 0) { + shadowModifiers.push(`drawBehind { + drawRect( + color = Color(0x${color.toUpperCase()}), + topLeft = Offset(${offsetX}.dp.toPx(), ${offsetY}.dp.toPx()), + size = size.copy( + width = size.width + ${spreadRadius}.dp.toPx(), + height = size.height + ${spreadRadius}.dp.toPx() + ), + blendMode = BlendMode.Multiply + ) +}`); + } else { + // Simple shadow with custom color + shadowModifiers.push(`shadow(${blurRadius}.dp, shape = RectangleShape)`); + } + } else if (effect.type === "INNER_SHADOW") { + // Inner shadows in Compose require custom drawing + const offsetX = numberToFixedString(effect.offset.x); + const offsetY = numberToFixedString(effect.offset.y); + const blurRadius = numberToFixedString(effect.radius); + const color = rgbTo8hex(effect.color, effect.color.a); + + shadowModifiers.push(`drawWithContent { + drawContent() + drawRect( + color = Color(0x${color.toUpperCase()}), + topLeft = Offset(${offsetX}.dp.toPx(), ${offsetY}.dp.toPx()), + size = size, + blendMode = BlendMode.Multiply + ) +}`); + } + }); + + return shadowModifiers.join("\n."); +}; + +/** + * Helper function to determine if a shadow can use simple elevation + * @param effect The shadow effect to check + * @returns boolean indicating if simple elevation can be used + */ +export const canUseSimpleElevation = (effect: Effect): boolean => { + if (effect.type !== "DROP_SHADOW") { + return false; + } + + return ( + effect.offset.x === 0 && + effect.offset.y > 0 && + effect.offset.y <= 24 && + effect.spread === 0 && + effect.radius <= 8 + ); +}; + +/** + * Maps common Figma shadow presets to Material Design elevation levels + * @param effect The shadow effect + * @returns Material Design elevation level or null if no match + */ +export const getMaterialElevation = (effect: Effect): number | null => { + if (effect.type !== "DROP_SHADOW" || !canUseSimpleElevation(effect)) { + return null; + } + + const offsetY = Math.abs(effect.offset.y); + const blur = effect.radius; + + // Material Design elevation mappings + if (offsetY === 1 && blur === 3) return 1; + if (offsetY === 2 && blur === 4) return 2; + if (offsetY === 3 && blur === 5) return 3; + if (offsetY === 4 && blur === 6) return 4; + if (offsetY === 6 && blur === 10) return 6; + if (offsetY === 8 && blur === 12) return 8; + if (offsetY === 12 && blur === 17) return 12; + if (offsetY === 16 && blur === 24) return 16; + if (offsetY === 24 && blur === 38) return 24; + + return offsetY; // Fallback to offset as elevation +}; \ No newline at end of file diff --git a/packages/backend/src/compose/builderImpl/composeSize.ts b/packages/backend/src/compose/builderImpl/composeSize.ts new file mode 100644 index 00000000..e0827401 --- /dev/null +++ b/packages/backend/src/compose/builderImpl/composeSize.ts @@ -0,0 +1,42 @@ +import { numberToFixedString } from "../../common/numToAutoFixed"; + +export const composeSize = (node: SceneNode): string | null => { + const modifiers: string[] = []; + + if ("width" in node && "height" in node) { + const width = numberToFixedString(node.width); + const height = numberToFixedString(node.height); + + // Check for special sizing modes + if ("layoutSizingHorizontal" in node && node.layoutSizingHorizontal === "FILL") { + modifiers.push("fillMaxWidth()"); + } else if (width > 0) { + modifiers.push(`width(${width}.dp)`); + } + + if ("layoutSizingVertical" in node && node.layoutSizingVertical === "FILL") { + modifiers.push("fillMaxHeight()"); + } else if (height > 0) { + modifiers.push(`height(${height}.dp)`); + } + + // Handle special cases for size constraints + if ("constraints" in node) { + const constraints = node.constraints; + + if (constraints.horizontal === "STRETCH") { + modifiers.push("fillMaxWidth()"); + } else if (constraints.horizontal === "SCALE") { + modifiers.push("wrapContentWidth()"); + } + + if (constraints.vertical === "STRETCH") { + modifiers.push("fillMaxHeight()"); + } else if (constraints.vertical === "SCALE") { + modifiers.push("wrapContentHeight()"); + } + } + } + + return modifiers.length > 0 ? modifiers.join(".") : null; +}; \ No newline at end of file diff --git a/packages/backend/src/compose/composeContainer.ts b/packages/backend/src/compose/composeContainer.ts new file mode 100644 index 00000000..7e85f023 --- /dev/null +++ b/packages/backend/src/compose/composeContainer.ts @@ -0,0 +1,109 @@ +import { retrieveTopFill } from "../common/retrieveFill"; +import { getCommonRadius } from "../common/commonRadius"; +import { composeSize } from "./builderImpl/composeSize"; +import { composeBorder } from "./builderImpl/composeBorder"; +import { composeColor } from "./builderImpl/composeColor"; +import { composeShadow } from "./builderImpl/composeShadow"; +import { composePadding } from "./builderImpl/composePadding"; + +export const composeContainer = ( + node: SceneNode & MinimalBlendMixin, + child: string, +): string => { + // Safety check for node dimensions + if ("width" in node && "height" in node) { + if ((node.width <= 0 || node.height <= 0) && !child) { + return "// Invalid node dimensions"; + } + } + + const modifiers: string[] = []; + let containerType = "Box"; + + // Determine if we need a specific container type + if ("fills" in node) { + const topFill = retrieveTopFill(node.fills); + if (topFill) { + // Background color or gradient + const backgroundModifier = composeColor(topFill); + if (backgroundModifier) { + modifiers.push(backgroundModifier); + } + } + } + + // Size modifiers + const sizeModifier = composeSize(node); + if (sizeModifier) { + modifiers.push(sizeModifier); + } + + // Border radius + let shape = null; + if ("cornerRadius" in node || "topLeftRadius" in node) { + const radius = getCommonRadius(node); + if ("all" in radius && radius.all > 0) { + shape = `RoundedCornerShape(${radius.all}.dp)`; + modifiers.push(`clip(${shape})`); + } else if ("topLeft" in radius) { + shape = `RoundedCornerShape( + topStart = ${radius.topLeft}.dp, + topEnd = ${radius.topRight}.dp, + bottomEnd = ${radius.bottomRight}.dp, + bottomStart = ${radius.bottomLeft}.dp + )`; + modifiers.push(`clip(${shape})`); + } + } + + // Border + if ("strokes" in node && node.strokes.length > 0) { + const borderModifier = composeBorder(node, shape); + if (borderModifier) { + modifiers.push(borderModifier); + } + } + + // Shadow/elevation + if ("effects" in node && node.effects.length > 0) { + const shadowModifier = composeShadow(node.effects); + if (shadowModifier) { + modifiers.push(shadowModifier); + } + } + + // Padding (if this is a container with children) + if ("paddingLeft" in node) { + const paddingModifier = composePadding(node); + if (paddingModifier) { + modifiers.push(paddingModifier); + } + } + + // Build modifier chain + const modifierChain = modifiers.length > 0 + ? `modifier = Modifier${modifiers.map(m => `.${m}`).join("")}` + : ""; + + // Generate container + if (child) { + if (modifierChain) { + return `${containerType}( + ${modifierChain} +) { + ${child} +}`; + } else { + return `${containerType} { + ${child} +}`; + } + } else { + // Empty container + if (modifierChain) { + return `Spacer(${modifierChain})`; + } else { + return `Spacer(modifier = Modifier.size(0.dp))`; + } + } +}; \ No newline at end of file diff --git a/packages/backend/src/compose/composeDefaultBuilder.ts b/packages/backend/src/compose/composeDefaultBuilder.ts new file mode 100644 index 00000000..756de0dd --- /dev/null +++ b/packages/backend/src/compose/composeDefaultBuilder.ts @@ -0,0 +1,54 @@ +import { + composeVisibility, + composeOpacity, + composeRotation, +} from "./builderImpl/composeBlend"; + +import { composeContainer } from "./composeContainer"; +import { + commonIsAbsolutePosition, + getCommonPositionValue, +} from "../common/commonPosition"; + +export class ComposeDefaultBuilder { + child: string; + rotationApplied: boolean = false; + + constructor(optChild: string) { + this.child = optChild; + } + + createContainer(node: SceneNode): this { + this.child = composeContainer(node, this.child); + this.rotationApplied = true; + + return this; + } + + blendAttr(node: SceneNode): this { + if ("rotation" in node && !this.rotationApplied) { + this.child = composeRotation(node, this.child); + } + + if ("visible" in node) { + this.child = composeVisibility(node, this.child); + } else if ("opacity" in node) { + this.child = composeOpacity(node, this.child); + } + return this; + } + + position(node: SceneNode): this { + if (commonIsAbsolutePosition(node)) { + const { x, y } = getCommonPositionValue(node); + // In Compose, absolute positioning is handled differently + // We use offset modifier instead of a positioned wrapper + this.child = `Box( + modifier = Modifier.offset(x = ${x}.dp, y = ${y}.dp) +) { + ${this.child} +}`; + } + return this; + } +} \ No newline at end of file diff --git a/packages/backend/src/compose/composeMain.ts b/packages/backend/src/compose/composeMain.ts new file mode 100644 index 00000000..ebef0efd --- /dev/null +++ b/packages/backend/src/compose/composeMain.ts @@ -0,0 +1,330 @@ +import { stringToClassName } from "../common/numToAutoFixed"; +import { retrieveTopFill } from "../common/retrieveFill"; +import { ComposeDefaultBuilder } from "./composeDefaultBuilder"; +import { ComposeTextBuilder } from "./composeTextBuilder"; +import { indentString } from "../common/indentString"; + +import { + getCrossAxisAlignment, + getMainAxisAlignment, +} from "./builderImpl/composeAutoLayout"; +import { PluginSettings } from "types"; +import { addWarning } from "../common/commonConversionWarnings"; +import { getVisibleNodes } from "../common/nodeVisibility"; + +let localSettings: PluginSettings; +let previousExecutionCache: string[]; + +// Pre-compute static imports for performance +const COMPOSE_IMPORTS = `import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.*`; + +const getFullScreenTemplate = (name: string, injectCode: string): string => + `${COMPOSE_IMPORTS} + +// Generated by: https://www.figma.com/community/plugin/842128343887142055/ +@Composable +fun ${name}Screen() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF1A2034) + ) { + ${name}() + } +} + +@Composable +fun ${name}() { +${indentString(injectCode, 4)} +} + +@Preview(showBackground = true) +@Composable +fun ${name}Preview() { + ${name}() +}`; + +const getComposableTemplate = (name: string, injectCode: string): string => + `@Composable +fun ${name}() { +${indentString(injectCode, 4)} +}`; + +export const composeMain = ( + sceneNode: ReadonlyArray, + settings: PluginSettings, +): string => { + localSettings = settings; + previousExecutionCache = []; + + // Handle empty input + if (!sceneNode || sceneNode.length === 0) { + return "// No nodes to convert"; + } + + let result = composeWidgetGenerator(sceneNode); + + // Handle empty result + if (!result || result.trim() === "") { + result = "// No visible content generated"; + } + + switch (localSettings.composeGenerationMode) { + case "snippet": + return result; + case "composable": + if (!result.startsWith("Column") && !result.startsWith("//")) { + result = generateComposeWidget("Column", { content: [result] }); + } + return getComposableTemplate( + stringToClassName(sceneNode[0]?.name || "Component"), + result, + ); + case "screen": + if (!result.startsWith("Column") && !result.startsWith("//")) { + result = generateComposeWidget("Column", { content: [result] }); + } + return getFullScreenTemplate( + stringToClassName(sceneNode[0]?.name || "Component"), + result, + ); + default: + return result; + } +}; + +const generateComposeWidget = ( + widget: string, + props: Record, +): string => { + const { content, ...modifiers } = props; + + let modifierChain = ""; + if (Object.keys(modifiers).length > 0) { + modifierChain = `modifier = Modifier`; + Object.entries(modifiers).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + modifierChain += `.${key}(${value})`; + } + }); + } + + if (content && content.length > 0) { + const contentStr = content.join(",\n"); + if (modifierChain) { + return `${widget}( + ${modifierChain} +) { +${indentString(contentStr, 4)} +}`; + } else { + return `${widget}() { +${indentString(contentStr, 4)} +}`; + } + } else { + return modifierChain ? `${widget}(${modifierChain})` : `${widget}()`; + } +}; + +const composeWidgetGenerator = ( + sceneNode: ReadonlyArray, +): string => { + let comp: string[] = []; + + const visibleSceneNode = getVisibleNodes(sceneNode); + + visibleSceneNode.forEach((node) => { + switch ((node as any).type) { + case "RECTANGLE": + case "ELLIPSE": + case "STAR": + case "POLYGON": + case "LINE": + comp.push(composeContainer(node, "")); + break; + case "GROUP": + comp.push(composeGroup(node)); + break; + case "FRAME": + case "INSTANCE": + case "COMPONENT": + case "COMPONENT_SET": + case "SLOT": + comp.push(composeFrame(node)); + break; + case "SECTION": + comp.push(composeContainer(node, "")); + break; + case "TEXT": + comp.push(composeText(node)); + break; + case "VECTOR": + addWarning("VectorNodes are not fully supported in Compose"); + break; + case "SLICE": + default: + // do nothing + } + }); + + return comp.join(",\n"); +}; + +const composeGroup = (node: GroupNode): string => { + const widget = composeWidgetGenerator(node.children); + return composeContainer( + node, + generateComposeWidget("Box", { + content: widget ? [widget] : [], + }), + ); +}; + +const composeContainer = (node: SceneNode, child: string): string => { + let propChild = ""; + + if ("fills" in node && node.fills !== figma.mixed && retrieveTopFill(node.fills as any)?.type === "IMAGE") { + addWarning("Image fills are replaced with placeholders in Compose"); + } + + if (child.length > 0) { + propChild = child; + } + + const builder = new ComposeDefaultBuilder(propChild) + .createContainer(node) + .blendAttr(node) + .position(node); + + return builder.child; +}; + +const composeText = (node: TextNode): string => { + const builder = new ComposeTextBuilder().createText(node); + previousExecutionCache.push(builder.child); + + return builder.blendAttr(node).textAutoSize(node).position(node).child; +}; + +const composeFrame = ( + node: SceneNode & BaseFrameMixin & MinimalBlendMixin, +): string => { + const hasAbsoluteChildren = node.children.some( + (child: any) => (child as any).layoutPositioning === "ABSOLUTE", + ); + + if (hasAbsoluteChildren && node.layoutMode !== "NONE") { + addWarning( + `Frame "${node.name}" has absolute positioned children. Using Box instead of ${ + node.layoutMode === "HORIZONTAL" ? "Row" : "Column" + }.`, + ); + } + + const children = composeWidgetGenerator(node.children); + + if (hasAbsoluteChildren) { + return composeContainer( + node, + generateComposeWidget("Box", { + content: children !== "" ? [children] : [], + }), + ); + } + + if (node.layoutMode !== "NONE") { + const rowColumnWrap = makeRowColumnWrap(node, children); + return composeContainer(node, rowColumnWrap); + } else { + if (node.inferredAutoLayout) { + const rowColumnWrap = makeRowColumnWrap( + node.inferredAutoLayout, + children, + ); + return composeContainer(node, rowColumnWrap); + } + + if (node.isAsset) { + return composeContainer( + node, + "Icon(Icons.Default.Home, contentDescription = null)", + ); + } + + return composeContainer( + node, + generateComposeWidget("Box", { + content: children !== "" ? [children] : [], + }), + ); + } +}; + +const makeRowColumnWrap = ( + autoLayout: InferredAutoLayoutResult, + children: string, +): string => { + const isRow = autoLayout.layoutMode === "HORIZONTAL"; + const composable = isRow ? "Row" : "Column"; + + const widgetProps: Record = {}; + + // Add alignment properties + if (isRow) { + widgetProps.horizontalArrangement = getMainAxisAlignment(autoLayout); + widgetProps.verticalAlignment = getCrossAxisAlignment(autoLayout); + } else { + widgetProps.verticalArrangement = getMainAxisAlignment(autoLayout); + widgetProps.horizontalAlignment = getCrossAxisAlignment(autoLayout); + } + + // Add spacing if needed + if (autoLayout.itemSpacing > 0) { + const arrangement = isRow ? "horizontalArrangement" : "verticalArrangement"; + const currentArrangement = widgetProps[arrangement]; + // If we already have an arrangement, combine with spacedBy + if (currentArrangement && currentArrangement.includes("Arrangement.")) { + widgetProps[arrangement] = + `Arrangement.spacedBy(${autoLayout.itemSpacing}.dp, ${currentArrangement})`; + } else { + widgetProps[arrangement] = + `Arrangement.spacedBy(${autoLayout.itemSpacing}.dp)`; + } + } else if (autoLayout.itemSpacing < 0) { + addWarning("Compose doesn't support negative itemSpacing"); + } + + widgetProps.content = [children]; + + return generateComposeWidget(composable, widgetProps); +}; + +export const composeCodeGenTextStyles = () => { + const result = previousExecutionCache + .map((style) => `${style}`) + .join("\n// ---\n"); + + if (!result) { + return "// No text styles in this selection"; + } + return result; +}; diff --git a/packages/backend/src/compose/composeTextBuilder.ts b/packages/backend/src/compose/composeTextBuilder.ts new file mode 100644 index 00000000..28a0de74 --- /dev/null +++ b/packages/backend/src/compose/composeTextBuilder.ts @@ -0,0 +1,143 @@ +import { commonLetterSpacing } from "../common/commonTextHeightSpacing"; +import { numberToFixedString } from "../common/numToAutoFixed"; +import { ComposeDefaultBuilder } from "./composeDefaultBuilder"; +import { rgbTo6hex } from "../common/color"; +import { getCommonRadius } from "../common/commonRadius"; +import { retrieveTopFill } from "../common/retrieveFill"; + +// Cache static mappings for performance +const FONT_WEIGHT_MAP: Record = { + 100: "Thin", + 200: "ExtraLight", + 300: "Light", + 400: "Normal", + 500: "Medium", + 600: "SemiBold", + 700: "Bold", + 800: "ExtraBold", + 900: "Black", +}; + +const TEXT_ALIGN_MAP: Record = { + "LEFT": "Left", + "CENTER": "Center", + "RIGHT": "Right", + "JUSTIFIED": "Justify", +}; + +const TEXT_ESCAPE_MAP: Record = { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t' +}; + +const TEXT_ESCAPE_REGEX = /[\\"\n\r\t]/g; + +export class ComposeTextBuilder extends ComposeDefaultBuilder { + constructor() { + super(""); + } + + createText(node: TextNode): this { + this.child = this.getText(node); + return this; + } + + private getText(node: TextNode): string { + const text = node.characters || ""; + const textStyles = this.getTextStyles(node); + + // Escape text content properly (single pass for performance) + const escapedText = text.replace(TEXT_ESCAPE_REGEX, (char) => TEXT_ESCAPE_MAP[char]); + + // Handle multiline text differently + if (text.includes('\n')) { + return `Text( + text = """${text}""", + ${textStyles} +)`; + } + + return `Text( + text = "${escapedText}", + ${textStyles} +)`; + } + + private getTextStyles(node: TextNode): string { + const styles: string[] = []; + + // Font size + if (node.fontSize !== figma.mixed && typeof node.fontSize === "number" && node.fontSize > 0) { + styles.push(`fontSize = ${numberToFixedString(node.fontSize)}.sp`); + } + + // Font weight + if (node.fontWeight !== figma.mixed && typeof node.fontWeight === "number") { + const weight = this.mapFontWeight(node.fontWeight); + if (weight) { + styles.push(`fontWeight = FontWeight.${weight}`); + } + } + + // Text color + const fill = retrieveTopFill(node.fills); + if (fill?.type === "SOLID") { + const color = rgbTo6hex(fill.color); + styles.push(`color = Color(0xFF${color.toUpperCase()})`); + } + + // Letter spacing + if (node.letterSpacing !== figma.mixed && node.letterSpacing !== 0) { + const spacing = commonLetterSpacing(node.letterSpacing, node.fontSize as number); + styles.push(`letterSpacing = ${spacing}.sp`); + } + + // Line height + if (node.lineHeight !== figma.mixed && typeof node.lineHeight === "object" && node.lineHeight.unit === "PIXELS") { + styles.push(`lineHeight = ${node.lineHeight.value}.sp`); + } + + // Text align + if (node.textAlignHorizontal !== "LEFT") { + const alignment = this.mapTextAlign(node.textAlignHorizontal); + if (alignment) { + styles.push(`textAlign = TextAlign.${alignment}`); + } + } + + // Text decoration + if (node.textDecoration === "UNDERLINE") { + styles.push(`textDecoration = TextDecoration.Underline`); + } else if (node.textDecoration === "STRIKETHROUGH") { + styles.push(`textDecoration = TextDecoration.LineThrough`); + } + + return styles.join(",\n "); + } + + private mapFontWeight(weight: number): string | null { + return FONT_WEIGHT_MAP[weight] || null; + } + + private mapTextAlign(align: string): string | null { + return TEXT_ALIGN_MAP[align] || null; + } + + textAutoSize(node: TextNode): this { + // Compose doesn't have equivalent to Flutter's textAutoSize + // Instead, we can use maxLines and overflow properties + if (node.textAutoResize === "NONE") { + // Fixed size text + this.child = this.child.replace( + /Text\(/, + `Text( + maxLines = 1, + overflow = TextOverflow.Ellipsis,` + ); + } + return this; + } +} \ No newline at end of file diff --git a/packages/backend/src/flutter/builderImpl/flutterColor.ts b/packages/backend/src/flutter/builderImpl/flutterColor.ts index b9c85ab3..a4d12377 100644 --- a/packages/backend/src/flutter/builderImpl/flutterColor.ts +++ b/packages/backend/src/flutter/builderImpl/flutterColor.ts @@ -214,8 +214,8 @@ export const flutterAngularGradient = (fill: GradientPaint): string => { /** * Convert opacity (0-1) to alpha (0-255) */ -const opacityToAlpha = (opacity: number): number => { - return Math.round(opacity * 255); +const opacityToAlpha = (opacity: number) => { + return numberToFixedString(opacity); }; export const flutterColor = ( diff --git a/packages/backend/src/flutter/flutterMain.ts b/packages/backend/src/flutter/flutterMain.ts index c9912bb0..26a472bd 100644 --- a/packages/backend/src/flutter/flutterMain.ts +++ b/packages/backend/src/flutter/flutterMain.ts @@ -15,7 +15,6 @@ import { } from "./builderImpl/flutterAutoLayout"; import { PluginSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; -import { getPlaceholderImage } from "../common/images"; import { getVisibleNodes } from "../common/nodeVisibility"; let localSettings: PluginSettings; @@ -97,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": @@ -112,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 2b3be479..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); @@ -439,16 +432,16 @@ const htmlWrapSVG = ( settings: HTMLSettings, ): string => { if (node.svg === "") return ""; - + const builder = new HtmlDefaultBuilder(node, settings) .addData("svg-wrapper") .position(); - + // The SVG content already has the var() references, so we don't need // to add inline CSS variables in most cases. The browser will use the fallbacks // if the variables aren't defined in the CSS. - - return `\n\n${node.svg ?? ""}
`; + + return `\n\n${indentString(node.svg ?? "")}
`; }; const htmlGroup = async ( @@ -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 @@ -639,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/index.ts b/packages/backend/src/index.ts index 17d9ac79..fcf4dc94 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,5 +2,6 @@ export { flutterMain } from "./flutter/flutterMain"; export { htmlMain } from "./html/htmlMain"; export { tailwindMain } from "./tailwind/tailwindMain"; export { swiftuiMain } from "./swiftui/swiftuiMain"; +export { composeMain } from "./compose/composeMain"; export { run } from "./code"; export * from "./messaging"; 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/conversionTables.ts b/packages/backend/src/tailwind/conversionTables.ts index 81a428c5..4532d608 100644 --- a/packages/backend/src/tailwind/conversionTables.ts +++ b/packages/backend/src/tailwind/conversionTables.ts @@ -14,7 +14,7 @@ export const nearestValue = (goal: number, array: Array): number => { export const nearestValueWithThreshold = ( goal: number, array: Array, - thresholdPercent: number = 15, + thresholdPercent: number = localTailwindSettings.thresholdPercent, ): number | null => { const nearest = nearestValue(goal, array); const diff = Math.abs(nearest - goal); @@ -61,7 +61,7 @@ const pxToRemToTailwind = ( return conversionMap[convertedValue]; } else if (localTailwindSettings.roundTailwindValues) { // Only round if the nearest value is within acceptable threshold - const thresholdValue = nearestValueWithThreshold(remValue, keys, 15); + const thresholdValue = nearestValueWithThreshold(remValue, keys); if (thresholdValue !== null) { return conversionMap[thresholdValue]; @@ -82,7 +82,7 @@ const pxToTailwind = ( return conversionMap[convertedValue]; } else if (localTailwindSettings.roundTailwindValues) { // Only round if the nearest value is within acceptable threshold - const thresholdValue = nearestValueWithThreshold(value, keys, 15); + const thresholdValue = nearestValueWithThreshold(value, keys); if (thresholdValue !== null) { return conversionMap[thresholdValue]; @@ -106,8 +106,8 @@ export const pxToFontSize = (value: number): string => { export const pxToBorderRadius = (value: number): string => { const conversionMap = localTailwindSettings.useTailwind4 - ? config.borderRadiusV4 - : config.borderRadius; + ? config.borderRadiusV4 + : config.borderRadius; return pxToRemToTailwind(value, conversionMap); }; @@ -121,8 +121,8 @@ export const pxToOutline = (value: number): string | null => { export const pxToBlur = (value: number): string | null => { const conversionMap = localTailwindSettings.useTailwind4 - ? config.blurV4 - : config.blur; + ? config.blurV4 + : config.blur; return pxToTailwind(value, conversionMap); }; diff --git a/packages/backend/src/tailwind/tailwindConfig.ts b/packages/backend/src/tailwind/tailwindConfig.ts index 081412a3..cb08676d 100644 --- a/packages/backend/src/tailwind/tailwindConfig.ts +++ b/packages/backend/src/tailwind/tailwindConfig.ts @@ -78,16 +78,14 @@ const fontSize = { }; const lineHeight = { - 0.75: "3", - 1: "none", - 1.25: "tight", - 1.375: "snug", - 1.5: "normal", - 1.625: "relaxed", - 2: "loose", - 1.75: "7", - 2.25: "9", - 2.5: "10", + 0.75: "3", // 0.75rem + 1: "4", // 1rem + 1.25: "5", // 1.25rem + 1.5: "6", // 1.5rem + 1.75: "7", // 1.75rem + 2: "8", // 2rem + 2.25: "9", // 2.25rem + 2.5: "10", // 2.5rem }; const letterSpacing = { 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 d2ed446c..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); @@ -97,7 +98,7 @@ const tailwindWrapSVG = ( .addData("svg-wrapper") .position(); - return `\n\n${node.svg}`; + return `\n\n${indentString(node.svg ?? "")}`; }; const tailwindGroup = async ( @@ -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 66ee2882..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, @@ -12,6 +13,7 @@ import { import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; import { config } from "./tailwindConfig"; import { StyledTextSegmentSubset } from "types"; +import { localTailwindSettings } from "./tailwindMain"; export class TailwindTextBuilder extends TailwindDefaultBuilder { getTextSegments(node: TextNode): { @@ -54,11 +56,16 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { // textIndentStyle, blurStyle, shadowStyle, + this.truncateText(node), ] .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, @@ -67,6 +74,19 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { }); } + truncateText = ( + node: TextNode, + ) => { + if (node.textTruncation !== "DISABLED" && node.maxLines) { + if (node.maxLines > 0 && node.maxLines < 7) { + return `line-clamp-${node.maxLines}` + } else { + return `line-clamp-[${node.maxLines}]` + } + } + return ""; + }; + getTailwindColorFromFills = ( fills: ReadonlyArray | PluginAPI["mixed"], ) => { @@ -93,15 +113,36 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { }; fontFamily = (fontName: FontName): string => { - if (config.fontFamily.sans.includes(fontName.family)) { - return "font-sans"; - } - if (config.fontFamily.serif.includes(fontName.family)) { - return "font-serif"; + // Check if the font matches the base font family setting + const baseFontFamily = localTailwindSettings.baseFontFamily; + + // If the font matches exactly the base font, don't add a class + if (baseFontFamily && fontName.family.toLowerCase() === baseFontFamily.toLowerCase()) { + return ""; } - 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 6f957422..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.2.4", - "eslint-config-prettier": "^10.1.1", - "eslint-config-turbo": "^2.4.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 74a7ca6c..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.0.12", - "@types/react-dom": "^19.0.4", + "@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.0.0", - "react-syntax-highlighter": "^15.6.1", - "tailwind-merge": "^3.0.2", - "tailwindcss": "^4.0.17" + "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.23.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.8.2" + "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 b28085aa..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"], }, { @@ -41,7 +41,7 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ description: "Export code using Figma variables as colors. Example: 'bg-background' instead of 'bg-white'.", isDefault: true, - includedLanguages: ["HTML", "Tailwind", "Flutter"], + includedLanguages: ["HTML", "Tailwind", "Flutter", "Compose"], }, { itemType: "individual_select", @@ -83,6 +83,7 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ options: [ { label: "HTML", value: "html" }, { label: "React (JSX)", value: "jsx" }, + { label: "Twig", value: "twig" }, ], includedLanguages: ["Tailwind"], }, @@ -108,4 +109,15 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ ], includedLanguages: ["SwiftUI"], }, + { + itemType: "select", + propertyName: "composeGenerationMode", + label: "Mode", + options: [ + { label: "Snippet", value: "snippet" }, + { label: "Composable", value: "composable" }, + { label: "Full Screen", value: "screen" }, + ], + includedLanguages: ["Compose"], + }, ]; 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 && ( +
+ +
+ )} { ? "dart" : selectedFramework === "SwiftUI" ? "swift" - : "html" + : selectedFramework === "Compose" + ? "kotlin" + : "html" } style={theme} customStyle={{ diff --git a/packages/plugin-ui/src/components/ColorsPanel.tsx b/packages/plugin-ui/src/components/ColorsPanel.tsx index 22b42490..eec35ffd 100644 --- a/packages/plugin-ui/src/components/ColorsPanel.tsx +++ b/packages/plugin-ui/src/components/ColorsPanel.tsx @@ -24,7 +24,7 @@ const ColorsPanel = (props: { }; return ( -
+

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" ? ( +