diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 00000000..fffaebb0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,58 @@ +name: Bug Report +description: Report a bug in the project +title: "[bug]: " +labels: ["bug"] + +body: + - type: markdown + attributes: + value: "## 🐛 Bug Report\nPlease fill out the details below to help us resolve your issue quickly." + + - type: textarea + id: steps + attributes: + label: "Steps to Reproduce" + description: "Provide a step-by-step guide of the actions you took that led to the bug." + placeholder: | + 1. Open the plugin + 2. Select a text layer + 3. Reorder the layer. + 4. Observe the error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: "Expected Behavior" + description: "Describe what you expected to happen." + placeholder: "The code should be generated successfully and displayed in the output panel." + validations: + required: false + + - type: textarea + id: actual-behavior + attributes: + label: "Actual Behavior" + description: "Describe what actually happened. Include any error messages or logs if applicable." + placeholder: "An error message appears: 'Failed to generate code due to invalid layer configuration.'" + validations: + required: false + + - type: input + id: design-link + attributes: + label: "Design Reference" + description: "If applicable, provide a link to the design related to this bug (e.g., Figma link)." + placeholder: "e.g., https://www.figma.com/file/example" + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: "Screenshots or Videos" + description: "If applicable, add screenshots or provide links to videos to help explain the issue. You can drag and drop images here or paste them from your clipboard." + placeholder: "Drag and drop images or videos here" + validations: + required: false diff --git a/README.md b/README.md index 0ab9378c..34653f46 100644 --- a/README.md +++ b/README.md @@ -13,42 +13,45 @@

-Most _design to code_ plugins are bad, some are even paid. This project aims to raise the bar by generating **responsive** layouts in [Tailwind](https://tailwindcss.com/), [Flutter](https://flutter.github.io/) and [SwiftUI](https://developer.apple.com/xcode/swiftui/). The plan is to eventually add support for [Jetpack Compose](https://developer.android.com/jetpack/compose) and possibly standard HTML or other frameworks like [React Native](https://reactnative.dev/), [Bootstrap](https://getbootstrap.com/) or [Fluent](https://www.microsoft.com/design/fluent/). Feedback, ideas and partnerships are appreciated! +Converting Figma designs into usable code can be a challenge, often requiring time-consuming manual work. Figma to Code simplifies that process. This plugin generates responsive layouts in `HTML`, `React (JSX)`, `Svelte`, `styled-components`, `Tailwind`, `Flutter`, and `SwiftUI` directly from your designs. Your feedback and ideas are always welcome. ![Gif showing the conversion](assets/lossy_gif.gif) ## How it works -This plugin takes an unconventional approach to improve code quality: it optimizes the layout before the conversion to code even begins. The standard Figma [Nodes](https://www.figma.com/plugin-docs/api/nodes/) (what represents each layer) is a joy to work with, but it can't modify a layer without modifying the user project. For this reason, I decided to virtualize it, remaking the official implementation and naming them `AltNodes`. During the process of converting a `Node` into an `AltNode`, the plugin does the following: +The plugin uses a sophisticated multi-step process to transform your Figma designs into clean, optimized code: + +1. **Node Conversion**: First, the plugin converts Figma's native nodes into JSON representations, preserving all necessary properties while adding optimizations and parent references. + +2. **Intermediate Representation**: The JSON nodes are then transformed into `AltNodes` - a custom virtual representation that can be manipulated without affecting your original design. + +3. **Layout Optimization**: The plugin analyzes and optimizes layouts, detecting patterns like auto-layouts, responsive constraints and color variables. + +4. **Code Generation**: Finally, the optimized structure is transformed into the target framework's code, with special handling for each framework's unique patterns and best practices. If a feature is unsupported, the plugin will provide a warning. ![Conversion Workflow](assets/workflow.png) -That process can also be seen as an [Intermediate Representation](https://en.wikipedia.org/wiki/Intermediate_representation) and might allow this plugin to, one day, live outside Figma. +This intermediate representation approach allows for sophisticated transformations and optimizations before any code is generated, resulting in cleaner, more maintainable output. ## Hard cases -When finding the unknown (a `Group` or `Frame` with more than one child and no vertical or horizontal alignment), Tailwind mode uses [insets](https://tailwindcss.com/docs/top-right-bottom-left/#app) for best cases and `left`, `top` from standard CSS for the worst cases. Flutter mode uses `Stack` and `Positioned.fill`. Both are usually not recommended and can easily defeat the responsiveness. In many scenarios, just wrapping some elements in a `Group` or `Frame` can solve: - -![Conversion Workflow](assets/examples.png) +Converting visual designs to code inevitably encounters complex edge cases. Here are some challenges the plugin handles: -**Tip**: Instead of selecting the whole page, you can also select individual items. This can be useful for both debugging and componentization. For example: you can use the plugin to generate the code of a single element and then replicate it using a for-loop. +1. **Complex Layouts**: When working with mixed positioning (absolute + auto-layout), the plugin has to make intelligent decisions about how to structure the resulting code. It detects parent-child relationships and z-index ordering to produce the most accurate representation. -### Todo +2. **Color Variables**: The plugin detects and processes color variables, allowing for theme-consistent output. -- Vectors (tricky in HTML, unsupported in Flutter) -- Images (they are local, how to support them?) -- Line/Star/Polygon (todo. Rectangle and Ellipse were prioritized and are more common) -- The source code is fully commented and there are more than 30 "todo"s there +3. **Gradients and Effects**: Different frameworks handle gradients and effects in unique ways, requiring specialized conversion logic. -### Tailwind limitations +![Conversion Workflow](assets/examples.png) -- **Width:** Tailwind has a maximum width of 384px. If an item passes this, the width will be set to `w-full` (unless it is already relative like `w-1/2`, `w-1/3`, etc). This is usually a feature, but be careful: if most layers in your project are larger than 384px, the plugin's result might be less than optimal. +**Tip**: Instead of selecting the whole page, you can also select individual items. This can be useful for both debugging and componentization. For example: you can use the plugin to generate the code of a single element and then replicate it using a for-loop. -### Flutter limits and ideas +### Todo -- **Stack:** in some simpler cases, a `Stack` could be replaced with a `Container` and a `BoxDecoration`. Discover those cases and optimize them. -- **Material Styles**: text could be matched to existing Material styles (like outputting `Headline6` when text size is 20). -- **Identify Buttons**: the plugin could identify specific buttons and output them instead of always using `Container` or `Material`. +- Vectors (possible to enable in HTML and Tailwind) +- Images (possible to enable to inline them in HTML and Tailwind) +- Line/Star/Polygon ## How to build the project @@ -70,16 +73,49 @@ The plugin is organized as a monorepo. There are several packages: - `ui-src` - loads the common `plugin-ui` and compiles to `index.html` - `apps/debug` - This is a debug mode plugin that is a more convenient way to see all the UI elements. -The plugin is built using Turbo which in turn builds the internal packages. +### Development Workflow + +The project uses [Turborepo](https://turborepo.com/) for managing the monorepo, and each package is compiled using [esbuild](https://esbuild.github.io/) for fast development cycles. Only modified files are recompiled when changes are made, making the development process more efficient. + +#### Running the Project + +You have two main options for development: + +1. **Root development mode** (includes debug UI): + + ```bash + pnpm dev + ``` + + This runs the plugin in dev mode and also starts a Next.js server for the debug UI. You can access the debug UI at `http://localhost:3000`. + +2. **Plugin-only development mode**: + + ```bash + cd apps/plugin + pnpm dev + ``` + + This focuses only on the plugin without the Next.js debug UI. Use this when you're making changes specifically to the plugin. + +#### Where to Make Changes + +Most of your development work will happen in these directories: + +- `packages/backend` - For plugin backend +- `packages/plugin-ui` - For plugin UI +- `apps/plugin/` - The main plugin result that combines the backend and UI and is called by Figma. + +You'll rarely need to modify files directly in the `apps/` directory, as they mostly contain build configuration. #### Commands `pnpm run ...` - `dev` - runs the app in dev mode. This can be run in the Figma editor. -- `build` -- `build:watch` -- `lint` +- `build` - builds the project for production +- `build:watch` - builds and watches for changes +- `lint` - runs ESLint - `format` - formats with prettier (warning: may edit files!) #### Debug mode diff --git a/apps/debug/README.md b/apps/debug/README.md index 4fae62af..e6d38f06 100644 --- a/apps/debug/README.md +++ b/apps/debug/README.md @@ -1,4 +1,4 @@ -## Getting Started +# Getting Started First, run the development server: diff --git a/apps/debug/app/layout.tsx b/apps/debug/app/layout.tsx new file mode 100644 index 00000000..cf057a8a --- /dev/null +++ b/apps/debug/app/layout.tsx @@ -0,0 +1,21 @@ + +import type { Metadata } from "next"; +import "../styles/globals.css"; + +export const metadata: Metadata = { + title: "v0 App", + description: "Created with v0", + generator: "v0.dev", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/debug/app/page.tsx b/apps/debug/app/page.tsx new file mode 100644 index 00000000..1bc02a9f --- /dev/null +++ b/apps/debug/app/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Framework } from "types"; +import * as React from "react"; +import { PluginUI } from "plugin-ui"; + +export default function Web() { + const [selectedFramework, setSelectedFramework] = + React.useState("HTML"); + const testWarnings = ["This is an example of a conversion warning message."]; + + return ( +
+
+

+ Debug Mode +

+

+ Preview your Figma to Code plugin in both light and dark modes +

+
+
+ +
+
+
+

+ Light Mode +

+
+ + {}} + colors={[]} + gradients={[]} + warnings={testWarnings} + /> +
+
+
+ +
+
+

+ Dark Mode +

+
+ + {}} + colors={[]} + gradients={[]} + warnings={testWarnings} + /> +
+
+
+
+
+ ); +} + +const PluginFigmaToolbar = (props: { variant: string }) => ( +
+
+
+
+
+
+ Figma to Code {props.variant} +
+); diff --git a/apps/debug/next-env.d.ts b/apps/debug/next-env.d.ts index a4a7b3f5..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/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/debug/next.config.js b/apps/debug/next.config.js deleted file mode 100644 index fa69eeea..00000000 --- a/apps/debug/next.config.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = { - reactStrictMode: true, - transpilePackages: ["ui", "plugin-ui"], - publicRuntimeConfig: { - site: { - name: "Next.js + Tailwind CSS template", - url: - process.env.NODE_ENV === "development" - ? "http://localhost:3000" - : "https://earvinpiamonte-nextjs-tailwindcss-template.vercel.app", - title: "Next.js + Tailwind CSS template", - description: "Next.js + Tailwind CSS template", - socialPreview: "/images/preview.png", - }, - }, - swcMinify: true, - i18n: { - locales: ["en-US"], - defaultLocale: "en-US", - }, - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'images.unsplash.com', - }, - ], - }, -}; diff --git a/apps/debug/next.config.mjs b/apps/debug/next.config.mjs new file mode 100644 index 00000000..fdd1ee4b --- /dev/null +++ b/apps/debug/next.config.mjs @@ -0,0 +1,45 @@ +let userConfig = undefined +try { + userConfig = await import('./v0-user-next.config') +} catch (e) { + // ignore error +} + +/** @type {import('next').NextConfig} */ +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + experimental: { + webpackBuildWorker: true, + parallelServerBuildTraces: true, + parallelServerCompiles: true, + }, +} + +mergeConfig(nextConfig, userConfig) + +function mergeConfig(nextConfig, userConfig) { + if (!userConfig) { + return + } + + for (const key in userConfig) { + if ( + typeof nextConfig[key] === 'object' && + !Array.isArray(nextConfig[key]) + ) { + nextConfig[key] = { + ...nextConfig[key], + ...userConfig[key], + } + } else { + nextConfig[key] = userConfig[key] + } + } +} + +export default nextConfig diff --git a/apps/debug/package.json b/apps/debug/package.json index 044d2f36..0cee8e2d 100644 --- a/apps/debug/package.json +++ b/apps/debug/package.json @@ -11,21 +11,21 @@ }, "dependencies": { "backend": "workspace:*", - "next": "^14.2.20", + "next": "^16.2.6", "plugin-ui": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.2.6", + "react-dom": "^19.2.6" }, "devDependencies": { - "@types/node": "^20.17.10", - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", - "autoprefixer": "^10.4.20", + "@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.4.49", - "tailwindcss": "3.4.6", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.7.2" + "typescript": "^6.0.3" } } diff --git a/apps/debug/pages/_app.tsx b/apps/debug/pages/_app.tsx deleted file mode 100644 index 6d7d0783..00000000 --- a/apps/debug/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { AppProps } from "next/app"; -import "../styles/globals.css"; - -export default function App({ Component, pageProps }: AppProps) { - return ; -} diff --git a/apps/debug/pages/index.tsx b/apps/debug/pages/index.tsx deleted file mode 100644 index 176790dd..00000000 --- a/apps/debug/pages/index.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Framework } from "types"; -import * as React from "react"; -import { PluginUI } from "plugin-ui"; - -export default function Web() { - const [selectedFramework, setSelectedFramework] = - React.useState("HTML"); - - const testWarnings = ["This is an example of a conversion warning message."]; - - return ( -
-

Debug Mode

-
- -
-
-
- - {}} - colors={[]} - gradients={[]} - warnings={testWarnings} - /> -
-
- -
-
- - {}} - colors={[]} - gradients={[]} - warnings={testWarnings} - /> -
-
-
- -
-
-
- Plugin dropdown selection (each frame a different breakpoint) -
-
-
-
-
-
-
- -
-
Outputs from plugin (different screen sizes)
-
-
-
-
-
-
- -
-
- Experiment on dark mode (invert colors on output)
-
-
-
-
-
-
-
- -
-

HTML Output Tester (No JSX)

- -
- Empty -
-
-
- ); -} - -const PluginFigmaToolbar = (props: { variant: string }) => ( -
- Figma to Code {props.variant} -
-); diff --git a/apps/debug/postcss.config.js b/apps/debug/postcss.config.js index 12a703d9..e5640725 100644 --- a/apps/debug/postcss.config.js +++ b/apps/debug/postcss.config.js @@ -1,6 +1,5 @@ module.exports = { plugins: { - tailwindcss: {}, - autoprefixer: {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/apps/debug/styles/globals.css b/apps/debug/styles/globals.css index b5c61c95..ef98114b 100644 --- a/apps/debug/styles/globals.css +++ b/apps/debug/styles/globals.css @@ -1,3 +1,131 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@source '../../../packages/plugin-ui/**/*.{js,ts,jsx,tsx}'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.5rem; + --background: oklch(1 0 0); + --foreground: oklch(0.12 0.04 266.7); + --card: oklch(0.99 0 264); + --card-foreground: oklch(0.12 0.04 266.7); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.12 0.04 266.7); + --primary: oklch(0.63 0.25 161.73); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.93 0.01 266.23); + --secondary-foreground: oklch(0.14 0.01 266.4); + --muted: oklch(0.97 0.0 266.23); + --muted-foreground: oklch(0.5 0.01 266.33); + --accent: oklch(0.93 0.01 266.23); + --accent-foreground: oklch(0.14 0.01 266.4); + --destructive: oklch(0.53 0.31 24.65); + --destructive-foreground: oklch(0.97 0 0); + --border: oklch(0.89 0.01 266.3); + --input: oklch(0.89 0.01 266.3); + --ring: oklch(0.14 0.01 266.4); + --chart-1: oklch(0.68 0.29 41.48); + --chart-2: oklch(0.48 0.23 154.44); + --chart-3: oklch(0.27 0.13 244.46); + --chart-4: oklch(0.72 0.31 81.39); + --chart-5: oklch(0.74 0.36 50.3); +} + +.dark { + --background: oklch(0.12 0.04 266.7); + --foreground: oklch(0.97 0 0); + --card: oklch(0.237 0 0); + --card-foreground: oklch(0.97 0 0); + --popover: oklch(0.12 0.04 266.7); + --popover-foreground: oklch(0.97 0 0); + --primary: oklch(0.63 0.25 161.73); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.18 0.01 266.3); + --secondary-foreground: oklch(0.97 0 0); + --muted: oklch(0.18 0.01 266.3); + --muted-foreground: oklch(0.67 0.01 266.38); + --accent: oklch(0.18 0.01 266.3); + --accent-foreground: oklch(0.97 0 0); + --destructive: oklch(0.34 0.24 24.48); + --destructive-foreground: oklch(0.97 0 0); + --border: oklch(0.237 0 0); + --input: oklch(0.18 0.01 266.3); + --ring: oklch(0.82 0.01 266.4); + --chart-1: oklch(0.55 0.28 262.73); + --chart-2: oklch(0.51 0.24 150.67); + --chart-3: oklch(0.43 0.33 48.41); + --chart-4: oklch(0.61 0.26 300.96); + --chart-5: oklch(0.58 0.38 320.79); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + /* body { + @apply bg-background text-foreground; + } */ + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } +} \ No newline at end of file diff --git a/apps/debug/tailwind.config.js b/apps/debug/tailwind.config.js deleted file mode 100644 index 4006c8d3..00000000 --- a/apps/debug/tailwind.config.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - content: [ - "./pages/**/*.{js,ts,jsx,tsx}", - "./components/**/*.{js,ts,jsx,tsx}", - "../../packages/plugin-ui/**/*.{js,ts,jsx,tsx}", - ], - darkMode: "class", - theme: { - extend: {}, - }, - variants: {}, - plugins: [], -}; diff --git a/apps/debug/tsconfig.json b/apps/debug/tsconfig.json index a355365b..dde2a116 100644 --- a/apps/debug/tsconfig.json +++ b/apps/debug/tsconfig.json @@ -1,5 +1,12 @@ { "extends": "tsconfig/nextjs.json", - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/apps/plugin/README.md b/apps/plugin/README.md index 4fae62af..e6d38f06 100644 --- a/apps/plugin/README.md +++ b/apps/plugin/README.md @@ -1,4 +1,4 @@ -## Getting Started +# Getting Started First, run the development server: diff --git a/apps/plugin/package.json b/apps/plugin/package.json index 12272dbc..00783aab 100644 --- a/apps/plugin/package.json +++ b/apps/plugin/package.json @@ -10,32 +10,38 @@ "dev": "pnpm build:watch" }, "dependencies": { - "@figma/plugin-typings": "^1.105.0", + "@figma/plugin-typings": "^1.125.0", "backend": "workspace:*", + "clsx": "^2.1.1", + "copy-to-clipboard": "^4.0.2", + "lucide-react": "^1.14.0", + "motion": "^12.38.0", + "nanoid": "^5.1.11", "plugin-ui": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@types/node": "^20.17.10", - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitejs/plugin-react-swc": "^3.7.2", - "autoprefixer": "^10.4.20", - "concurrently": "^8.2.2", - "esbuild": "^0.23.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": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.16", - "postcss": "^8.4.49", - "tailwindcss": "3.4.6", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", - "typescript": "^5.7.2", "types": "workspace:*", - "vite": "^5.4.11", - "vite-plugin-singlefile": "^2.1.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 4491a152..1a4bd3da 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -4,30 +4,41 @@ import { flutterMain, tailwindMain, swiftuiMain, - convertIntoNodes, htmlMain, + composeMain, postSettingsChanged, } from "backend"; +import { nodesToJSON } from "backend/src/altNodes/jsonNodeConversion"; import { retrieveGenericSolidUIColors } from "backend/src/common/retrieveUI/retrieveColors"; 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; export const defaultPluginSettings: PluginSettings = { framework: "HTML", - jsx: false, - optimizeLayout: true, showLayerNames: false, - inlineStyle: true, + useOldPluginVersion2025: false, responsiveRoot: false, flutterGenerationMode: "snippet", swiftUIGenerationMode: "snippet", - roundTailwindValues: false, - roundTailwindColors: false, - customTailwindColors: false, + composeGenerationMode: "snippet", + roundTailwindValues: true, + roundTailwindColors: true, + useColorVariables: true, + customTailwindPrefix: "", + embedImages: false, + embedVectors: false, + htmlGenerationMode: "html", + tailwindGenerationMode: "jsx", + baseFontSize: 16, + useTailwind4: true, + thresholdPercent: 15, + baseFontFamily: "", + fontFamilyCustomConfig: {}, }; // A helper type guard to ensure the key belongs to the PluginSettings type @@ -36,8 +47,13 @@ function isKeyOfPluginSettings(key: string): key is keyof PluginSettings { } const getUserSettings = async () => { + console.log("[DEBUG] getUserSettings - Starting to fetch user settings"); const possiblePluginSrcSettings = (await figma.clientStorage.getAsync("userPluginSettings")) ?? {}; + console.log( + "[DEBUG] getUserSettings - Raw settings from storage:", + possiblePluginSrcSettings, + ); const updatedPluginSrcSettings = { ...defaultPluginSettings, @@ -55,181 +71,386 @@ const getUserSettings = async () => { }; userPluginSettings = updatedPluginSrcSettings as PluginSettings; + console.log("[DEBUG] getUserSettings - Final settings:", userPluginSettings); + return userPluginSettings; }; const initSettings = async () => { + console.log("[DEBUG] initSettings - Initializing plugin settings"); await getUserSettings(); postSettingsChanged(userPluginSettings); + console.log("[DEBUG] initSettings - Calling safeRun with settings"); safeRun(userPluginSettings); }; -const safeRun = (settings: PluginSettings) => { - try { - run(settings); - } catch (e) { - if (e && typeof e === "object" && "message" in e) { - const error = e as Error; - console.log("error: ", error.stack); - figma.ui.postMessage({ type: "error", error: error.message }); +// Used to prevent running from happening again. +let isLoading = false; +const safeRun = async (settings: PluginSettings) => { + console.log( + "[DEBUG] safeRun - Called with isLoading =", + isLoading, + "selectionCount =", + figma.currentPage.selection.length, + ); + if (isLoading === false) { + try { + isLoading = true; + console.log("[DEBUG] safeRun - Starting run execution"); + await run(settings); + console.log("[DEBUG] safeRun - Run execution completed"); + // hack to make it not immediately set to false when complete. (executes on next frame) + setTimeout(() => { + console.log("[DEBUG] safeRun - Resetting isLoading to false"); + isLoading = false; + }, 1); + } catch (e) { + console.log("[DEBUG] safeRun - Error caught in execution"); + isLoading = false; // Make sure to reset the flag on error + if (e && typeof e === "object" && "message" in e) { + const error = e as Error; + console.log("error: ", error.stack); + figma.ui.postMessage({ type: "error", error: error.message }); + } else { + // Handle non-standard errors or unknown error types + const errorMessage = String(e); + console.log("Unknown error: ", errorMessage); + figma.ui.postMessage({ + type: "error", + error: errorMessage || "Unknown error occurred", + }); + } + + // Send a message to reset the UI state + figma.ui.postMessage({ type: "conversion-complete", success: false }); } + } else { + console.log( + "[DEBUG] safeRun - Skipping execution because isLoading =", + isLoading, + ); } }; const standardMode = async () => { + console.log("[DEBUG] standardMode - Starting standard mode initialization"); figma.showUI(__html__, { width: 450, height: 700, themeColors: true }); - await initSettings(); + 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 count:", + figma.currentPage.selection.length, + ); safeRun(userPluginSettings); }); - // Listen for document changes + // Listen for page changes + figma.loadAllPagesAsync(); figma.on("documentchange", () => { + console.log("[DEBUG] documentchange event triggered"); + // Node: This was causing an infinite load when you try to export a background image from a group that contains children. + // The reason for this is that the code will temporarily hide the children of the group in order to export a clean image + // then restores the visibility of the children. This constitutes a document change so it's restarting the whole conversion. + // In order to stop this, we disable safeRun() when doing conversions (while isLoading === true). safeRun(userPluginSettings); }); - figma.ui.onmessage = (msg) => { - console.log("[node] figma.ui.onmessage", msg); + figma.ui.onmessage = async (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; figma.clientStorage.setAsync("userPluginSettings", userPluginSettings); safeRun(userPluginSettings); + } else if (msg.type === "get-selection-json") { + console.log("[DEBUG] get-selection-json message received"); + + const nodes = figma.currentPage.selection; + if (nodes.length === 0) { + figma.ui.postMessage({ + type: "selection-json", + data: { message: "No nodes selected" }, + }); + return; + } + const result: { + json?: SceneNode[]; + oldConversion?: any; + newConversion?: any; + } = {}; + + try { + result.json = (await Promise.all( + nodes.map( + async (node) => + ( + (await node.exportAsync({ + format: "JSON_REST_V1", + })) as any + ).document, + ), + )) as SceneNode[]; + } catch (error) { + console.error("Error exporting JSON:", error); + } + + try { + const newNodes = await nodesToJSON(nodes, userPluginSettings); + const removeParent = (node: any) => { + if (node.parent) { + delete node.parent; + } + if (node.children) { + node.children.forEach(removeParent); + } + }; + newNodes.forEach(removeParent); + result.newConversion = newNodes; + } catch (error) { + console.error("Error in new conversion:", error); + } + + const nodeJson = result; + + 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({ + type: "selection-json", + data: nodeJson, + }); } }; }; const codegenMode = async () => { + console.log("[DEBUG] codegenMode - Starting codegen mode initialization"); // figma.showUI(__html__, { visible: false }); await getUserSettings(); - figma.codegen.on("generate", ({ language, node }) => { - const convertedSelection = convertIntoNodes([node], null); - - switch (language) { - case "html": - return [ - { - title: `Code`, - code: htmlMain( - convertedSelection, - { ...userPluginSettings, jsx: false }, - true, - ), - language: "HTML", - }, - { - title: `Text Styles`, - code: htmlCodeGenTextStyles(userPluginSettings), - language: "HTML", - }, - ]; - case "html_jsx": - return [ - { - title: `Code`, - code: htmlMain( - convertedSelection, - { ...userPluginSettings, jsx: true }, - true, - ), - language: "HTML", - }, - { - title: `Text Styles`, - code: htmlCodeGenTextStyles(userPluginSettings), - language: "HTML", - }, - ]; - case "tailwind": - case "tailwind_jsx": - return [ - { - title: `Code`, - code: tailwindMain(convertedSelection, { - ...userPluginSettings, - jsx: language === "tailwind_jsx", - }), - language: "HTML", - }, - // { - // title: `Style`, - // code: tailwindMain(convertedSelection, defaultPluginSettings), - // language: "HTML", - // }, - { - title: `Tailwind Colors`, - code: retrieveGenericSolidUIColors("Tailwind") - .map((d) => { - let str = `${d.hex};`; - if (d.colorName !== d.hex) { - str += ` // ${d.colorName}`; - } - if (d.meta) { - str += ` (${d.meta})`; - } - return str; - }) - .join("\n"), - language: "JAVASCRIPT", - }, - { - title: `Text Styles`, - code: tailwindCodeGenTextStyles(), - language: "HTML", - }, - ]; - case "flutter": - return [ - { - title: `Code`, - code: flutterMain(convertedSelection, { - ...userPluginSettings, - flutterGenerationMode: "snippet", - }), - language: "SWIFT", - }, - { - title: `Text Styles`, - code: flutterCodeGenTextStyles(), - language: "SWIFT", - }, - ]; - case "swiftUI": - return [ - { - title: `SwiftUI`, - code: swiftuiMain(convertedSelection, { - ...userPluginSettings, - swiftUIGenerationMode: "snippet", - }), - language: "SWIFT", - }, - { - title: `Text Styles`, - code: swiftUICodeGenTextStyles(), - language: "SWIFT", - }, - ]; - default: - break; - } + figma.codegen.on( + "generate", + async ({ language, node }: CodegenEvent): Promise => { + console.log( + `[DEBUG] codegen.generate - Language: ${language}, Node: id=${node.id}, type=${node.type}`, + ); - const blocks: CodegenResult[] = []; - return blocks; - }); + const convertedSelection = await nodesToJSON([node], userPluginSettings); + console.log( + "[DEBUG] codegen.generate - Converted selection count:", + convertedSelection.length, + ); + + switch (language) { + case "html": + return [ + { + title: "Code", + code: ( + await htmlMain( + convertedSelection, + { ...userPluginSettings, htmlGenerationMode: "html" }, + true, + ) + ).html, + language: "HTML", + }, + { + title: "Text Styles", + code: htmlCodeGenTextStyles(userPluginSettings), + language: "HTML", + }, + ]; + case "html_jsx": + return [ + { + title: "Code", + code: ( + await htmlMain( + convertedSelection, + { ...userPluginSettings, htmlGenerationMode: "jsx" }, + true, + ) + ).html, + language: "HTML", + }, + { + title: "Text Styles", + code: htmlCodeGenTextStyles(userPluginSettings), + language: "HTML", + }, + ]; + + case "html_svelte": + return [ + { + title: "Code", + code: ( + await htmlMain( + convertedSelection, + { ...userPluginSettings, htmlGenerationMode: "svelte" }, + true, + ) + ).html, + language: "HTML", + }, + { + title: "Text Styles", + code: htmlCodeGenTextStyles(userPluginSettings), + language: "HTML", + }, + ]; + + case "html_styled_components": + return [ + { + title: "Code", + code: ( + await htmlMain( + convertedSelection, + { + ...userPluginSettings, + htmlGenerationMode: "styled-components", + }, + true, + ) + ).html, + language: "HTML", + }, + { + title: "Text Styles", + code: htmlCodeGenTextStyles(userPluginSettings), + language: "HTML", + }, + ]; + + case "tailwind": + case "tailwind_jsx": + return [ + { + title: "Code", + code: await tailwindMain(convertedSelection, { + ...userPluginSettings, + tailwindGenerationMode: + language === "tailwind_jsx" ? "jsx" : "html", + }), + language: "HTML", + }, + // { + // title: "Style", + // code: tailwindMain(convertedSelection, defaultPluginSettings), + // language: "HTML", + // }, + { + title: "Tailwind Colors", + code: (await retrieveGenericSolidUIColors("Tailwind")) + .map((d) => { + let str = `${d.hex};`; + if (d.colorName !== d.hex) { + str += ` // ${d.colorName}`; + } + if (d.meta) { + str += ` (${d.meta})`; + } + return str; + }) + .join("\n"), + language: "JAVASCRIPT", + }, + { + title: "Text Styles", + code: tailwindCodeGenTextStyles(), + language: "HTML", + }, + ]; + case "flutter": + return [ + { + title: "Code", + code: flutterMain(convertedSelection, { + ...userPluginSettings, + flutterGenerationMode: "snippet", + }), + language: "SWIFT", + }, + { + title: "Text Styles", + code: flutterCodeGenTextStyles(), + language: "SWIFT", + }, + ]; + case "swiftUI": + return [ + { + title: "SwiftUI", + code: swiftuiMain(convertedSelection, { + ...userPluginSettings, + swiftUIGenerationMode: "snippet", + }), + language: "SWIFT", + }, + { + title: "Text Styles", + code: swiftUICodeGenTextStyles(), + 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; + } + + const blocks: CodegenResult[] = []; + return blocks; + }, + ); }; switch (figma.mode) { case "default": case "inspect": + console.log("[DEBUG] Starting plugin in", figma.mode, "mode"); standardMode(); break; case "codegen": + console.log("[DEBUG] Starting plugin in codegen mode"); codegenMode(); break; default: + console.log("[DEBUG] Unknown plugin mode:", figma.mode); break; } diff --git a/apps/plugin/postcss.config.js b/apps/plugin/postcss.config.js index 12a703d9..e5640725 100644 --- a/apps/plugin/postcss.config.js +++ b/apps/plugin/postcss.config.js @@ -1,6 +1,5 @@ module.exports = { plugins: { - tailwindcss: {}, - autoprefixer: {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/apps/plugin/tailwind.config.js b/apps/plugin/tailwind.config.js deleted file mode 100644 index 77687bd7..00000000 --- a/apps/plugin/tailwind.config.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - content: [ - "plugin-ui", - "./pages/**/*.{js,ts,jsx,tsx}", - "./components/**/*.{js,ts,jsx,tsx}", - "../../packages/plugin-ui/**/*.{js,ts,jsx,tsx}", - ], - darkMode: "class", - theme: { - extend: {}, - }, - variants: {}, - plugins: [], -}; diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 7bee7558..56451aba 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -13,6 +13,7 @@ import { Warning, } from "types"; import { postUISettingsChangingMessage } from "./messaging"; +import copy from "copy-to-clipboard"; interface AppState { code: string; @@ -26,11 +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: [], @@ -49,16 +62,25 @@ export default function App() { console.log("[ui] message received:", untypedMessage); switch (untypedMessage.type) { + case "conversionStart": + setState((prevState) => ({ + ...prevState, + code: "", + isLoading: true, + })); + break; + case "code": const conversionMessage = untypedMessage as ConversionMessage; setState((prevState) => ({ ...prevState, ...conversionMessage, selectedFramework: conversionMessage.settings.framework, + isLoading: false, })); break; - case "pluginSettingChanged": + case "pluginSettingsChanged": const settingsMessage = untypedMessage as SettingsChangedMessage; setState((prevState) => ({ ...prevState, @@ -76,6 +98,7 @@ export default function App() { warnings: [], colors: [], gradients: [], + isLoading: false, })); break; @@ -87,8 +110,14 @@ export default function App() { colors: [], gradients: [], code: `Error :(\n// ${errorMessage.error}`, + isLoading: false, })); break; + + case "selection-json": + const json = event.data.pluginMessage.data; + copy(JSON.stringify(json, null, 2)); + default: break; } @@ -100,49 +129,47 @@ export default function App() { }, []); useEffect(() => { - if (state.selectedFramework === null) { - const timer = setTimeout( - () => setState((prevState) => ({ ...prevState, isLoading: true })), - 300, - ); - return () => clearTimeout(timer); - } else { - setState((prevState) => ({ ...prevState, isLoading: false })); - } - }, [state.selectedFramework]); - - if (state.selectedFramework === null) { - return state.isLoading ? ( -
- Loading Plugin... -
- ) : null; - } + parent.postMessage({ pluginMessage: { type: "ui-ready" } }, "*"); + }, []); const handleFrameworkChange = (updatedFramework: Framework) => { - setState((prevState) => ({ - ...prevState, - // code: "// Loading...", - selectedFramework: updatedFramework, - })); - postUISettingsChangingMessage("framework", updatedFramework, { - targetOrigin: "*", - }); + if (updatedFramework !== state.selectedFramework) { + setState((prevState) => ({ + ...prevState, + // code: "// Loading...", + selectedFramework: updatedFramework, + })); + postUISettingsChangingMessage("framework", updatedFramework, { + targetOrigin: "*", + }); + } }; - console.log("state.code", state.code.slice(0, 25)); + const handlePreferencesChange = ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => { + if (state.settings && state.settings[key] === value) { + // do nothing + } else { + postUISettingsChangingMessage(key, value, { targetOrigin: "*" }); + } + }; + + const darkMode = isDarkFigmaBackground(figmaColorBgValue); return ( -
+
- postUISettingsChangingMessage(key, value, { targetOrigin: "*" }) - } colors={state.colors} gradients={state.gradients} /> diff --git a/apps/plugin/ui-src/index.css b/apps/plugin/ui-src/index.css index b5c61c95..7a2cc666 100644 --- a/apps/plugin/ui-src/index.css +++ b/apps/plugin/ui-src/index.css @@ -1,3 +1,143 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; + +@source '../../../packages/plugin-ui/**/*.{js,ts,jsx,tsx}'; + +@custom-variant dark (&:is(.dark *)); + +html, +body, +#root { + width: 100%; + height: 100%; + overflow: hidden; +} + +body { + margin: 0; +} + +:root { + --radius: 0.5rem; + --background: oklch(1 0 0); + --foreground: oklch(0.12 0.04 266.7); + --card: oklch(0.99 0 264); + --card-foreground: oklch(0.12 0.04 266.7); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.12 0.04 266.7); + --primary: oklch(0.63 0.25 161.73); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.93 0.01 266.23); + --secondary-foreground: oklch(0.14 0.01 266.4); + --muted: oklch(0.97 0 266.23); + --muted-foreground: oklch(0.5 0.01 266.33); + --accent: oklch(0.93 0.01 266.23); + --accent-foreground: oklch(0.14 0.01 266.4); + --destructive: oklch(0.53 0.31 24.65); + --destructive-foreground: oklch(0.97 0 0); + --border: oklch(0.89 0.01 266.3); + --input: oklch(0.89 0.01 266.3); + --ring: oklch(0.14 0.01 266.4); + --chart-1: oklch(0.68 0.29 41.48); + --chart-2: oklch(0.48 0.23 154.44); + --chart-3: oklch(0.27 0.13 244.46); + --chart-4: oklch(0.72 0.31 81.39); + --chart-5: oklch(0.74 0.36 50.3); +} + +.dark { + --background: #2c2c2c; + --foreground: oklch(0.97 0 0); + --card: oklch(0.237 0 0); + --card-foreground: oklch(0.97 0 0); + --popover: oklch(0.12 0.04 266.7); + --popover-foreground: oklch(0.97 0 0); + --primary: oklch(0.63 0.25 161.73); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.18 0.01 266.3); + --secondary-foreground: oklch(0.97 0 0); + --muted: oklch(0.18 0.01 266.3); + --muted-foreground: oklch(0.67 0.01 266.38); + --accent: oklch(0.18 0.01 266.3); + --accent-foreground: oklch(0.97 0 0); + --destructive: oklch(0.34 0.24 24.48); + --destructive-foreground: oklch(0.97 0 0); + --border: oklch(0.237 0 0); + --input: oklch(0.18 0.01 266.3); + --ring: oklch(0.82 0.01 266.4); + --chart-1: oklch(0.55 0.28 262.73); + --chart-2: oklch(0.51 0.24 150.67); + --chart-3: oklch(0.43 0.33 48.41); + --chart-4: oklch(0.61 0.26 300.96); + --chart-5: oklch(0.58 0.38 320.79); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + /* body { + @apply bg-background text-foreground; + } */ + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } +} diff --git a/apps/plugin/ui-src/main.tsx b/apps/plugin/ui-src/main.tsx index faff9775..2a882473 100644 --- a/apps/plugin/ui-src/main.tsx +++ b/apps/plugin/ui-src/main.tsx @@ -1,5 +1,5 @@ -import ReactDOM from "react-dom"; +import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; -ReactDOM.render(, document.getElementById("root")); +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/apps/plugin/vite.config.ts b/apps/plugin/vite.config.ts index 89d075ae..9ea84213 100755 --- a/apps/plugin/vite.config.ts +++ b/apps/plugin/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ root: "./ui-src", plugins: [react(), viteSingleFile()], build: { - target: "ES2017", + target: "es2017", assetsInlineLimit: 100000000, chunkSizeWarningLimit: 100000000, cssCodeSplit: false, diff --git a/manifest.json b/manifest.json index 8fcdc7c2..05cfa1c4 100644 --- a/manifest.json +++ b/manifest.json @@ -7,12 +7,15 @@ "editorType": ["figma", "dev"], "capabilities": ["inspect", "codegen", "vscode"], "permissions": [], + "documentAccess": "dynamic-page", "networkAccess": { "allowedDomains": ["none"] }, "codegenLanguages": [ { "label": "HTML", "value": "html" }, { "label": "React (JSX)", "value": "html_jsx" }, + { "label": "Svelte", "value": "html_svelte" }, + { "label": "Styled Components", "value": "html_styled_components" }, { "label": "Tailwind", "value": "tailwind" }, { "label": "Tailwind (JSX)", "value": "tailwind_jsx" }, { "label": "Flutter", "value": "flutter" }, diff --git a/package.json b/package.json index 5257cd4a..052c97ef 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,10 @@ "format": "prettier --write \"**/*.{ts,tsx,css,md}\"" }, "devDependencies": { - "eslint": "^9.17.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", - "prettier": "^3.4.2", - "turbo": "^2.3.3", - "typescript": "^5.7.2" + "prettier": "^3.8.3", + "turbo": "^2.9.12", + "typescript": "^6.0.3" } } diff --git a/packages/backend/package.json b/packages/backend/package.json index 7b2a6abf..d677c6bd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -13,18 +13,21 @@ "lint": "eslint \"src/**/*.ts*\"" }, "dependencies": { - "@figma/plugin-typings": "^1.105.0", - "react": "18.3.1", - "react-dom": "18.3.1", + "@figma/plugin-typings": "^1.125.0", + "html-entities": "^2.6.0", + "js-base64": "^3.7.8", + "nanoid": "^5.1.11", + "react": "19.2.6", + "react-dom": "19.2.6", "types": "workspace:*" }, "devDependencies": { - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", - "eslint": "^9.17.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", - "tsup": "^8.3.5", - "typescript": "^5.7.2" + "tsup": "^8.5.1", + "typescript": "^6.0.3" } } diff --git a/packages/backend/src/altNodes/altNodeUtils.ts b/packages/backend/src/altNodes/altNodeUtils.ts index 53de7f44..0cc6331f 100644 --- a/packages/backend/src/altNodes/altNodeUtils.ts +++ b/packages/backend/src/altNodes/altNodeUtils.ts @@ -1,5 +1,9 @@ import { AltNode } from "types"; import { curry } from "../common/curry"; +import { exportAsyncProxy } from "../common/exportAsyncProxy"; +import { addWarning } from "../common/commonConversionWarnings"; +import { getVariableNameFromColor } from "./jsonNodeConversion"; +import { htmlColor } from "../html/builderImpl/htmlColor"; export const overrideReadonlyProperty = curry( (prop: K, value: any, obj: T): T => @@ -23,41 +27,92 @@ export function isNotEmpty( export const isTypeOrGroupOfTypes = curry( (matchTypes: NodeType[], node: SceneNode): boolean => { - if (node.visible === false || matchTypes.includes(node.type)) return true; + // Check if the current node's type is in the matchTypes array + if (matchTypes.includes(node.type)) return true; + // Only check children if this is a container type node that can have children if ("children" in node) { for (let i = 0; i < node.children.length; i++) { const childNode = node.children[i]; const result = isTypeOrGroupOfTypes(matchTypes, childNode); - if (result) continue; - // child is false - return false; + if (!result) { + // If any child is not of the specified types, return false + return false; + } } - // all children are true - return true; + // All children are valid types + return node.children.length > 0; // Only return true if there are children } - // not group or vector + // Not a container node and not a matching type return false; }, ); -export const renderNodeAsSVG = async (node: SceneNode) => - await node.exportAsync({ format: "SVG_STRING" }); - -export const renderAndAttachSVG = async (node: SceneNode) => { +export const isSVGNode = (node: SceneNode) => { const altNode = node as AltNode; - // const nodeName = `${node.type}:${node.id}`; - // console.log(altNode); - if (altNode.canBeFlattened) { - if (altNode.svg) { - // console.log(`SVG already rendered for ${nodeName}`); - return altNode; + return altNode.canBeFlattened; +}; + +export const renderAndAttachSVG = async (node: any) => { + if (node.canBeFlattened) { + if (node.svg) { + return node; + } + + try { + const svg = (await exportAsyncProxy(node, { + format: "SVG_STRING", + })) as string; + + // Process the SVG to replace colors with variable references + if (node.colorVariableMappings && node.colorVariableMappings.size > 0) { + let processedSvg = svg; + + // Replace fill="COLOR" or stroke="COLOR" patterns + const colorAttributeRegex = /(fill|stroke)="([^"]*)"/g; + + processedSvg = processedSvg.replace(colorAttributeRegex, (match, attribute, colorValue) => { + // Clean up the color value and normalize it + const normalizedColor = colorValue.toLowerCase().trim(); + + // Look up the color directly in our mappings + const mapping = node.colorVariableMappings.get(normalizedColor); + if (mapping) { + // If we have a variable reference, use it with fallback to original + return `${attribute}="var(--${mapping.variableName}, ${colorValue})"`; + } + + // Otherwise keep the original color + return match; + }); + + // Also handle style attributes with fill: or stroke: properties + const styleRegex = /style="([^"]*)(?:(fill|stroke):\s*([^;"]*))(;|\s|")([^"]*)"/g; + + processedSvg = processedSvg.replace(styleRegex, (match, prefix, property, colorValue, separator, suffix) => { + // Clean up any extra spaces from the color value + const normalizedColor = colorValue.toLowerCase().trim(); + + // Look up the color directly in our mappings + const mapping = node.colorVariableMappings.get(normalizedColor); + if (mapping) { + // Replace just the color value with the variable and fallback + return `style="${prefix}${property}: var(--${mapping.variableName}, ${colorValue})${separator}${suffix}"`; + } + + return match; + }); + + node.svg = processedSvg; + } else { + node.svg = svg; + } + } catch (error) { + addWarning(`Failed rendering SVG for ${node.name}`); + console.error(`Error rendering SVG for ${node.type}:${node.id}`); + console.error(error); } - // console.log(`${nodeName} can be flattened!`); - const svg = await renderNodeAsSVG(altNode.originalNode); - // console.log(`${svg}`); - altNode.svg = svg; } - return altNode; + return node; }; diff --git a/packages/backend/src/altNodes/iconDetection.ts b/packages/backend/src/altNodes/iconDetection.ts new file mode 100644 index 00000000..1d6fea0d --- /dev/null +++ b/packages/backend/src/altNodes/iconDetection.ts @@ -0,0 +1,265 @@ +// ======================================================================== +// Figma Icon Recognition Algorithm - Simplified v5 +// ======================================================================== +// This file provides a simplified function to determine if a Figma node +// is likely functioning as an icon based on structure and size. + +// --- Constants --- + +const ICON_PRIMITIVE_TYPES: ReadonlySet = new Set([ + "ELLIPSE", + "RECTANGLE", + "STAR", + "POLYGON", + "LINE", +]); // Removed duplicate POLYGON + +const ICON_COMPLEX_VECTOR_TYPES: ReadonlySet = new Set([ + "VECTOR", + "BOOLEAN_OPERATION", +]); + +// Types that are considered icons regardless of size if they are top-level +const ICON_TYPES_IGNORE_SIZE: ReadonlySet = new Set([ + "VECTOR", + "BOOLEAN_OPERATION", + "POLYGON", + "STAR", +]); + +const ICON_CONTAINER_TYPES: ReadonlySet = new Set([ + "FRAME", + "GROUP", + "COMPONENT", + "INSTANCE", +]); + +// Types explicitly disallowed as top-level icons or nested within icons (except GROUP) +const DISALLOWED_ICON_TYPES: ReadonlySet = new Set([ + "SLICE", + "CONNECTOR", + "STICKY", + "SHAPE_WITH_TEXT", + "CODE_BLOCK", + "WIDGET", + "TEXT", + "COMPONENT_SET", // Component sets are containers for components, not icons themselves +]); + +// Types disallowed *inside* an icon container (recursive check) +const DISALLOWED_CHILD_TYPES: ReadonlySet = new Set([ + "FRAME", // No nested frames + "COMPONENT", // No nested components + "INSTANCE", // No nested instances + "TEXT", // No text + "SLICE", + "CONNECTOR", + "STICKY", + "SHAPE_WITH_TEXT", + "CODE_BLOCK", + "WIDGET", + "COMPONENT_SET", +]); + +// ======================================================================== +// Helper Function +// ======================================================================== + +/** + * Checks if a node's dimensions fall within a typical *maximum* size for icons. + * Simplified to only check max size. + */ +function isTypicalIconSize( + node: SceneNode, + maxSize = 64, // Standard max size +): boolean { + if ( + !("width" in node && "height" in node && node.width > 0 && node.height > 0) + ) { + return false; // Needs dimensions + } + // Only check if dimensions exceed the maximum allowed size + return node.width <= maxSize && node.height <= maxSize; +} + +/** + * Checks if a node has export settings for SVG. + */ +function hasSvgExportSettings(node: SceneNode): boolean { + const settingsToCheck: ReadonlyArray = + node.exportSettings || []; + return settingsToCheck.some((setting) => setting.format === "SVG"); +} + +/** + * Recursively checks the children of a container node. + * Returns an object indicating if disallowed children were found + * and if any valid icon content (vector/primitive) was found. + */ +function checkChildrenRecursively(children: ReadonlyArray): { + hasDisallowedChild: boolean; + hasValidContent: boolean; +} { + let hasDisallowedChild = false; + let hasValidContent = false; + + for (const child of children) { + if (child.visible === false) { + continue; // Skip invisible children + } + + if (DISALLOWED_CHILD_TYPES.has(child.type)) { + hasDisallowedChild = true; + break; // Found disallowed type, no need to check further + } + + if ( + ICON_COMPLEX_VECTOR_TYPES.has(child.type) || + ICON_PRIMITIVE_TYPES.has(child.type) + ) { + hasValidContent = true; + } else if (child.type === "GROUP" && "children" in child) { + // Recursively check children of groups + const groupResult = checkChildrenRecursively(child.children); + if (groupResult.hasDisallowedChild) { + hasDisallowedChild = true; + break; // Disallowed child found in nested group + } + if (groupResult.hasValidContent) { + hasValidContent = true; // Valid content found in nested group + } + } + // Ignore other node types if they are not explicitly disallowed (e.g., SECTION?) + } + + return { hasDisallowedChild, hasValidContent }; +} + +// ======================================================================== +// Main Icon Recognition Function +// ======================================================================== + +/** + * Analyzes a Figma SceneNode using simplified structural rules to determine if it's likely an icon. + * v5.1: Added rule to always consider nodes with SVG export settings as icons. + * v5.2: Always consider VECTOR nodes as icons, regardless of size. + * v5.3: Always consider VECTOR, BOOLEAN_OPERATION, POLYGON, STAR as icons regardless of size. Simplified size check to max dimension only. + * v5.4: Check for disallowed types *before* checking SVG export settings. + * + * @param node The Figma SceneNode to evaluate. + * @param logDetails Set to true to print debug information to the console. + * @returns True if the node is likely an icon, false otherwise. + */ +export function isLikelyIcon(node: SceneNode, logDetails = false): boolean { + const info: string[] = [`Node: ${node.name} (${node.type}, ID: ${node.id})`]; + let result = false; + let reason = ""; + + // --- 1. Initial Filtering (Disallowed Types First) --- + if (DISALLOWED_ICON_TYPES.has(node.type)) { + reason = `Disallowed Type: ${node.type}`; + result = false; + } + // --- 2. Check for SVG Export Settings (Only if not disallowed) --- + else if (hasSvgExportSettings(node)) { + reason = "Has SVG export settings"; + result = true; + } + // --- 3. Dimension Check --- + else if ( + !("width" in node && "height" in node && node.width > 0 && node.height > 0) + ) { + // Exception: Allow specific types even without dimensions initially. + if (ICON_TYPES_IGNORE_SIZE.has(node.type)) { + reason = `Direct ${node.type} type (no dimensions check needed)`; + result = true; + } else { + reason = "No dimensions"; + result = false; + } + } else { + // --- 4. Direct Vector/Boolean/Primitive --- + // Special case: VECTOR, BOOLEAN_OPERATION, POLYGON, STAR are always icons + if (ICON_TYPES_IGNORE_SIZE.has(node.type)) { + reason = `Direct ${node.type} type (size ignored)`; + result = true; + } + // Check other primitives (ELLIPSE, RECTANGLE, LINE) with size constraint + else if (ICON_PRIMITIVE_TYPES.has(node.type)) { + if (isTypicalIconSize(node)) { + reason = `Direct ${node.type} with typical size`; + result = true; + } else { + reason = `Direct ${node.type} but too large (${Math.round(node.width)}x${Math.round(node.height)})`; + result = false; + } + } + // --- 5. Container Logic --- + else if (ICON_CONTAINER_TYPES.has(node.type) && "children" in node) { + // Container size check still uses the simplified isTypicalIconSize + if (!isTypicalIconSize(node)) { + reason = `Container but too large (${Math.round(node.width)}x${Math.round(node.height)})`; + result = false; + } else { + const visibleChildren = node.children.filter( + (child) => child.visible !== false, + ); + + if (visibleChildren.length === 0) { + // Check for styling on empty containers (size already checked) + const hasVisibleFill = + "fills" in node && + Array.isArray(node.fills) && + node.fills.some( + (f) => + typeof f === "object" && + f !== null && + f.visible !== false && + ("opacity" in f ? (f.opacity ?? 1) : 1) > 0, + ); + const hasVisibleStroke = + "strokes" in node && + Array.isArray(node.strokes) && + node.strokes.some((s) => s.visible !== false); + + if (hasVisibleFill || hasVisibleStroke) { + reason = + "Empty container with visible fill/stroke and typical size"; + result = true; + } else { + reason = "Empty container with no visible style"; + result = false; // Size is okay, but no content or style + } + } else { + // Check content of non-empty containers (size already checked) + const checkResult = checkChildrenRecursively(visibleChildren); + + if (checkResult.hasDisallowedChild) { + reason = + "Container has disallowed child type (Text, Frame, Component, Instance, etc.)"; + result = false; + } else if (!checkResult.hasValidContent) { + // Allow containers if they *only* contain other groups, + // as long as those groups eventually contain valid content. + // The checkResult.hasValidContent handles this. + reason = "Container has no vector or primitive content"; + result = false; + } else { + reason = "Container with valid children and typical size"; + result = true; // Passed size, no disallowed children, has valid content + } + } + } + } + // --- 6. Default --- + else { + reason = + "Not a recognized icon structure (Vector, Primitive, or valid Container)"; + result = false; + } + } + + info.push(`Result: ${result ? "YES" : "NO"} (${reason})`); + if (logDetails) console.log(info.join(" | ")); + return result; +} diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts new file mode 100644 index 00000000..d9b23f32 --- /dev/null +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -0,0 +1,710 @@ +import { addWarning } from "../common/commonConversionWarnings"; +import { PluginSettings } from "types"; +import { variableToColorName } from "../tailwind/conversionTables"; +import { HasGeometryTrait, Node, Paint } from "../api_types"; +import { calculateRectangleFromBoundingBox } from "../common/commonPosition"; +import { isLikelyIcon } from "./iconDetection"; +import { AltNode } from "../alt_api_types"; + +// Performance tracking counters +export let getNodeByIdAsyncTime = 0; +export let getNodeByIdAsyncCalls = 0; +export let getStyledTextSegmentsTime = 0; +export let getStyledTextSegmentsCalls = 0; +export let processColorVariablesTime = 0; +export let processColorVariablesCalls = 0; + +export const resetPerformanceCounters = () => { + getNodeByIdAsyncTime = 0; + getNodeByIdAsyncCalls = 0; + getStyledTextSegmentsTime = 0; + getStyledTextSegmentsCalls = 0; + processColorVariablesTime = 0; + processColorVariablesCalls = 0; +}; + +// Keep track of node names for sequential numbering +const nodeNameCounters: Map = new Map(); + +const variableCache = new Map(); + +/** + * Maps variable IDs to color names and caches the result + */ +const memoizedVariableToColorName = async ( + variableId: string, +): Promise => { + if (!variableCache.has(variableId)) { + const colorName = (await variableToColorName(variableId)).replaceAll( + ",", + "", + ); + variableCache.set(variableId, colorName); + return colorName; + } + return variableCache.get(variableId)!; +}; + +/** + * Maps a color hex value to its variable name using node-specific color mappings + */ +export const getVariableNameFromColor = ( + hexColor: string, + colorMappings?: Map, +): string | undefined => { + if (!colorMappings) return undefined; + + const normalizedColor = hexColor.toLowerCase(); + const mapping = colorMappings.get(normalizedColor); + + if (mapping) { + return mapping.variableName; + } + + return undefined; +}; + +/** + * Collects all color variables used in a node and its descendants + */ +const collectNodeColorVariables = async ( + node: any, +): Promise> => { + const colorMappings = new Map< + string, + { variableId: string; variableName: string } + >(); + + // Helper function to add a mapping from a paint object + const addMappingFromPaint = (paint: any) => { + // Ensure we have a solid paint, a resolved variable name, and color data + if ( + paint.type === "SOLID" && + paint.variableColorName && + paint.color && + paint.boundVariables?.color + ) { + // Prefer the actual variable name from the bound variable if available + const variableName = + paint.boundVariables.color.name || paint.variableColorName; + + if (variableName) { + // Sanitize the variable name for CSS + const sanitizedVarName = variableName.replace(/[^a-zA-Z0-9_-]/g, "-"); + + const colorInfo = { + variableId: paint.boundVariables.color.id, + variableName: sanitizedVarName, + }; + + // Create hex representation of the color + const r = Math.round(paint.color.r * 255); + const g = Math.round(paint.color.g * 255); + const b = Math.round(paint.color.b * 255); + + // Standard hex format (lowercase for consistent mapping) + const hexColor = + `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toLowerCase(); + colorMappings.set(hexColor, colorInfo); + + // Add common named colors that the SVG might use + // When htmlColor() in builderImpl/htmlColor.ts converts colors to strings, + // it returns "white" for RGB(1,1,1) and "black" for RGB(0,0,0) + if (r === 255 && g === 255 && b === 255) { + colorMappings.set("white", colorInfo); // Classic CSS color name + colorMappings.set("rgb(255,255,255)", colorInfo); // RGB format + } else if (r === 0 && g === 0 && b === 0) { + colorMappings.set("black", colorInfo); + colorMappings.set("rgb(0,0,0)", colorInfo); + } + // Add other frequently used named colors if needed + } + } + }; + + // Process fills + if (node.fills && Array.isArray(node.fills)) { + node.fills.forEach(addMappingFromPaint); + } + + // Process strokes + if (node.strokes && Array.isArray(node.strokes)) { + node.strokes.forEach(addMappingFromPaint); + } + + // Process children recursively + if (node.children && Array.isArray(node.children)) { + for (const child of node.children) { + const childMappings = await collectNodeColorVariables(child); + // Merge child mappings with this node's mappings + childMappings.forEach((value, key) => { + colorMappings.set(key, value); + }); + } + } + + return colorMappings; +}; + +/** + * Process color variables in a paint style and add pre-computed variable names + * @param paint The paint style to process (fill or stroke) + */ +export const processColorVariables = async (paint: Paint) => { + const start = Date.now(); + processColorVariablesCalls++; + + if ( + paint.type === "GRADIENT_ANGULAR" || + paint.type === "GRADIENT_DIAMOND" || + paint.type === "GRADIENT_LINEAR" || + paint.type === "GRADIENT_RADIAL" + ) { + // Filter stops with bound variables first to avoid unnecessary work + const stopsWithVariables = paint.gradientStops.filter( + (stop) => stop.boundVariables?.color, + ); + + // Process all gradient stops with variables in parallel + if (stopsWithVariables.length > 0) { + await Promise.all( + stopsWithVariables.map(async (stop) => { + (stop as any).variableColorName = await memoizedVariableToColorName( + stop.boundVariables!.color!.id, + ); + }), + ); + } + } else if (paint.type === "SOLID" && paint.boundVariables?.color) { + // Pre-compute and store the variable name + (paint as any).variableColorName = await memoizedVariableToColorName( + paint.boundVariables.color.id, + ); + } + + processColorVariablesTime += Date.now() - start; +}; + +const processEffectVariables = async ( + paint: DropShadowEffect | InnerShadowEffect, +) => { + const start = Date.now(); + processColorVariablesCalls++; + + if (paint.boundVariables?.color) { + // Pre-compute and store the variable name + (paint as any).variableColorName = await memoizedVariableToColorName( + paint.boundVariables.color.id, + ); + } + + processColorVariablesTime += Date.now() - start; +}; + +const getColorVariables = async ( + node: HasGeometryTrait, + settings: PluginSettings, +) => { + // This tries to be as fast as it can, using Promise.all so it can parallelize calls. + if (settings.useColorVariables) { + if (node.fills && Array.isArray(node.fills)) { + await Promise.all( + node.fills.map((fill: Paint) => processColorVariables(fill)), + ); + } + if (node.strokes && Array.isArray(node.strokes)) { + await Promise.all( + node.strokes.map((stroke: Paint) => processColorVariables(stroke)), + ); + } + if ("effects" in node && node.effects && Array.isArray(node.effects)) { + await Promise.all( + node.effects + .filter( + (effect: Effect) => + effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW", + ) + .map((effect: DropShadowEffect | InnerShadowEffect) => + processEffectVariables(effect), + ), + ); + } + } +}; + +function adjustChildrenOrder(node: any) { + if (!node.itemReverseZIndex || !node.children || node.layoutMode === "NONE") { + return; + } + + const children = node.children; + const absoluteChildren = []; + const fixedChildren = []; + + // Single pass to separate absolute and fixed children + for (let i = children.length - 1; i >= 0; i--) { + const child = children[i]; + if (child.layoutPositioning === "ABSOLUTE") { + absoluteChildren.push(child); + } else { + fixedChildren.unshift(child); // Add to beginning to maintain original order + } + } + + // Combine the arrays (reversed absolute children + original order fixed children) + node.children = [...absoluteChildren, ...fixedChildren]; +} + +/** + * Recursively process both JSON node and Figma node to update with data not available in JSON + * This now includes the functionality from convertNodeToAltNode + * @param jsonNode The JSON node to process + * @param figmaNode The corresponding Figma node + * @param settings Plugin settings + * @param parentNode Optional parent node reference to set + * @param parentCumulativeRotation Optional parent cumulative rotation to inherit + * @returns Potentially modified jsonNode, array of nodes (for inlined groups), or null + */ +const processNodePair = async ( + jsonNode: AltNode, + figmaNode: SceneNode, + settings: PluginSettings, + parentNode?: AltNode, + parentCumulativeRotation: number = 0, +): Promise => { + if (!jsonNode.id) return null; + if (jsonNode.visible === false) return null; + + // Handle node type-specific conversions (from convertNodeToAltNode) + const nodeType = jsonNode.type; + + // Store the cumulative rotation (parent's cumulative + node's own) + if (parentNode) { + // Only add cumulative when there is a parent. This is useful for the GROUP -> FRAME transformation, where + // we want to move the rotation of the GROUP to children, but want to se FRAME to 0. + jsonNode.cumulativeRotation = parentCumulativeRotation; + } + + // Handle empty frames and convert to rectangles + if ( + (nodeType === "FRAME" || + nodeType === "INSTANCE" || + nodeType === "COMPONENT" || + nodeType === "COMPONENT_SET") && + (!jsonNode.children || jsonNode.children.length === 0) + ) { + // Convert to rectangle + (jsonNode as any).type = "RECTANGLE"; + return processNodePair( + jsonNode, + figmaNode, + settings, + parentNode, + parentCumulativeRotation, + ); + } + + if ("rotation" in jsonNode && jsonNode.rotation) { + jsonNode.rotation = -jsonNode.rotation * (180 / Math.PI); + } + + // Inline all GROUP nodes by processing their children directly + if (nodeType === "GROUP" && jsonNode.children) { + const processedChildren = []; + + if ( + Array.isArray(jsonNode.children) && + figmaNode && + "children" in figmaNode + ) { + // Get visible JSON children (filters out nodes with visible: false) + const visibleJsonChildren = jsonNode.children.filter( + (child) => child.visible !== false, + ) as AltNode[]; + + // Map figma children to their IDs for matching + const figmaChildrenById = new Map(); + figmaNode.children.forEach((child) => { + figmaChildrenById.set(child.id, child); + }); + + // Process all visible JSON children that have matching Figma nodes + for (const child of visibleJsonChildren) { + const figmaChild = figmaChildrenById.get(child.id); + if (!figmaChild) continue; // Skip if no matching Figma node found + + const processedChild = await processNodePair( + child, + figmaChild, + settings, + parentNode, // The group's parent + parentCumulativeRotation + (jsonNode.rotation || 0), + ); + + // Push the processed group children directly + if (processedChild !== null) { + if (Array.isArray(processedChild)) { + processedChildren.push(...processedChild); + } else { + processedChildren.push(processedChild); + } + } + } + } + + // Simply return the processed children; skip splicing parent's children + return processedChildren; + } + + // Return null for unsupported nodes + if (nodeType === "SLICE") { + return null; + } + + // Set parent reference if parent is provided + if (parentNode) { + (jsonNode as any).parent = parentNode; + } + + // Ensure node has a unique name with simple numbering + const cleanName = jsonNode.name.trim(); + + // Track names with simple counter + const count = nodeNameCounters.get(cleanName) || 0; + nodeNameCounters.set(cleanName, count + 1); + + // For first occurrence, use original name; for duplicates, add sequential suffix + jsonNode.uniqueName = + count === 0 + ? cleanName + : `${cleanName}_${count.toString().padStart(2, "0")}`; + + // Handle text-specific properties + if (figmaNode.type === "TEXT") { + const getSegmentsStart = Date.now(); + getStyledTextSegmentsCalls++; + let styledTextSegments = figmaNode.getStyledTextSegments([ + "fontName", + "fills", + "fontSize", + "fontWeight", + "hyperlink", + "indentation", + "letterSpacing", + "lineHeight", + "listOptions", + "textCase", + "textDecoration", + "textStyleId", + "fillStyleId", + "openTypeFeatures", + ]); + getStyledTextSegmentsTime += Date.now() - getSegmentsStart; + + // Assign unique IDs to each segment + if (styledTextSegments.length > 0) { + const baseSegmentName = (jsonNode.uniqueName || jsonNode.name) + .replace(/[^a-zA-Z0-9_-]/g, "") + .toLowerCase(); + + // Add a uniqueId to each segment + styledTextSegments = await Promise.all( + styledTextSegments.map(async (segment, index) => { + const mutableSegment: any = Object.assign({}, segment); + + if (settings.useColorVariables && segment.fills) { + mutableSegment.fills = await Promise.all( + segment.fills.map(async (d) => { + if ( + d.blendMode !== "PASS_THROUGH" && + d.blendMode !== "NORMAL" + ) { + addWarning("BlendMode is not supported in Text colors"); + } + const fill = { ...d } as Paint; + await processColorVariables(fill); + return fill; + }), + ); + } + + // For single segments, don't add index suffix + if (styledTextSegments.length === 1) { + (mutableSegment as any).uniqueId = `${baseSegmentName}_span`; + } else { + // For multiple segments, add index suffix + (mutableSegment as any).uniqueId = + `${baseSegmentName}_span_${(index + 1).toString().padStart(2, "0")}`; + } + return mutableSegment; + }), + ); + + jsonNode.styledTextSegments = styledTextSegments; + } + + // Inline text style. + Object.assign(jsonNode, jsonNode.style); + if (!jsonNode.textAutoResize) { + jsonNode.textAutoResize = "NONE"; + } + } + + // Always copy size and position + if ("absoluteBoundingBox" in jsonNode && jsonNode.absoluteBoundingBox) { + if (jsonNode.parent) { + // Extract width and height from bounding box and rotation. This is necessary because Figma JSON API doesn't have width and height. + const rect = calculateRectangleFromBoundingBox( + { + width: jsonNode.absoluteBoundingBox.width, + height: jsonNode.absoluteBoundingBox.height, + x: + jsonNode.absoluteBoundingBox.x - + (jsonNode.parent?.absoluteBoundingBox.x || 0), + y: + jsonNode.absoluteBoundingBox.y - + (jsonNode.parent?.absoluteBoundingBox.y || 0), + }, + -((jsonNode.rotation || 0) + (jsonNode.cumulativeRotation || 0)), + ); + + jsonNode.width = rect.width; + jsonNode.height = rect.height; + jsonNode.x = rect.left; + jsonNode.y = rect.top; + } else { + jsonNode.width = jsonNode.absoluteBoundingBox.width; + jsonNode.height = jsonNode.absoluteBoundingBox.height; + jsonNode.x = 0; + jsonNode.y = 0; + } + } + + // Add canBeFlattened property + if (settings.embedVectors && !parentNode?.canBeFlattened) { + const isIcon = isLikelyIcon(jsonNode as any); + (jsonNode as any).canBeFlattened = isIcon; + + // If this node will be flattened to SVG, collect its color variables + if (isIcon && settings.useColorVariables) { + // Schedule color mapping collection after variable processing + (jsonNode as any)._collectColorMappings = true; + } + } else { + (jsonNode as any).canBeFlattened = false; + } + + if ( + "individualStrokeWeights" in jsonNode && + jsonNode.individualStrokeWeights + ) { + (jsonNode as any).strokeTopWeight = jsonNode.individualStrokeWeights.top; + (jsonNode as any).strokeBottomWeight = + jsonNode.individualStrokeWeights.bottom; + (jsonNode as any).strokeLeftWeight = jsonNode.individualStrokeWeights.left; + (jsonNode as any).strokeRightWeight = + jsonNode.individualStrokeWeights.right; + } + + await getColorVariables(jsonNode, settings); + + // Some places check if paddingLeft exists. This makes sure they all exist, even if 0. + if ("layoutMode" in jsonNode && jsonNode.layoutMode) { + if (jsonNode.paddingLeft === undefined) { + jsonNode.paddingLeft = 0; + } + if (jsonNode.paddingRight === undefined) { + jsonNode.paddingRight = 0; + } + if (jsonNode.paddingTop === undefined) { + jsonNode.paddingTop = 0; + } + if (jsonNode.paddingBottom === undefined) { + jsonNode.paddingBottom = 0; + } + } + + // Set default layout properties if missing + if (!jsonNode.layoutMode) jsonNode.layoutMode = "NONE"; + if (!jsonNode.layoutGrow) jsonNode.layoutGrow = 0; + if (!jsonNode.layoutSizingHorizontal) + jsonNode.layoutSizingHorizontal = "FIXED"; + if (!jsonNode.layoutSizingVertical) jsonNode.layoutSizingVertical = "FIXED"; + if (!jsonNode.primaryAxisAlignItems) { + jsonNode.primaryAxisAlignItems = "MIN"; + } + if (!jsonNode.counterAxisAlignItems) { + jsonNode.counterAxisAlignItems = "MIN"; + } + + // If layout sizing is HUG but there are no children, set it to FIXED + const hasChildren = + "children" in jsonNode && + jsonNode.children && + Array.isArray(jsonNode.children) && + jsonNode.children.length > 0; + + if (jsonNode.layoutSizingHorizontal === "HUG" && !hasChildren) { + jsonNode.layoutSizingHorizontal = "FIXED"; + } + if (jsonNode.layoutSizingVertical === "HUG" && !hasChildren) { + jsonNode.layoutSizingVertical = "FIXED"; + } + + // Process children recursively if both have children + if ( + "children" in jsonNode && + jsonNode.children && + Array.isArray(jsonNode.children) && + "children" in figmaNode + ) { + // Get only visible JSON children + const visibleJsonChildren = jsonNode.children.filter( + (child) => child.visible !== false, + ) as AltNode[]; + + // Create a map of figma children by ID for easier matching + const figmaChildrenById = new Map(); + figmaNode.children.forEach((child) => { + figmaChildrenById.set(child.id, child); + }); + + const cumulative = + parentCumulativeRotation + + (jsonNode.type === "GROUP" ? jsonNode.rotation || 0 : 0); + + // Process children and handle potential null returns + const processedChildren = []; + + // Process all visible JSON children that have matching Figma nodes + for (const child of visibleJsonChildren) { + const figmaChild = figmaChildrenById.get(child.id); + if (!figmaChild) continue; // Skip if no matching Figma node found + + const processedChild = await processNodePair( + child, + figmaChild, + settings, + jsonNode, + cumulative, + ); + + if (processedChild !== null) { + if (Array.isArray(processedChild)) { + processedChildren.push(...processedChild); + } else { + processedChildren.push(processedChild); + } + } + } + + // Replace children array with processed children + jsonNode.children = processedChildren; + + if ( + jsonNode.layoutMode === "NONE" || + jsonNode.children.some( + (d: any) => + "layoutPositioning" in d && d.layoutPositioning === "ABSOLUTE", + ) + ) { + jsonNode.isRelative = true; + } + + adjustChildrenOrder(jsonNode); + } + + // Collect color variables for SVG nodes after all processing is done + if ((jsonNode as any)._collectColorMappings) { + (jsonNode as any).colorVariableMappings = + await collectNodeColorVariables(jsonNode); + delete (jsonNode as any)._collectColorMappings; + } + + return jsonNode; +}; + +/** + * Convert Figma nodes to JSON format with parent references added + * @param nodes The Figma nodes to convert to JSON + * @param settings Plugin settings + * @returns JSON representation of the nodes with parent references + */ +export const nodesToJSON = async ( + nodes: ReadonlyArray, + settings: PluginSettings, +): Promise => { + // Reset name counters for each conversion + nodeNameCounters.clear(); + const exportJsonStart = Date.now(); + // First get the JSON representation of nodes with rotation handling + const nodeResults = await Promise.all( + nodes.map(async (node) => { + // Export node to JSON + const nodeDoc = ( + (await node.exportAsync({ + format: "JSON_REST_V1", + })) as any + ).document; + + let nodeCumulativeRotation = 0; + + // Wire GROUPs into FRAME. + if (node.type === "GROUP") { + nodeDoc.type = "FRAME"; + + // Fix rotation for children. + if ("rotation" in nodeDoc && nodeDoc.rotation) { + nodeCumulativeRotation = -nodeDoc.rotation * (180 / Math.PI); + nodeDoc.rotation = 0; + } + } + + return { + nodeDoc, + nodeCumulativeRotation, + }; + }), + ); + + if (nodes.length > 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`, + ); + + // Now process each top-level node pair (JSON node + Figma node) + const processNodesStart = Date.now(); + const result: Node[] = []; + + for (let i = 0; i < nodes.length; i++) { + const processedNode = await processNodePair( + nodeResults[i].nodeDoc, + nodes[i], + settings, + undefined, + nodeResults[i].nodeCumulativeRotation, + ); + if (processedNode !== null) { + if (Array.isArray(processedNode)) { + // If processNodePair returns an array (inlined group), add all nodes + result.push(...processedNode); + } else { + // If it returns a single node, add it directly + result.push(processedNode); + } + } + } + + console.log( + `[benchmark][inside nodesToJSON] Process node pairs: ${Date.now() - processNodesStart}ms`, + ); + + return result; +}; diff --git a/packages/backend/src/altNodes/altConversion.ts b/packages/backend/src/altNodes/oldAltConversion.ts similarity index 64% rename from packages/backend/src/altNodes/altConversion.ts rename to packages/backend/src/altNodes/oldAltConversion.ts index a6c51d71..02a1244a 100644 --- a/packages/backend/src/altNodes/altConversion.ts +++ b/packages/backend/src/altNodes/oldAltConversion.ts @@ -4,8 +4,29 @@ import { isNotEmpty, assignRectangleType, assignChildren, - isTypeOrGroupOfTypes, } from "./altNodeUtils"; +import { curry } from "../common/curry"; + +export const isTypeOrGroupOfTypes = curry( + (matchTypes: NodeType[], node: SceneNode): boolean => { + if (node.visible === false || matchTypes.includes(node.type)) return true; + + if ("children" in node) { + for (let i = 0; i < node.children.length; i++) { + const childNode = node.children[i]; + const result = isTypeOrGroupOfTypes(matchTypes, childNode); + if (result) continue; + // child is false + return false; + } + // all children are true + return true; + } + + // not group or vector + return false; + }, +); export let globalTextStyleSegments: Record = {}; @@ -21,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 @@ -51,7 +79,7 @@ export const convertNodeToAltNode = case "SECTION": const group = cloneNode(node, parent); - const groupChildren = convertNodesToAltNodes(node.children, group); + const groupChildren = oldConvertNodesToAltNodes(node.children, group); return assignChildren(groupChildren, group); // Text Nodes @@ -71,7 +99,7 @@ export const convertNodeToAltNode = } }; -export const convertNodesToAltNodes = ( +export const oldConvertNodesToAltNodes = ( sceneNode: ReadonlyArray, parent: ParentNode | null, ): Array => @@ -84,32 +112,44 @@ export const cloneNode = ( // Create the cloned object with the correct prototype const cloned = {} as T; // Create a new object with only the desired descriptors (excluding 'parent' and 'children') - const droppedProps = [ - "parent", - "children", - "horizontalPadding", - "verticalPadding", - "mainComponent", - "masterComponent", - "variantProperties", - "get_annotations", - "componentPropertyDefinitions", - "exposedInstances", - "componentProperties", - "componenPropertyReferences", - ]; for (const prop in node) { - if (prop in droppedProps === false) { + if ( + prop !== "parent" && + prop !== "children" && + prop !== "horizontalPadding" && + prop !== "verticalPadding" && + prop !== "mainComponent" && + prop !== "masterComponent" && + prop !== "variantProperties" && + prop !== "get_annotations" && + prop !== "componentPropertyDefinitions" && + prop !== "exposedInstances" && + prop !== "instances" && + prop !== "componentProperties" && + prop !== "componenPropertyReferences" && + prop !== "constrainProportions" + ) { cloned[prop as keyof T] = node[prop as keyof T]; } } + + // Set parent explicitly in addition to using assignParent assignParent(parent, cloned); + // if (parent) { + // (cloned as any).parent = parent; + // } const altNode = { ...cloned, + parent: cloned.parent, originalNode: node, canBeFlattened: canBeFlattened(node), } as AltNode; + + if (globalTextStyleSegments[node.id]) { + altNode.styledTextSegments = globalTextStyleSegments[node.id]; + } + return altNode; }; diff --git a/packages/backend/src/alt_api_types.ts b/packages/backend/src/alt_api_types.ts new file mode 100644 index 00000000..3f287953 --- /dev/null +++ b/packages/backend/src/alt_api_types.ts @@ -0,0 +1,15 @@ +import { type Node } from "./api_types"; + +export type AltNode = Node & { + styledTextSegments: Array< + Pick + >; + cumulativeRotation: number; + uniqueName: string; + canBeFlattened: boolean; + isRelative: boolean; + width: number; + height: number; + x: number; + y: number; +}; diff --git a/packages/backend/src/api_types.ts b/packages/backend/src/api_types.ts new file mode 100644 index 00000000..46327862 --- /dev/null +++ b/packages/backend/src/api_types.ts @@ -0,0 +1,6913 @@ +// Copied from https://github.com/figma/rest-api-spec/blob/main/dist/api_types.ts + +export type IsLayerTrait = { + /** + * A string uniquely identifying this node within the document. + */ + id: string + + /** + * The name given to the node by the user in the tool. + */ + name: string + + /** + * The type of the node + */ + type: string + + /** + * Whether or not the node is visible on the canvas. + */ + visible?: boolean + + /** + * If true, layer is locked and cannot be edited + */ + locked?: boolean + + /** + * Whether the layer is fixed while the parent is scrolling + * + * @deprecated + */ + isFixed?: boolean + + /** + * How layer should be treated when the frame is resized + */ + scrollBehavior: 'SCROLLS' | 'FIXED' | 'STICKY_SCROLLS' + + /** + * The rotation of the node, if not 0. + */ + rotation?: number + + /** + * A mapping of a layer's property to component property name of component properties attached to + * this node. The component property name can be used to look up more information on the + * corresponding component's or component set's componentPropertyDefinitions. + */ + componentPropertyReferences?: { [key: string]: string } + + /** + * Data written by plugins that is visible only to the plugin that wrote it. Requires the + * `pluginData` to include the ID of the plugin. + */ + pluginData?: unknown + + /** + * Data written by plugins that is visible to all plugins. Requires the `pluginData` parameter to + * include the string "shared". + */ + sharedPluginData?: unknown + + /** + * A mapping of field to the variables applied to this field. Most fields will only map to a single + * `VariableAlias`. However, for properties like `fills`, `strokes`, `size`, `componentProperties`, + * and `textRangeFills`, it is possible to have multiple variables bound to the field. + */ + boundVariables?: { + size?: { + x?: VariableAlias + + y?: VariableAlias + } + + individualStrokeWeights?: { + top?: VariableAlias + + bottom?: VariableAlias + + left?: VariableAlias + + right?: VariableAlias + } + + characters?: VariableAlias + + itemSpacing?: VariableAlias + + paddingLeft?: VariableAlias + + paddingRight?: VariableAlias + + paddingTop?: VariableAlias + + paddingBottom?: VariableAlias + + visible?: VariableAlias + + topLeftRadius?: VariableAlias + + topRightRadius?: VariableAlias + + bottomLeftRadius?: VariableAlias + + bottomRightRadius?: VariableAlias + + minWidth?: VariableAlias + + maxWidth?: VariableAlias + + minHeight?: VariableAlias + + maxHeight?: VariableAlias + + counterAxisSpacing?: VariableAlias + + opacity?: VariableAlias + + fontFamily?: VariableAlias[] + + fontSize?: VariableAlias[] + + fontStyle?: VariableAlias[] + + fontWeight?: VariableAlias[] + + letterSpacing?: VariableAlias[] + + lineHeight?: VariableAlias[] + + paragraphSpacing?: VariableAlias[] + + paragraphIndent?: VariableAlias[] + + fills?: VariableAlias[] + + strokes?: VariableAlias[] + + componentProperties?: { [key: string]: VariableAlias } + + textRangeFills?: VariableAlias[] + + effects?: VariableAlias[] + + layoutGrids?: VariableAlias[] + } + + /** + * A mapping of variable collection ID to mode ID representing the explicitly set modes for this + * node. + */ + explicitVariableModes?: { [key: string]: string } + } + + export type HasChildrenTrait = { + /** + * An array of nodes that are direct children of this node + */ + children: SubcanvasNode[] + } + + export type HasLayoutTrait = { + /** + * Bounding box of the node in absolute space coordinates. + */ + absoluteBoundingBox: Rectangle | null + + /** + * The actual bounds of a node accounting for drop shadows, thick strokes, and anything else that + * may fall outside the node's regular bounding box defined in `x`, `y`, `width`, and `height`. The + * `x` and `y` inside this property represent the absolute position of the node on the page. This + * value will be `null` if the node is invisible. + */ + absoluteRenderBounds: Rectangle | null + + /** + * Keep height and width constrained to same ratio. + */ + preserveRatio?: boolean + + /** + * Horizontal and vertical layout constraints for node. + */ + constraints?: LayoutConstraint + + /** + * The top two rows of a matrix that represents the 2D transform of this node relative to its + * parent. The bottom row of the matrix is implicitly always (0, 0, 1). Use to transform coordinates + * in geometry. Only present if `geometry=paths` is passed. + */ + relativeTransform?: Transform + + /** + * Width and height of element. This is different from the width and height of the bounding box in + * that the absolute bounding box represents the element after scaling and rotation. Only present if + * `geometry=paths` is passed. + */ + size?: Vector + + /** + * Determines if the layer should stretch along the parent's counter axis. This property is only + * provided for direct children of auto-layout frames. + * + * - `INHERIT` + * - `STRETCH` + * + * In previous versions of auto layout, determined how the layer is aligned inside an auto-layout + * frame. This property is only provided for direct children of auto-layout frames. + * + * - `MIN` + * - `CENTER` + * - `MAX` + * - `STRETCH` + * + * In horizontal auto-layout frames, "MIN" and "MAX" correspond to "TOP" and "BOTTOM". In vertical + * auto-layout frames, "MIN" and "MAX" correspond to "LEFT" and "RIGHT". + */ + layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX' + + /** + * This property is applicable only for direct children of auto-layout frames, ignored otherwise. + * Determines whether a layer should stretch along the parent's primary axis. A `0` corresponds to a + * fixed size and `1` corresponds to stretch. + */ + layoutGrow?: 0 | 1 + + /** + * Determines whether a layer's size and position should be determined by auto-layout settings or + * manually adjustable. + */ + layoutPositioning?: 'AUTO' | 'ABSOLUTE' + + /** + * The minimum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + minWidth?: number + + /** + * The maximum width of the frame. This property is only applicable for auto-layout frames or direct + * children of auto-layout frames. + */ + maxWidth?: number + + /** + * The minimum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + minHeight?: number + + /** + * The maximum height of the frame. This property is only applicable for auto-layout frames or + * direct children of auto-layout frames. + */ + maxHeight?: number + + /** + * The horizontal sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingHorizontal?: 'FIXED' | 'HUG' | 'FILL' + + /** + * The vertical sizing setting on this auto-layout frame or frame child. + * + * - `FIXED` + * - `HUG`: only valid on auto-layout frames and text nodes + * - `FILL`: only valid on auto-layout frame children + */ + layoutSizingVertical?: 'FIXED' | 'HUG' | 'FILL' + } + + export type HasFramePropertiesTrait = { + /** + * Whether or not this node clip content outside of its bounds + */ + clipsContent: boolean + + /** + * Background of the node. This is deprecated, as backgrounds for frames are now in the `fills` + * field. + * + * @deprecated + */ + background?: Paint[] + + /** + * Background color of the node. This is deprecated, as frames now support more than a solid color + * as a background. Please use the `fills` field instead. + * + * @deprecated + */ + backgroundColor?: RGBA + + /** + * An array of layout grids attached to this node (see layout grids section for more details). GROUP + * nodes do not have this attribute + */ + layoutGrids?: LayoutGrid[] + + /** + * Whether a node has primary axis scrolling, horizontal or vertical. + */ + overflowDirection?: + | 'HORIZONTAL_SCROLLING' + | 'VERTICAL_SCROLLING' + | 'HORIZONTAL_AND_VERTICAL_SCROLLING' + | 'NONE' + + /** + * Whether this layer uses auto-layout to position its children. + */ + layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL' + + /** + * Whether the primary axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + primaryAxisSizingMode?: 'FIXED' | 'AUTO' + + /** + * Whether the counter axis has a fixed length (determined by the user) or an automatic length + * (determined by the layout engine). This property is only applicable for auto-layout frames. + */ + counterAxisSizingMode?: 'FIXED' | 'AUTO' + + /** + * Determines how the auto-layout frame's children should be aligned in the primary axis direction. + * This property is only applicable for auto-layout frames. + */ + primaryAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN' + + /** + * Determines how the auto-layout frame's children should be aligned in the counter axis direction. + * This property is only applicable for auto-layout frames. + */ + counterAxisAlignItems?: 'MIN' | 'CENTER' | 'MAX' | 'BASELINE' + + /** + * The padding between the left border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingLeft?: number + + /** + * The padding between the right border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingRight?: number + + /** + * The padding between the top border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingTop?: number + + /** + * The padding between the bottom border of the frame and its children. This property is only + * applicable for auto-layout frames. + */ + paddingBottom?: number + + /** + * The distance between children of the frame. Can be negative. This property is only applicable for + * auto-layout frames. + */ + itemSpacing?: number + + /** + * Determines the canvas stacking order of layers in this frame. When true, the first layer will be + * draw on top. This property is only applicable for auto-layout frames. + */ + itemReverseZIndex?: boolean + + /** + * Determines whether strokes are included in layout calculations. When true, auto-layout frames + * behave like css "box-sizing: border-box". This property is only applicable for auto-layout + * frames. + */ + strokesIncludedInLayout?: boolean + + /** + * Whether this auto-layout frame has wrapping enabled. + */ + layoutWrap?: 'NO_WRAP' | 'WRAP' + + /** + * The distance between wrapped tracks of an auto-layout frame. This property is only applicable for + * auto-layout frames with `layoutWrap: "WRAP"` + */ + counterAxisSpacing?: number + + /** + * Determines how the auto-layout frame’s wrapped tracks should be aligned in the counter axis + * direction. This property is only applicable for auto-layout frames with `layoutWrap: "WRAP"`. + */ + counterAxisAlignContent?: 'AUTO' | 'SPACE_BETWEEN' + } + + export type HasBlendModeAndOpacityTrait = { + /** + * How this node blends with nodes behind it in the scene (see blend mode section for more details) + */ + blendMode: BlendMode + + /** + * Opacity of the node + */ + opacity?: number + } + + export type HasExportSettingsTrait = { + /** + * An array of export settings representing images to export from the node. + */ + exportSettings?: ExportSetting[] + } + + export type HasGeometryTrait = MinimalFillsTrait & + MinimalStrokesTrait & { + /** + * Map from ID to PaintOverride for looking up fill overrides. To see which regions are overriden, + * you must use the `geometry=paths` option. Each path returned may have an `overrideID` which maps + * to this table. + */ + fillOverrideTable?: { [key: string]: PaintOverride | null } + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * fill. + */ + fillGeometry?: Path[] + + /** + * Only specified if parameter `geometry=paths` is used. An array of paths representing the object + * stroke. + */ + strokeGeometry?: Path[] + + /** + * A string enum describing the end caps of vector paths. + */ + strokeCap?: + | 'NONE' + | 'ROUND' + | 'SQUARE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + | 'WASHI_TAPE_1' + | 'WASHI_TAPE_2' + | 'WASHI_TAPE_3' + | 'WASHI_TAPE_4' + | 'WASHI_TAPE_5' + | 'WASHI_TAPE_6' + + /** + * Only valid if `strokeJoin` is "MITER". The corner angle, in degrees, below which `strokeJoin` + * will be set to "BEVEL" to avoid super sharp corners. By default this is 28.96 degrees. + */ + strokeMiterAngle?: number + } + + export type MinimalFillsTrait = { + /** + * An array of fill paints applied to the node. + */ + fills: Paint[] + + /** + * A mapping of a StyleType to style ID (see Style) of styles present on this node. The style ID can + * be used to look up more information about the style in the top-level styles field. + */ + styles?: { [key: string]: string } + } + + export type MinimalStrokesTrait = { + /** + * An array of stroke paints applied to the node. + */ + strokes?: Paint[] + + /** + * The weight of strokes on the node. + */ + strokeWeight?: number + + /** + * Position of stroke relative to vector outline, as a string enum + * + * - `INSIDE`: stroke drawn inside the shape boundary + * - `OUTSIDE`: stroke drawn outside the shape boundary + * - `CENTER`: stroke drawn centered along the shape boundary + */ + strokeAlign?: 'INSIDE' | 'OUTSIDE' | 'CENTER' + + /** + * A string enum with value of "MITER", "BEVEL", or "ROUND", describing how corners in vector paths + * are rendered. + */ + strokeJoin?: 'MITER' | 'BEVEL' | 'ROUND' + + /** + * An array of floating point numbers describing the pattern of dash length and gap lengths that the + * vector stroke will use when drawn. + * + * For example a value of [1, 2] indicates that the stroke will be drawn with a dash of length 1 + * followed by a gap of length 2, repeated. + */ + strokeDashes?: number[] + } + + export type IndividualStrokesTrait = { + /** + * An object including the top, bottom, left, and right stroke weights. Only returned if individual + * stroke weights are used. + */ + individualStrokeWeights?: StrokeWeights + } + + export type CornerTrait = { + /** + * Radius of each corner if a single radius is set for all corners + */ + cornerRadius?: number + + /** + * A value that lets you control how "smooth" the corners are. Ranges from 0 to 1. 0 is the default + * and means that the corner is perfectly circular. A value of 0.6 means the corner matches the iOS + * 7 "squircle" icon shape. Other values produce various other curves. + */ + cornerSmoothing?: number + + /** + * Array of length 4 of the radius of each corner of the frame, starting in the top left and + * proceeding clockwise. + * + * Values are given in the order top-left, top-right, bottom-right, bottom-left. + */ + rectangleCornerRadii?: number[] + } + + export type HasEffectsTrait = { + /** + * An array of effects attached to this node (see effects section for more details) + */ + effects: Effect[] + } + + export type HasMaskTrait = { + /** + * Does this node mask sibling nodes in front of it? + */ + isMask?: boolean + + /** + * If this layer is a mask, this property describes the operation used to mask the layer's siblings. + * The value may be one of the following: + * + * - ALPHA: the mask node's alpha channel will be used to determine the opacity of each pixel in the + * masked result. + * - VECTOR: if the mask node has visible fill paints, every pixel inside the node's fill regions will + * be fully visible in the masked result. If the mask has visible stroke paints, every pixel + * inside the node's stroke regions will be fully visible in the masked result. + * - LUMINANCE: the luminance value of each pixel of the mask node will be used to determine the + * opacity of that pixel in the masked result. + */ + maskType?: 'ALPHA' | 'VECTOR' | 'LUMINANCE' + + /** + * True if maskType is VECTOR. This field is deprecated; use maskType instead. + * + * @deprecated + */ + isMaskOutline?: boolean + } + + export type ComponentPropertiesTrait = { + /** + * A mapping of name to `ComponentPropertyDefinition` for every component property on this + * component. Each property has a type, defaultValue, and other optional values. + */ + componentPropertyDefinitions?: { [key: string]: ComponentPropertyDefinition } + } + + export type TypePropertiesTrait = { + /** + * The raw characters in the text node. + */ + characters: string + + /** + * Style of text including font family and weight. + */ + style: TypeStyle + + /** + * The array corresponds to characters in the text box, where each element references the + * 'styleOverrideTable' to apply specific styles to each character. The array's length can be less + * than or equal to the number of characters due to the removal of trailing zeros. Elements with a + * value of 0 indicate characters that use the default type style. If the array is shorter than the + * total number of characters, the characters beyond the array's length also use the default style. + */ + characterStyleOverrides: number[] + + /** + * Internal property, preserved for backward compatibility. Avoid using this value. + */ + layoutVersion?: number + + /** + * Map from ID to TypeStyle for looking up style overrides. + */ + styleOverrideTable: { [key: string]: TypeStyle } + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the list type + * of a specific line. List types are represented as string enums with one of these possible + * values: + * + * - `NONE`: Not a list item. + * - `ORDERED`: Text is an ordered list (numbered). + * - `UNORDERED`: Text is an unordered list (bulleted). + */ + lineTypes: ('NONE' | 'ORDERED' | 'UNORDERED')[] + + /** + * An array with the same number of elements as lines in the text node, where lines are delimited by + * newline or paragraph separator characters. Each element in the array corresponds to the + * indentation level of a specific line. + */ + lineIndentations: number[] + } + + export type HasTextSublayerTrait = { + /** + * Text contained within a text box. + */ + characters: string + } + + export type TransitionSourceTrait = { + /** + * Node ID of node to transition to in prototyping + */ + transitionNodeID?: string + + /** + * The duration of the prototyping transition on this node (in milliseconds). This will override the + * default transition duration on the prototype, for this node. + */ + transitionDuration?: number + + /** + * The easing curve used in the prototyping transition on this node. + */ + transitionEasing?: EasingType + + interactions?: Interaction[] + } + + export type DevStatusTrait = { + /** + * Represents whether or not a node has a particular handoff (or dev) status applied to it. + */ + devStatus?: { + type: 'NONE' | 'READY_FOR_DEV' | 'COMPLETED' + + /** + * An optional field where the designer can add more information about the design and what has + * changed. + */ + description?: string + } + } + + export type AnnotationsTrait = object + + export type FrameTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasFramePropertiesTrait & + CornerTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait & + IndividualStrokesTrait & + DevStatusTrait & + AnnotationsTrait + + export type DefaultShapeTraits = IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait + + export type CornerRadiusShapeTraits = DefaultShapeTraits & CornerTrait + + export type RectangularShapeTraits = DefaultShapeTraits & + CornerTrait & + IndividualStrokesTrait & + AnnotationsTrait + + export type Node = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | ConnectorNode + | EllipseNode + | EmbedNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | LinkUnfurlNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | ShapeWithTextNode + | SliceNode + | SlotNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | VectorNode + | WashiTapeNode + | WidgetNode + | DocumentNode + | CanvasNode + + export type DocumentNode = { + type: 'DOCUMENT' + + children: CanvasNode[] + } & IsLayerTrait + + export type CanvasNode = { + type: 'CANVAS' + + children: SubcanvasNode[] + + /** + * Background color of the canvas. + */ + backgroundColor: RGBA + + /** + * Node ID that corresponds to the start frame for prototypes. This is deprecated with the + * introduction of multiple flows. Please use the `flowStartingPoints` field. + * + * @deprecated + */ + prototypeStartNodeID: string | null + + /** + * An array of flow starting points sorted by its position in the prototype settings panel. + */ + flowStartingPoints: FlowStartingPoint[] + + /** + * The device used to view a prototype. + */ + prototypeDevice: PrototypeDevice + + measurements?: Measurement[] + } & IsLayerTrait & + HasExportSettingsTrait + + export type SubcanvasNode = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | ConnectorNode + | EllipseNode + | EmbedNode + | FrameNode + | GroupNode + | InstanceNode + | LineNode + | LinkUnfurlNode + | RectangleNode + | RegularPolygonNode + | SectionNode + | ShapeWithTextNode + | SliceNode + | SlotNode + | StarNode + | StickyNode + | TableNode + | TableCellNode + | TextNode + | VectorNode + | WashiTapeNode + | WidgetNode + + export type BooleanOperationNode = { + /** + * The type of this node, represented by the string literal "BOOLEAN_OPERATION" + */ + type: 'BOOLEAN_OPERATION' + + /** + * A string enum indicating the type of boolean operation applied. + */ + booleanOperation: 'UNION' | 'INTERSECT' | 'SUBTRACT' | 'EXCLUDE' + } & IsLayerTrait & + HasBlendModeAndOpacityTrait & + HasChildrenTrait & + HasLayoutTrait & + HasGeometryTrait & + HasExportSettingsTrait & + HasEffectsTrait & + HasMaskTrait & + TransitionSourceTrait + + export type SectionNode = { + /** + * The type of this node, represented by the string literal "SECTION" + */ + type: 'SECTION' + + /** + * Whether the contents of the section are visible + */ + sectionContentsHidden: boolean + } & IsLayerTrait & + HasGeometryTrait & + HasChildrenTrait & + HasLayoutTrait & + DevStatusTrait + + export type FrameNode = { + /** + * The type of this node, represented by the string literal "FRAME" + */ + type: 'FRAME' + } & FrameTraits + + export type GroupNode = { + /** + * The type of this node, represented by the string literal "GROUP" + */ + type: 'GROUP' + } & FrameTraits + + export type ComponentNode = { + /** + * The type of this node, represented by the string literal "COMPONENT" + */ + type: 'COMPONENT' + } & FrameTraits & + ComponentPropertiesTrait + + export type ComponentSetNode = { + /** + * The type of this node, represented by the string literal "COMPONENT_SET" + */ + type: 'COMPONENT_SET' + } & FrameTraits & + ComponentPropertiesTrait + + export type VectorNode = { + /** + * The type of this node, represented by the string literal "VECTOR" + */ + type: 'VECTOR' + } & CornerRadiusShapeTraits & + AnnotationsTrait + + export type StarNode = { + /** + * The type of this node, represented by the string literal "STAR" + */ + type: 'STAR' + } & CornerRadiusShapeTraits & + AnnotationsTrait + + export type LineNode = { + /** + * The type of this node, represented by the string literal "LINE" + */ + type: 'LINE' + } & DefaultShapeTraits & + AnnotationsTrait + + export type EllipseNode = { + /** + * The type of this node, represented by the string literal "ELLIPSE" + */ + type: 'ELLIPSE' + + arcData: ArcData + } & DefaultShapeTraits & + AnnotationsTrait + + export type RegularPolygonNode = { + /** + * The type of this node, represented by the string literal "REGULAR_POLYGON" + */ + type: 'REGULAR_POLYGON' + } & CornerRadiusShapeTraits & + AnnotationsTrait + + export type RectangleNode = { + /** + * The type of this node, represented by the string literal "RECTANGLE" + */ + type: 'RECTANGLE' + } & RectangularShapeTraits + + export type TextNode = { + /** + * The type of this node, represented by the string literal "TEXT" + */ + type: 'TEXT' + } & DefaultShapeTraits & + TypePropertiesTrait & + AnnotationsTrait + + export type TableNode = { + /** + * The type of this node, represented by the string literal "TABLE" + */ + type: 'TABLE' + } & IsLayerTrait & + HasChildrenTrait & + HasLayoutTrait & + MinimalStrokesTrait & + HasEffectsTrait & + HasBlendModeAndOpacityTrait & + HasExportSettingsTrait + + export type TableCellNode = { + /** + * The type of this node, represented by the string literal "TABLE_CELL" + */ + type: 'TABLE_CELL' + } & IsLayerTrait & + MinimalFillsTrait & + HasLayoutTrait & + HasTextSublayerTrait + + export type SliceNode = { + /** + * The type of this node, represented by the string literal "SLICE" + */ + type: 'SLICE' + } & IsLayerTrait + + export type SlotNode = { + /** + * The type of this node, represented by the string literal "SLOT" + */ + type: 'SLOT' + } & FrameTraits + + export type InstanceNode = { + /** + * The type of this node, represented by the string literal "INSTANCE" + */ + type: 'INSTANCE' + + /** + * ID of component that this instance came from. + */ + componentId: string + + /** + * If true, this node has been marked as exposed to its containing component or component set. + */ + isExposedInstance?: boolean + + /** + * IDs of instances that have been exposed to this node's level. + */ + exposedInstances?: string[] + + /** + * A mapping of name to `ComponentProperty` for all component properties on this instance. Each + * property has a type, value, and other optional values. + */ + componentProperties?: { [key: string]: ComponentProperty } + + /** + * An array of all of the fields directly overridden on this instance. Inherited overrides are not + * included. + */ + overrides: Overrides[] + } & FrameTraits + + export type EmbedNode = { + /** + * The type of this node, represented by the string literal "EMBED" + */ + type: 'EMBED' + } & IsLayerTrait & + HasExportSettingsTrait + + export type LinkUnfurlNode = { + /** + * The type of this node, represented by the string literal "LINK_UNFURL" + */ + type: 'LINK_UNFURL' + } & IsLayerTrait & + HasExportSettingsTrait + + export type StickyNode = { + /** + * The type of this node, represented by the string literal "STICKY" + */ + type: 'STICKY' + + /** + * If true, author name is visible. + */ + authorVisible?: boolean + } & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + MinimalFillsTrait & + HasMaskTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait + + export type ShapeWithTextNode = { + /** + * The type of this node, represented by the string literal "SHAPE_WITH_TEXT" + */ + type: 'SHAPE_WITH_TEXT' + + /** + * Geometric shape type. Most shape types have the same name as their tooltip but there are a few + * exceptions. ENG_DATABASE: Cylinder, ENG_QUEUE: Horizontal cylinder, ENG_FILE: File, ENG_FOLDER: + * Folder. + */ + shapeType: ShapeType + } & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + MinimalFillsTrait & + HasMaskTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait & + CornerTrait & + MinimalStrokesTrait + + export type ConnectorNode = { + /** + * The type of this node, represented by the string literal "CONNECTOR" + */ + type: 'CONNECTOR' + + /** + * The starting point of the connector. + */ + connectorStart: ConnectorEndpoint + + /** + * The ending point of the connector. + */ + connectorEnd: ConnectorEndpoint + + /** + * A string enum describing the end cap of the start of the connector. + */ + connectorStartStrokeCap: + | 'NONE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + + /** + * A string enum describing the end cap of the end of the connector. + */ + connectorEndStrokeCap: + | 'NONE' + | 'LINE_ARROW' + | 'TRIANGLE_ARROW' + | 'DIAMOND_FILLED' + | 'CIRCLE_FILLED' + | 'TRIANGLE_FILLED' + + /** + * Connector line type. + */ + connectorLineType: ConnectorLineType + + /** + * Connector text background. + */ + textBackground?: ConnectorTextBackground + } & IsLayerTrait & + HasLayoutTrait & + HasBlendModeAndOpacityTrait & + HasEffectsTrait & + HasExportSettingsTrait & + HasTextSublayerTrait & + MinimalStrokesTrait + + export type WashiTapeNode = { + /** + * The type of this node, represented by the string literal "WASHI_TAPE" + */ + type: 'WASHI_TAPE' + } & DefaultShapeTraits + + export type WidgetNode = { + /** + * The type of this node, represented by the string literal "WIDGET" + */ + type: 'WIDGET' + } & IsLayerTrait & + HasExportSettingsTrait & + HasChildrenTrait + + /** + * An RGB color + */ + export type RGB = { + /** + * Red channel value, between 0 and 1. + */ + r: number + + /** + * Green channel value, between 0 and 1. + */ + g: number + + /** + * Blue channel value, between 0 and 1. + */ + b: number + } + + /** + * An RGBA color + */ + export type RGBA = { + /** + * Red channel value, between 0 and 1. + */ + r: number + + /** + * Green channel value, between 0 and 1. + */ + g: number + + /** + * Blue channel value, between 0 and 1. + */ + b: number + + /** + * Alpha channel value, between 0 and 1. + */ + a: number + } + + /** + * A flow starting point used when launching a prototype to enter Presentation view. + */ + export type FlowStartingPoint = { + /** + * Unique identifier specifying the frame. + */ + nodeId: string + + /** + * Name of flow. + */ + name: string + } + + /** + * A width and a height. + */ + export type Size = { + /** + * The width of a size. + */ + width: number + + /** + * The height of a size. + */ + height: number + } + + /** + * The device used to view a prototype. + */ + export type PrototypeDevice = { + type: 'NONE' | 'PRESET' | 'CUSTOM' | 'PRESENTATION' + + size?: Size + + presetIdentifier?: string + + rotation: 'NONE' | 'CCW_90' + } + + /** + * Sizing constraint for exports. + */ + export type Constraint = { + /** + * Type of constraint to apply: + * + * - `SCALE`: Scale by `value`. + * - `WIDTH`: Scale proportionally and set width to `value`. + * - `HEIGHT`: Scale proportionally and set height to `value`. + */ + type: 'SCALE' | 'WIDTH' | 'HEIGHT' + + /** + * See type property for effect of this field. + */ + value: number + } + + /** + * An export setting. + */ + export type ExportSetting = { + suffix: string + + format: 'JPG' | 'PNG' | 'SVG' | 'PDF' + + constraint: Constraint + } + + /** + * This type is a string enum with the following possible values + * + * Normal blends: + * + * - `PASS_THROUGH` (only applicable to objects with children) + * - `NORMAL` + * + * Darken: + * + * - `DARKEN` + * - `MULTIPLY` + * - `LINEAR_BURN` + * - `COLOR_BURN` + * + * Lighten: + * + * - `LIGHTEN` + * - `SCREEN` + * - `LINEAR_DODGE` + * - `COLOR_DODGE` + * + * Contrast: + * + * - `OVERLAY` + * - `SOFT_LIGHT` + * - `HARD_LIGHT` + * + * Inversion: + * + * - `DIFFERENCE` + * - `EXCLUSION` + * + * Component: + * + * - `HUE` + * - `SATURATION` + * - `COLOR` + * - `LUMINOSITY` + */ + export type BlendMode = + | 'PASS_THROUGH' + | 'NORMAL' + | 'DARKEN' + | 'MULTIPLY' + | 'LINEAR_BURN' + | 'COLOR_BURN' + | 'LIGHTEN' + | 'SCREEN' + | 'LINEAR_DODGE' + | 'COLOR_DODGE' + | 'OVERLAY' + | 'SOFT_LIGHT' + | 'HARD_LIGHT' + | 'DIFFERENCE' + | 'EXCLUSION' + | 'HUE' + | 'SATURATION' + | 'COLOR' + | 'LUMINOSITY' + + /** + * A 2d vector. + */ + export type Vector = { + /** + * X coordinate of the vector. + */ + x: number + + /** + * Y coordinate of the vector. + */ + y: number + } + + /** + * A single color stop with its position along the gradient axis, color, and bound variables if any + */ + export type ColorStop = { + /** + * Value between 0 and 1 representing position along gradient axis. + */ + position: number + + /** + * Color attached to corresponding position. + */ + color: RGBA + + /** + * The variables bound to a particular gradient stop + */ + boundVariables?: { color?: VariableAlias } + } + + /** + * A transformation matrix is standard way in computer graphics to represent translation and + * rotation. These are the top two rows of a 3x3 matrix. The bottom row of the matrix is assumed to + * be [0, 0, 1]. This is known as an affine transform and is enough to represent translation, + * rotation, and skew. + * + * The identity transform is [[1, 0, 0], [0, 1, 0]]. + * + * A translation matrix will typically look like: + * + * ;[ + * [1, 0, tx], + * [0, 1, ty], + * ] + * + * And a rotation matrix will typically look like: + * + * ;[ + * [cos(angle), sin(angle), 0], + * [-sin(angle), cos(angle), 0], + * ] + * + * Another way to think about this transform is as three vectors: + * + * - The x axis (t[0][0], t[1][0]) + * - The y axis (t[0][1], t[1][1]) + * - The translation offset (t[0][2], t[1][2]) + * + * The most common usage of the Transform matrix is the `relativeTransform property`. This + * particular usage of the matrix has a few additional restrictions. The translation offset can take + * on any value but we do enforce that the axis vectors are unit vectors (i.e. have length 1). The + * axes are not required to be at 90° angles to each other. + */ + export type Transform = number[][] + + /** + * Image filters to apply to the node. + */ + export type ImageFilters = { + exposure?: number + + contrast?: number + + saturation?: number + + temperature?: number + + tint?: number + + highlights?: number + + shadows?: number + } + + export type BasePaint = { + /** + * Is the paint enabled? + */ + visible?: boolean + + /** + * Overall opacity of paint (colors within the paint can also have opacity values which would blend + * with this) + */ + opacity?: number + + /** + * How this node blends with nodes behind it in the scene + */ + blendMode: BlendMode + } + + export type SolidPaint = { + /** + * The string literal "SOLID" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: 'SOLID' + + /** + * Solid color of the paint + */ + color: RGBA + + /** + * The variables bound to a particular field on this paint + */ + boundVariables?: { color?: VariableAlias } + } & BasePaint + + export type GradientPaint = { + /** + * The string literal representing the paint's type. Always check the `type` before reading other + * properties. + */ + type: 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' + + /** + * This field contains three vectors, each of which are a position in normalized object space + * (normalized object space is if the top left corner of the bounding box of the object is (0, 0) + * and the bottom right is (1,1)). The first position corresponds to the start of the gradient + * (value 0 for the purposes of calculating gradient stops), the second position is the end of the + * gradient (value 1), and the third handle position determines the width of the gradient. + */ + gradientHandlePositions: Vector[] + + /** + * Positions of key points along the gradient axis with the colors anchored there. Colors along the + * gradient are interpolated smoothly between neighboring gradient stops. + */ + gradientStops: ColorStop[] + } & BasePaint + + export type ImagePaint = { + /** + * The string literal "IMAGE" representing the paint's type. Always check the `type` before reading + * other properties. + */ + type: 'IMAGE' + + /** + * Image scaling mode. + */ + scaleMode: 'FILL' | 'FIT' | 'TILE' | 'STRETCH' + + /** + * A reference to an image embedded in this node. To download the image using this reference, use + * the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + imageRef: string + + /** + * Affine transform applied to the image, only present if `scaleMode` is `STRETCH` + */ + imageTransform?: Transform + + /** + * Amount image is scaled by in tiling, only present if scaleMode is `TILE`. + */ + scalingFactor?: number + + /** + * Defines what image filters have been applied to this paint, if any. If this property is not + * defined, no filters have been applied. + */ + filters?: ImageFilters + + /** + * Image rotation, in degrees. + */ + rotation?: number + + /** + * A reference to an animated GIF embedded in this node. To download the image using this reference, + * use the `GET file images` endpoint to retrieve the mapping from image references to image URLs. + */ + gifRef?: string + } & BasePaint + + export type Paint = SolidPaint | GradientPaint | ImagePaint + + /** + * Layout constraint relative to containing Frame + */ + export type LayoutConstraint = { + /** + * Vertical constraint (relative to containing frame) as an enum: + * + * - `TOP`: Node is laid out relative to top of the containing frame + * - `BOTTOM`: Node is laid out relative to bottom of the containing frame + * - `CENTER`: Node is vertically centered relative to containing frame + * - `TOP_BOTTOM`: Both top and bottom of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales vertically with containing frame + */ + vertical: 'TOP' | 'BOTTOM' | 'CENTER' | 'TOP_BOTTOM' | 'SCALE' + + /** + * Horizontal constraint (relative to containing frame) as an enum: + * + * - `LEFT`: Node is laid out relative to left of the containing frame + * - `RIGHT`: Node is laid out relative to right of the containing frame + * - `CENTER`: Node is horizontally centered relative to containing frame + * - `LEFT_RIGHT`: Both left and right of node are constrained relative to containing frame (node + * stretches with frame) + * - `SCALE`: Node scales horizontally with containing frame + */ + horizontal: 'LEFT' | 'RIGHT' | 'CENTER' | 'LEFT_RIGHT' | 'SCALE' + } + + /** + * A rectangle that expresses a bounding box in absolute coordinates. + */ + export type Rectangle = { + /** + * X coordinate of top left corner of the rectangle. + */ + x: number + + /** + * Y coordinate of top left corner of the rectangle. + */ + y: number + + /** + * Width of the rectangle. + */ + width: number + + /** + * Height of the rectangle. + */ + height: number + } + + /** + * Guides to align and place objects within a frames. + */ + export type LayoutGrid = { + /** + * Orientation of the grid as a string enum + * + * - `COLUMNS`: Vertical grid + * - `ROWS`: Horizontal grid + * - `GRID`: Square grid + */ + pattern: 'COLUMNS' | 'ROWS' | 'GRID' + + /** + * Width of column grid or height of row grid or square grid spacing. + */ + sectionSize: number + + /** + * Is the grid currently visible? + */ + visible: boolean + + /** + * Color of the grid + */ + color: RGBA + + /** + * Positioning of grid as a string enum + * + * - `MIN`: Grid starts at the left or top of the frame + * - `MAX`: Grid starts at the right or bottom of the frame + * - `STRETCH`: Grid is stretched to fit the frame + * - `CENTER`: Grid is center aligned + */ + alignment: 'MIN' | 'MAX' | 'STRETCH' | 'CENTER' + + /** + * Spacing in between columns and rows + */ + gutterSize: number + + /** + * Spacing before the first column or row + */ + offset: number + + /** + * Number of columns or rows + */ + count: number + + /** + * The variables bound to a particular field on this layout grid + */ + boundVariables?: { + gutterSize?: VariableAlias + + numSections?: VariableAlias + + sectionSize?: VariableAlias + + offset?: VariableAlias + } + } + + /** + * Base properties shared by all shadow effects + */ + export type BaseShadowEffect = { + /** + * The color of the shadow + */ + color: RGBA + + /** + * Blend mode of the shadow + */ + blendMode: BlendMode + + /** + * How far the shadow is projected in the x and y directions + */ + offset: Vector + + /** + * Radius of the blur effect (applies to shadows as well) + */ + radius: number + + /** + * The distance by which to expand (or contract) the shadow. + * + * For drop shadows, a positive `spread` value creates a shadow larger than the node, whereas a + * negative value creates a shadow smaller than the node. + * + * For inner shadows, a positive `spread` value contracts the shadow. Spread values are only + * accepted on rectangles and ellipses, or on frames, components, and instances with visible fill + * paints and `clipsContent` enabled. When left unspecified, the default value is 0. + */ + spread?: number + + /** + * Whether this shadow is visible. + */ + visible: boolean + + /** + * The variables bound to a particular field on this shadow effect + */ + boundVariables?: { + radius?: VariableAlias + + spread?: VariableAlias + + color?: VariableAlias + + offsetX?: VariableAlias + + offsetY?: VariableAlias + } + } + + export type DropShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: 'DROP_SHADOW' + + /** + * Whether to show the shadow behind translucent or transparent pixels + */ + showShadowBehindNode: boolean + } & BaseShadowEffect + + export type InnerShadowEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type?: 'INNER_SHADOW' + } & BaseShadowEffect + + /** + * A blur effect + */ + export type BlurEffect = { + /** + * A string literal representing the effect's type. Always check the type before reading other + * properties. + */ + type: 'LAYER_BLUR' | 'BACKGROUND_BLUR' + + /** + * Whether this blur is active. + */ + visible: boolean + + /** + * Radius of the blur effect + */ + radius: number + + /** + * The variables bound to a particular field on this blur effect + */ + boundVariables?: { radius?: VariableAlias } + } + + export type Effect = DropShadowEffect | InnerShadowEffect | BlurEffect + + /** + * A set of properties that can be applied to nodes and published. Styles for a property can be + * created in the corresponding property's panel while editing a file. + */ + export type Style = { + /** + * The key of the style + */ + key: string + + /** + * Name of the style + */ + name: string + + /** + * Description of the style + */ + description: string + + /** + * Whether this style is a remote style that doesn't live in this file + */ + remote: boolean + + styleType: StyleType + } + + /** + * This type is a string enum with the following possible values: + * + * - `EASE_IN`: Ease in with an animation curve similar to CSS ease-in. + * - `EASE_OUT`: Ease out with an animation curve similar to CSS ease-out. + * - `EASE_IN_AND_OUT`: Ease in and then out with an animation curve similar to CSS ease-in-out. + * - `LINEAR`: No easing, similar to CSS linear. + * - `EASE_IN_BACK`: Ease in with an animation curve that moves past the initial keyframe's value and + * then accelerates as it reaches the end. + * - `EASE_OUT_BACK`: Ease out with an animation curve that starts fast, then slows and goes past the + * ending keyframe's value. + * - `EASE_IN_AND_OUT_BACK`: Ease in and then out with an animation curve that overshoots the initial + * keyframe's value, then accelerates quickly before it slows and overshoots the ending keyframes + * value. + * - `CUSTOM_CUBIC_BEZIER`: User-defined cubic bezier curve. + * - `GENTLE`: Gentle animation similar to react-spring. + * - `QUICK`: Quick spring animation, great for toasts and notifications. + * - `BOUNCY`: Bouncy spring, for delightful animations like a heart bounce. + * - `SLOW`: Slow spring, useful as a steady, natural way to scale up fullscreen content. + * - `CUSTOM_SPRING`: User-defined spring animation. + */ + export type EasingType = + | 'EASE_IN' + | 'EASE_OUT' + | 'EASE_IN_AND_OUT' + | 'LINEAR' + | 'EASE_IN_BACK' + | 'EASE_OUT_BACK' + | 'EASE_IN_AND_OUT_BACK' + | 'CUSTOM_CUBIC_BEZIER' + | 'GENTLE' + | 'QUICK' + | 'BOUNCY' + | 'SLOW' + | 'CUSTOM_SPRING' + + /** + * Individual stroke weights + */ + export type StrokeWeights = { + /** + * The top stroke weight. + */ + top: number + + /** + * The right stroke weight. + */ + right: number + + /** + * The bottom stroke weight. + */ + bottom: number + + /** + * The left stroke weight. + */ + left: number + } + + /** + * Paint metadata to override default paints. + */ + export type PaintOverride = { + /** + * Paints applied to characters. + */ + fills?: Paint[] + + /** + * ID of style node, if any, that this inherits fill data from. + */ + inheritFillStyleId?: string + } + + /** + * Defines a single path + */ + export type Path = { + /** + * A series of path commands that encodes how to draw the path. + */ + path: string + + /** + * The winding rule for the path (same as in SVGs). This determines whether a given point in space + * is inside or outside the path. + */ + windingRule: 'NONZERO' | 'EVENODD' + + /** + * If there is a per-region fill, this refers to an ID in the `fillOverrideTable`. + */ + overrideID?: number + } + + /** + * Information about the arc properties of an ellipse. 0° is the x axis and increasing angles rotate + * clockwise. + */ + export type ArcData = { + /** + * Start of the sweep in radians. + */ + startingAngle: number + + /** + * End of the sweep in radians. + */ + endingAngle: number + + /** + * Inner radius value between 0 and 1 + */ + innerRadius: number + } + + /** + * A link to either a URL or another frame (node) in the document. + */ + export type Hyperlink = { + /** + * The type of hyperlink. Can be either `URL` or `NODE`. + */ + type: 'URL' | 'NODE' + + /** + * The URL that the hyperlink points to, if `type` is `URL`. + */ + url?: string + + /** + * The ID of the node that the hyperlink points to, if `type` is `NODE`. + */ + nodeID?: string + } + + /** + * Metadata for character formatting. + */ + export type TypeStyle = { + /** + * Font family of text (standard name). + */ + fontFamily?: string + + /** + * PostScript font name. + */ + fontPostScriptName?: string | null + + /** + * Describes visual weight or emphasis, such as Bold or Italic. + */ + fontStyle?: string + + /** + * Space between paragraphs in px, 0 if not present. + */ + paragraphSpacing?: number + + /** + * Paragraph indentation in px, 0 if not present. + */ + paragraphIndent?: number + + /** + * Space between list items in px, 0 if not present. + */ + listSpacing?: number + + /** + * Whether or not text is italicized. + */ + italic?: boolean + + /** + * Numeric font weight. + */ + fontWeight?: number + + /** + * Font size in px. + */ + fontSize?: number + + /** + * Text casing applied to the node, default is the original casing. + */ + textCase?: 'UPPER' | 'LOWER' | 'TITLE' | 'SMALL_CAPS' | 'SMALL_CAPS_FORCED' + + /** + * Text decoration applied to the node, default is none. + */ + textDecoration?: 'NONE' | 'STRIKETHROUGH' | 'UNDERLINE' + + /** + * Dimensions along which text will auto resize, default is that the text does not auto-resize. + * TRUNCATE means that the text will be shortened and trailing text will be replaced with "…" if the + * text contents is larger than the bounds. `TRUNCATE` as a return value is deprecated and will be + * removed in a future version. Read from `textTruncation` instead. + */ + textAutoResize?: 'NONE' | 'WIDTH_AND_HEIGHT' | 'HEIGHT' | 'TRUNCATE' + + /** + * Whether this text node will truncate with an ellipsis when the text contents is larger than the + * text node. + */ + textTruncation?: 'DISABLED' | 'ENDING' + + /** + * When `textTruncation: "ENDING"` is set, `maxLines` determines how many lines a text node can grow + * to before it truncates. + */ + maxLines?: number + + /** + * Horizontal text alignment as string enum. + */ + textAlignHorizontal?: 'LEFT' | 'RIGHT' | 'CENTER' | 'JUSTIFIED' + + /** + * Vertical text alignment as string enum. + */ + textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM' + + /** + * Space between characters in px. + */ + letterSpacing?: number + + /** + * An array of fill paints applied to the characters. + */ + fills?: Paint[] + + /** + * Link to a URL or frame. + */ + hyperlink?: Hyperlink + + /** + * A map of OpenType feature flags to 1 or 0, 1 if it is enabled and 0 if it is disabled. Note that + * some flags aren't reflected here. For example, SMCP (small caps) is still represented by the + * `textCase` field. + */ + opentypeFlags?: { [key: string]: number } + + /** + * Line height in px. + */ + lineHeightPx?: number + + /** + * Line height as a percentage of normal line height. This is deprecated; in a future version of the + * API only lineHeightPx and lineHeightPercentFontSize will be returned. + */ + lineHeightPercent?: number + + /** + * Line height as a percentage of the font size. Only returned when `lineHeightPercent` (deprecated) + * is not 100. + */ + lineHeightPercentFontSize?: number + + /** + * The unit of the line height value specified by the user. + */ + lineHeightUnit?: 'PIXELS' | 'FONT_SIZE_%' | 'INTRINSIC_%' + + /** + * The variables bound to a particular field on this style + */ + boundVariables?: { + fontFamily?: VariableAlias + + fontSize?: VariableAlias + + fontStyle?: VariableAlias + + fontWeight?: VariableAlias + + letterSpacing?: VariableAlias + + lineHeight?: VariableAlias + + paragraphSpacing?: VariableAlias + + paragraphIndent?: VariableAlias + } + + /** + * Whether or not this style has overrides over a text style. The possible fields to override are + * semanticWeight, semanticItalic, hyperlink, and textDecoration. If this is true, then those fields + * are overrides if present. + */ + isOverrideOverTextStyle?: boolean + + /** + * Indicates how the font weight was overridden when there is a text style override. + */ + semanticWeight?: 'BOLD' | 'NORMAL' + + /** + * Indicates how the font style was overridden when there is a text style override. + */ + semanticItalic?: 'ITALIC' | 'NORMAL' + } + + /** + * Component property type. + */ + export type ComponentPropertyType = + | 'BOOLEAN' + | 'INSTANCE_SWAP' + | 'TEXT' + | 'VARIANT' + | 'SLOT' + + /** + * Instance swap preferred value. + */ + export type InstanceSwapPreferredValue = { + /** + * Type of node for this preferred value. + */ + type: 'COMPONENT' | 'COMPONENT_SET' + + /** + * Key of this component or component set. + */ + key: string + } + + /** + * A property of a component. + */ + export type ComponentPropertyDefinition = { + /** + * Type of this component property. + */ + type: ComponentPropertyType + + /** + * Initial value of this property for instances. + */ + defaultValue: boolean | string | string[] + + /** + * All possible values for this property. Only exists on VARIANT properties. + */ + variantOptions?: string[] + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[] + } + + /** + * A property of a component. + */ + export type ComponentProperty = { + /** + * Type of this component property. + */ + type: ComponentPropertyType + + /** + * Value of the property for this component instance. + */ + value: boolean | string | string[] + + /** + * Preferred values for this property. Only applicable if type is `INSTANCE_SWAP`. + */ + preferredValues?: InstanceSwapPreferredValue[] + + /** + * The variables bound to a particular field on this component property + */ + boundVariables?: { value?: VariableAlias } + } + + /** + * Fields directly overridden on an instance. Inherited overrides are not included. + */ + export type Overrides = { + /** + * A unique ID for a node. + */ + id: string + + /** + * An array of properties. + */ + overriddenFields: string[] + } + + /** + * Geometric shape type. + */ + export type ShapeType = + | 'SQUARE' + | 'ELLIPSE' + | 'ROUNDED_RECTANGLE' + | 'DIAMOND' + | 'TRIANGLE_UP' + | 'TRIANGLE_DOWN' + | 'PARALLELOGRAM_RIGHT' + | 'PARALLELOGRAM_LEFT' + | 'ENG_DATABASE' + | 'ENG_QUEUE' + | 'ENG_FILE' + | 'ENG_FOLDER' + | 'TRAPEZOID' + | 'PREDEFINED_PROCESS' + | 'SHIELD' + | 'DOCUMENT_SINGLE' + | 'DOCUMENT_MULTIPLE' + | 'MANUAL_INPUT' + | 'HEXAGON' + | 'CHEVRON' + | 'PENTAGON' + | 'OCTAGON' + | 'STAR' + | 'PLUS' + | 'ARROW_LEFT' + | 'ARROW_RIGHT' + | 'SUMMING_JUNCTION' + | 'OR' + | 'SPEECH_BUBBLE' + | 'INTERNAL_STORAGE' + + /** + * Stores canvas location for a connector start/end point. + */ + export type ConnectorEndpoint = + | { + /** + * Node ID that this endpoint attaches to. + */ + endpointNodeId?: string + + /** + * The position of the endpoint relative to the node. + */ + position?: Vector + } + | { + /** + * Node ID that this endpoint attaches to. + */ + endpointNodeId?: string + + /** + * The magnet type is a string enum. + */ + magnet?: 'AUTO' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CENTER' + } + + /** + * Connector line type. + */ + export type ConnectorLineType = 'STRAIGHT' | 'ELBOWED' + + export type ConnectorTextBackground = CornerTrait & MinimalFillsTrait + + /** + * A description of a main component. Helps you identify which component instances are attached to. + */ + export type Component = { + /** + * The key of the component + */ + key: string + + /** + * Name of the component + */ + name: string + + /** + * The description of the component as entered in the editor + */ + description: string + + /** + * The ID of the component set if the component belongs to one + */ + componentSetId?: string + + /** + * An array of documentation links attached to this component + */ + documentationLinks: DocumentationLink[] + + /** + * Whether this component is a remote component that doesn't live in this file + */ + remote: boolean + } + + /** + * A description of a component set, which is a node containing a set of variants of a component. + */ + export type ComponentSet = { + /** + * The key of the component set + */ + key: string + + /** + * Name of the component set + */ + name: string + + /** + * The description of the component set as entered in the editor + */ + description: string + + /** + * An array of documentation links attached to this component set + */ + documentationLinks?: DocumentationLink[] + + /** + * Whether this component set is a remote component set that doesn't live in this file + */ + remote?: boolean + } + + /** + * Represents a link to documentation for a component or component set. + */ + export type DocumentationLink = { + /** + * Should be a valid URI (e.g. https://www.figma.com). + */ + uri: string + } + + /** + * Contains a variable alias + */ + export type VariableAlias = { + type: 'VARIABLE_ALIAS' + + /** + * The id of the variable that the current variable is aliased to. This variable can be a local or + * remote variable, and both can be retrieved via the GET /v1/files/:file_key/variables/local + * endpoint. + */ + id: string + } + + /** + * An interaction in the Figma viewer, containing a trigger and one or more actions. + */ + export type Interaction = { + /** + * The user event that initiates the interaction. + */ + trigger: Trigger | null + + /** + * The actions that are performed when the trigger is activated. + */ + actions?: Action[] + } + + /** + * The `"ON_HOVER"` and `"ON_PRESS"` trigger types revert the navigation when the trigger is + * finished (the result is temporary). `"MOUSE_ENTER"`, `"MOUSE_LEAVE"`, `"MOUSE_UP"` and + * `"MOUSE_DOWN"` are permanent, one-way navigation. The `delay` parameter requires the trigger to + * be held for a certain duration of time before the action occurs. Both `timeout` and `delay` + * values are in milliseconds. The `"ON_MEDIA_HIT"` and `"ON_MEDIA_END"` trigger types can only + * trigger from a video. They fire when a video reaches a certain time or ends. The `timestamp` + * value is in seconds. + */ + export type Trigger = + | { type: 'ON_CLICK' | 'ON_HOVER' | 'ON_PRESS' | 'ON_DRAG' } + | AfterTimeoutTrigger + | { + type: 'MOUSE_ENTER' | 'MOUSE_LEAVE' | 'MOUSE_UP' | 'MOUSE_DOWN' + + delay: number + + /** + * Whether this is a [deprecated + * version](https://help.figma.com/hc/en-us/articles/360040035834-Prototype-triggers#h_01HHN04REHJNP168R26P1CMP0A) + * of the trigger that was left unchanged for backwards compatibility. If not present, the trigger + * is the latest version. + */ + deprecatedVersion?: boolean + } + | OnKeyDownTrigger + | OnMediaHitTrigger + | { type: 'ON_MEDIA_END' } + + export type AfterTimeoutTrigger = { + type: 'AFTER_TIMEOUT' + + timeout: number + } + + export type OnKeyDownTrigger = { + type: 'ON_KEY_DOWN' + + device: 'KEYBOARD' | 'XBOX_ONE' | 'PS4' | 'SWITCH_PRO' | 'UNKNOWN_CONTROLLER' + + keyCodes: number[] + } + + export type OnMediaHitTrigger = { + type: 'ON_MEDIA_HIT' + + mediaHitTime: number + } + + /** + * An action that is performed when a trigger is activated. + */ + export type Action = + | { type: 'BACK' | 'CLOSE' } + | OpenURLAction + | UpdateMediaRuntimeAction + | SetVariableAction + | SetVariableModeAction + | ConditionalAction + | NodeAction + + /** + * An action that opens a URL. + */ + export type OpenURLAction = { + type: 'URL' + + url: string + } + + /** + * An action that affects a video node in the Figma viewer. For example, to play, pause, or skip. + */ + export type UpdateMediaRuntimeAction = + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId: string | null + + mediaAction: 'PLAY' | 'PAUSE' | 'TOGGLE_PLAY_PAUSE' | 'MUTE' | 'UNMUTE' | 'TOGGLE_MUTE_UNMUTE' + } + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId?: string | null + + mediaAction: 'SKIP_FORWARD' | 'SKIP_BACKWARD' + + amountToSkip: number + } + | { + type: 'UPDATE_MEDIA_RUNTIME' + + destinationId?: string | null + + mediaAction: 'SKIP_TO' + + newTimestamp: number + } + + /** + * An action that navigates to a specific node in the Figma viewer. + */ + export type NodeAction = { + type: 'NODE' + + destinationId: string | null + + navigation: Navigation + + transition: Transition | null + + /** + * Whether the scroll offsets of any scrollable elements in the current screen or overlay are + * preserved when navigating to the destination. This is applicable only if the layout of both the + * current frame and its destination are the same. + */ + preserveScrollPosition?: boolean + + /** + * Applicable only when `navigation` is `"OVERLAY"` and the destination is a frame with + * `overlayPosition` equal to `"MANUAL"`. This value represents the offset by which the overlay is + * opened relative to this node. + */ + overlayRelativePosition?: Vector + + /** + * When true, all videos within the destination frame will reset their memorized playback position + * to 00:00 before starting to play. + */ + resetVideoPosition?: boolean + + /** + * Whether the scroll offsets of any scrollable elements in the current screen or overlay reset when + * navigating to the destination. This is applicable only if the layout of both the current frame + * and its destination are the same. + */ + resetScrollPosition?: boolean + + /** + * Whether the state of any interactive components in the current screen or overlay reset when + * navigating to the destination. This is applicable if there are interactive components in the + * destination frame. + */ + resetInteractiveComponents?: boolean + } + + /** + * The method of navigation. The possible values are: + * + * - `"NAVIGATE"`: Replaces the current screen with the destination, also closing all overlays. + * - `"OVERLAY"`: Opens the destination as an overlay on the current screen. + * - `"SWAP"`: On an overlay, replaces the current (topmost) overlay with the destination. On a + * top-level frame, behaves the same as `"NAVIGATE"` except that no entry is added to the + * navigation history. + * - `"SCROLL_TO"`: Scrolls to the destination on the current screen. + * - `"CHANGE_TO"`: Changes the closest ancestor instance of source node to the specified variant. + */ + export type Navigation = 'NAVIGATE' | 'SWAP' | 'OVERLAY' | 'SCROLL_TO' | 'CHANGE_TO' + + export type Transition = SimpleTransition | DirectionalTransition + + /** + * Describes an animation used when navigating in a prototype. + */ + export type SimpleTransition = { + type: 'DISSOLVE' | 'SMART_ANIMATE' | 'SCROLL_ANIMATE' + + /** + * The duration of the transition in milliseconds. + */ + duration: number + + /** + * The easing curve of the transition. + */ + easing: Easing + } + + /** + * Describes an animation used when navigating in a prototype. + */ + export type DirectionalTransition = { + type: 'MOVE_IN' | 'MOVE_OUT' | 'PUSH' | 'SLIDE_IN' | 'SLIDE_OUT' + + direction: 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM' + + /** + * The duration of the transition in milliseconds. + */ + duration: number + + /** + * The easing curve of the transition. + */ + easing: Easing + + /** + * When the transition `type` is `"SMART_ANIMATE"` or when `matchLayers` is `true`, then the + * transition will be performed using smart animate, which attempts to match corresponding layers an + * interpolate other properties during the animation. + */ + matchLayers?: boolean + } + + /** + * Describes an easing curve. + */ + export type Easing = { + /** + * The type of easing curve. + */ + type: EasingType + + /** + * A cubic bezier curve that defines the easing. + */ + easingFunctionCubicBezier?: { + /** + * The x component of the first control point. + */ + x1: number + + /** + * The y component of the first control point. + */ + y1: number + + /** + * The x component of the second control point. + */ + x2: number + + /** + * The y component of the second control point. + */ + y2: number + } + + /** + * A spring function that defines the easing. + */ + easingFunctionSpring?: { + mass: number + + stiffness: number + + damping: number + } + } + + /** + * Sets a variable to a specific value. + */ + export type SetVariableAction = { + type: 'SET_VARIABLE' + + variableId: string | null + + variableValue?: VariableData + } + + /** + * Sets a variable to a specific mode. + */ + export type SetVariableModeAction = { + type: 'SET_VARIABLE_MODE' + + variableCollectionId?: string | null + + variableModeId?: string | null + } + + /** + * Checks if a condition is met before performing certain actions by using an if/else conditional + * statement. + */ + export type ConditionalAction = { + type: 'CONDITIONAL' + + conditionalBlocks: ConditionalBlock[] + } + + /** + * A value to set a variable to during prototyping. + */ + export type VariableData = { + type?: VariableDataType + + resolvedType?: VariableResolvedDataType + + value?: boolean | number | string | RGB | RGBA | VariableAlias | Expression + } + + /** + * Defines the types of data a VariableData object can hold + */ + export type VariableDataType = + | 'BOOLEAN' + | 'FLOAT' + | 'STRING' + | 'COLOR' + | 'VARIABLE_ALIAS' + | 'EXPRESSION' + + /** + * Defines the types of data a VariableData object can eventually equal + */ + export type VariableResolvedDataType = 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * Defines the [Expression](https://help.figma.com/hc/en-us/articles/15253194385943) object, which + * contains a list of `VariableData` objects strung together by operators (`ExpressionFunction`). + */ + export type Expression = { + expressionFunction: ExpressionFunction + + expressionArguments: VariableData[] + } + + /** + * Defines the list of operators available to use in an Expression. + */ + export type ExpressionFunction = + | 'ADDITION' + | 'SUBTRACTION' + | 'MULTIPLICATION' + | 'DIVISION' + | 'EQUALS' + | 'NOT_EQUAL' + | 'LESS_THAN' + | 'LESS_THAN_OR_EQUAL' + | 'GREATER_THAN' + | 'GREATER_THAN_OR_EQUAL' + | 'AND' + | 'OR' + | 'VAR_MODE_LOOKUP' + | 'NEGATE' + | 'NOT' + + /** + * Either the if or else conditional blocks. The if block contains a condition to check. If that + * condition is met then it will run those list of actions, else it will run the actions in the else + * block. + */ + export type ConditionalBlock = { + condition?: VariableData + + actions: Action[] + } + + /** + * A pinned distance between two nodes in Dev Mode + */ + export type Measurement = { + id: string + + start: MeasurementStartEnd + + end: MeasurementStartEnd + + offset: MeasurementOffsetInner | MeasurementOffsetOuter + + /** + * When manually overridden, the displayed value of the measurement + */ + freeText?: string + } + + /** + * The node and side a measurement is pinned to + */ + export type MeasurementStartEnd = { + nodeId: string + + side: 'TOP' | 'RIGHT' | 'BOTTOM' | 'LEFT' + } + + /** + * Measurement offset relative to the inside of the start node + */ + export type MeasurementOffsetInner = { + type: 'INNER' + + relative: number + } + + /** + * Measurement offset relative to the outside of the start node + */ + export type MeasurementOffsetOuter = { + type: 'OUTER' + + fixed: number + } + + /** + * Position of a comment relative to the frame to which it is attached. + */ + export type FrameOffset = { + /** + * Unique id specifying the frame. + */ + node_id: string + + /** + * 2D vector offset within the frame from the top-left corner. + */ + node_offset: Vector + } + + /** + * Position of a region comment on the canvas. + */ + export type Region = { + /** + * X coordinate of the position. + */ + x: number + + /** + * Y coordinate of the position. + */ + y: number + + /** + * The height of the comment region. Must be greater than 0. + */ + region_height: number + + /** + * The width of the comment region. Must be greater than 0. + */ + region_width: number + + /** + * The corner of the comment region to pin to the node's corner as a string enum. + */ + comment_pin_corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + } + + /** + * Position of a region comment relative to the frame to which it is attached. + */ + export type FrameOffsetRegion = { + /** + * Unique id specifying the frame. + */ + node_id: string + + /** + * 2D vector offset within the frame from the top-left corner. + */ + node_offset: Vector + + /** + * The height of the comment region. Must be greater than 0. + */ + region_height: number + + /** + * The width of the comment region. Must be greater than 0. + */ + region_width: number + + /** + * The corner of the comment region to pin to the node's corner as a string enum. + */ + comment_pin_corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + } + + /** + * A comment or reply left by a user. + */ + export type Comment = { + /** + * Unique identifier for comment. + */ + id: string + + /** + * Positioning information of the comment. Includes information on the location of the comment pin, + * which is either the absolute coordinates on the canvas or a relative offset within a frame. If + * the comment is a region, it will also contain the region height, width, and position of the + * anchor in regards to the region. + */ + client_meta: Vector | FrameOffset | Region | FrameOffsetRegion + + /** + * The file in which the comment lives + */ + file_key: string + + /** + * If present, the id of the comment to which this is the reply + */ + parent_id?: string + + /** + * The user who left the comment + */ + user: User + + /** + * The UTC ISO 8601 time at which the comment was left + */ + created_at: string + + /** + * If set, the UTC ISO 8601 time the comment was resolved + */ + resolved_at?: string | null + + /** + * The content of the comment + */ + message: string + + /** + * Only set for top level comments. The number displayed with the comment in the UI + */ + order_id: string | null + + /** + * An array of reactions to the comment + */ + reactions: Reaction[] + } + + /** + * A reaction left by a user. + */ + export type Reaction = { + /** + * The user who left the reaction. + */ + user: User + + emoji: Emoji + + /** + * The UTC ISO 8601 time at which the reaction was left. + */ + created_at: string + } + + /** + * The emoji type of reaction as shortcode (e.g. `:heart:`, `:+1::skin-tone-2:`). The list of + * accepted emoji shortcodes can be found in [this + * file](https://raw.githubusercontent.com/missive/emoji-mart/main/packages/emoji-mart-data/sets/14/native.json) + * under the top-level emojis and aliases fields, with optional skin tone modifiers when + * applicable. + */ + export type Emoji = string + + /** + * A description of a user. + */ + export type User = { + /** + * Unique stable id of the user. + */ + id: string + + /** + * Name of the user. + */ + handle: string + + /** + * URL link to the user's profile image. + */ + img_url: string + } + + /** + * Data on the frame a component resides in. + */ + export type FrameInfo = { + /** + * The ID of the frame node within the file. + */ + nodeId?: string + + /** + * The name of the frame node. + */ + name?: string + + /** + * The background color of the frame node. + */ + backgroundColor?: string + + /** + * The ID of the page containing the frame node. + */ + pageId: string + + /** + * The name of the page containing the frame node. + */ + pageName: string + } + + /** + * An arrangement of published UI elements that can be instantiated across figma files. + */ + export type PublishedComponent = { + /** + * The unique identifier for the component. + */ + key: string + + /** + * The unique identifier of the Figma file that contains the component. + */ + file_key: string + + /** + * The unique identifier of the component node within the Figma file. + */ + node_id: string + + /** + * A URL to a thumbnail image of the component. + */ + thumbnail_url?: string + + /** + * The name of the component. + */ + name: string + + /** + * The description of the component as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the component was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the component was last updated. + */ + updated_at: string + + /** + * The user who last updated the component. + */ + user: User + + /** + * The containing frame of the component. + */ + containing_frame?: FrameInfo + } + + /** + * A node containing a set of variants of a component. + */ + export type PublishedComponentSet = { + /** + * The unique identifier for the component set. + */ + key: string + + /** + * The unique identifier of the Figma file that contains the component set. + */ + file_key: string + + /** + * The unique identifier of the component set node within the Figma file. + */ + node_id: string + + /** + * A URL to a thumbnail image of the component set. + */ + thumbnail_url?: string + + /** + * The name of the component set. + */ + name: string + + /** + * The description of the component set as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the component set was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the component set was last updated. + */ + updated_at: string + + /** + * The user who last updated the component set. + */ + user: User + + /** + * The containing frame of the component set. + */ + containing_frame?: FrameInfo + } + + /** + * The type of style + */ + export type StyleType = 'FILL' | 'TEXT' | 'EFFECT' | 'GRID' + + /** + * A set of published properties that can be applied to nodes. + */ + export type PublishedStyle = { + /** + * The unique identifier for the style + */ + key: string + + /** + * The unique identifier of the Figma file that contains the style. + */ + file_key: string + + /** + * ID of the style node within the figma file + */ + node_id: string + + style_type: StyleType + + /** + * A URL to a thumbnail image of the style. + */ + thumbnail_url?: string + + /** + * The name of the style. + */ + name: string + + /** + * The description of the style as entered by the publisher. + */ + description: string + + /** + * The UTC ISO 8601 time when the style was created. + */ + created_at: string + + /** + * The UTC ISO 8601 time when the style was last updated. + */ + updated_at: string + + /** + * The user who last updated the style. + */ + user: User + + /** + * A user specified order number by which the style can be sorted. + */ + sort_position: string + } + + /** + * A Project can be identified by both the Project name, and the Project ID. + */ + export type Project = { + /** + * The ID of the project. + */ + id: string + + /** + * The name of the project. + */ + name: string + } + + /** + * A version of a file + */ + export type Version = { + /** + * Unique identifier for version + */ + id: string + + /** + * The UTC ISO 8601 time at which the version was created + */ + created_at: string + + /** + * The label given to the version in the editor + */ + label: string | null + + /** + * The description of the version as entered in the editor + */ + description: string | null + + /** + * The user that created the version + */ + user: User + + /** + * A URL to a thumbnail image of the file version. + */ + thumbnail_url?: string + } + + /** + * A description of an HTTP webhook (from Figma back to your application) + */ + export type WebhookV2 = { + /** + * The ID of the webhook + */ + id: string + + /** + * The event this webhook triggers on + */ + event_type: WebhookV2Event + + /** + * The team id you are subscribed to for updates + */ + team_id: string + + /** + * The current status of the webhook + */ + status: WebhookV2Status + + /** + * The client ID of the OAuth application that registered this webhook, if any + */ + client_id: string | null + + /** + * The passcode that will be passed back to the webhook endpoint + */ + passcode: string + + /** + * The endpoint that will be hit when the webhook is triggered + */ + endpoint: string + + /** + * Optional user-provided description or name for the webhook. This is provided to help make + * maintaining a number of webhooks more convenient. Max length 140 characters. + */ + description: string | null + } + + /** + * An enum representing the possible events that a webhook can subscribe to + */ + export type WebhookV2Event = + | 'PING' + | 'FILE_UPDATE' + | 'FILE_VERSION_UPDATE' + | 'FILE_DELETE' + | 'LIBRARY_PUBLISH' + | 'FILE_COMMENT' + + /** + * An enum representing the possible statuses you can set a webhook to: + * + * - `ACTIVE`: The webhook is healthy and receive all events + * - `PAUSED`: The webhook is paused and will not receive any events + */ + export type WebhookV2Status = 'ACTIVE' | 'PAUSED' + + /** + * Information regarding the most recent interactions sent to a webhook endpoint + */ + export type WebhookV2Request = { + /** + * The ID of the webhook the requests were sent to + */ + webhook_id: string + + request_info: WebhookV2RequestInfo + + response_info: WebhookV2ResponseInfo + + /** + * Error message for this request. NULL if no error occurred + */ + error_msg: string | null + } + + /** + * Information regarding the request sent to a webhook endpoint + */ + export type WebhookV2RequestInfo = { + /** + * The ID of the webhook + */ + id: string + + /** + * The actual endpoint the request was sent to + */ + endpoint: string + + /** + * The contents of the request that was sent to the endpoint + */ + payload: object + + /** + * UTC ISO 8601 timestamp of when the request was sent + */ + sent_at: string + } + + /** + * Information regarding the reply sent back from a webhook endpoint + */ + export type WebhookV2ResponseInfo = object | null + + /** + * An object representing the library item information in the payload of the `LIBRARY_PUBLISH` event + */ + export type LibraryItemData = { + /** + * Unique identifier for the library item + */ + key: string + + /** + * Name of the library item + */ + name: string + } + + /** + * An object representing a fragment of a comment left by a user, used in the payload of the + * `FILE_COMMENT` event. Note only ONE of the fields below will be set + */ + export type CommentFragment = { + /** + * Comment text that is set if a fragment is text based + */ + text?: string + + /** + * User id that is set if a fragment refers to a user mention + */ + mention?: string + } + + export type WebhookBasePayload = { + /** + * The passcode specified when the webhook was created, should match what was initially provided + */ + passcode: string + + /** + * UTC ISO 8601 timestamp of when the event was triggered. + */ + timestamp: string + + /** + * The id of the webhook that caused the callback + */ + webhook_id: string + } + + export type WebhookPingPayload = WebhookBasePayload & { event_type: 'PING' } + + export type WebhookFileUpdatePayload = WebhookBasePayload & { + event_type: 'FILE_UPDATE' + + /** + * The key of the file that was updated + */ + file_key: string + + /** + * The name of the file that was updated + */ + file_name: string + } + + export type WebhookFileDeletePayload = WebhookBasePayload & { + event_type: 'FILE_DELETE' + + /** + * The key of the file that was deleted + */ + file_key: string + + /** + * The name of the file that was deleted + */ + file_name: string + + /** + * The user that deleted the file and triggered this event + */ + triggered_by: User + } + + export type WebhookFileVersionUpdatePayload = WebhookBasePayload & { + event_type: 'FILE_VERSION_UPDATE' + + /** + * UTC ISO 8601 timestamp of when the version was created + */ + created_at: string + + /** + * Description of the version in the version history + */ + description?: string + + /** + * The key of the file that was updated + */ + file_key: string + + /** + * The name of the file that was updated + */ + file_name: string + + /** + * The user that created the named version and triggered this event + */ + triggered_by: User + + /** + * ID of the published version + */ + version_id: string + } + + export type WebhookLibraryPublishPayload = WebhookBasePayload & { + event_type: 'LIBRARY_PUBLISH' + + /** + * Components that were created by the library publish + */ + created_components: LibraryItemData[] + + /** + * Styles that were created by the library publish + */ + created_styles: LibraryItemData[] + + /** + * Variables that were created by the library publish + */ + created_variables: LibraryItemData[] + + /** + * Components that were modified by the library publish + */ + modified_components: LibraryItemData[] + + /** + * Styles that were modified by the library publish + */ + modified_styles: LibraryItemData[] + + /** + * Variables that were modified by the library publish + */ + modified_variables: LibraryItemData[] + + /** + * Components that were deleted by the library publish + */ + deleted_components: LibraryItemData[] + + /** + * Styles that were deleted by the library publish + */ + deleted_styles: LibraryItemData[] + + /** + * Variables that were deleted by the library publish + */ + deleted_variables: LibraryItemData[] + + /** + * Description of the library publish + */ + description?: string + + /** + * The key of the file that was published + */ + file_key: string + + /** + * The name of the file that was published + */ + file_name: string + + /** + * The library item that was published + */ + library_item: LibraryItemData + + /** + * The user that published the library and triggered this event + */ + triggered_by: User + } + + export type WebhookFileCommentPayload = WebhookBasePayload & { + event_type: 'FILE_COMMENT' + + /** + * Contents of the comment itself + */ + comment: CommentFragment[] + + /** + * Unique identifier for comment + */ + comment_id: string + + /** + * The UTC ISO 8601 time at which the comment was left + */ + created_at: string + + /** + * The key of the file that was commented on + */ + file_key: string + + /** + * The name of the file that was commented on + */ + file_name: string + + /** + * Users that were mentioned in the comment + */ + mentions?: User[] + + /** + * The user that made the comment and triggered this event + */ + triggered_by: User + } + + /** + * A Figma user + */ + export type ActivityLogUserEntity = { + /** + * The type of entity. + */ + type: 'user' + + /** + * Unique stable id of the user. + */ + id: string + + /** + * Name of the user. + */ + name: string + + /** + * Email associated with the user's account. + */ + email: string + } + + /** + * A Figma Design or FigJam file + */ + export type ActivityLogFileEntity = { + /** + * The type of entity. + */ + type: 'file' + + /** + * Unique identifier of the file. + */ + key: string + + /** + * Name of the file. + */ + name: string + + /** + * Indicates if the object is a file on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' + + /** + * Access policy for users who have the link to the file. + */ + link_access: 'view' | 'edit' | 'org_view' | 'org_edit' | 'inherit' + + /** + * Access policy for users who have the link to the file's prototype. + */ + proto_link_access: 'view' | 'org_view' | 'inherit' + } + + /** + * A file branch that diverges from and can be merged back into the main file + */ + export type ActivityLogFileRepoEntity = { + /** + * The type of entity. + */ + type: 'file_repo' + + /** + * Unique identifier of the file branch. + */ + id: string + + /** + * Name of the file. + */ + name: string + + /** + * Key of the main file. + */ + main_file_key: string + } + + /** + * A project that a collection of Figma files are grouped under + */ + export type ActivityLogProjectEntity = { + /** + * The type of entity. + */ + type: 'project' + + /** + * Unique identifier of the project. + */ + id: string + + /** + * Name of the project. + */ + name: string + } + + /** + * A Figma team that contains multiple users and projects + */ + export type ActivityLogTeamEntity = { + /** + * The type of entity. + */ + type: 'team' + + /** + * Unique identifier of the team. + */ + id: string + + /** + * Name of the team. + */ + name: string + } + + /** + * Part of the organizational hierarchy of managing files and users within Figma, only available on + * the Enterprise Plan + */ + export type ActivityLogWorkspaceEntity = { + /** + * The type of entity. + */ + type: 'workspace' + + /** + * Unique identifier of the workspace. + */ + id: string + + /** + * Name of the workspace. + */ + name: string + } + + /** + * A Figma organization + */ + export type ActivityLogOrgEntity = { + /** + * The type of entity. + */ + type: 'org' + + /** + * Unique identifier of the organization. + */ + id: string + + /** + * Name of the organization. + */ + name: string + } + + /** + * A Figma plugin + */ + export type ActivityLogPluginEntity = { + /** + * The type of entity. + */ + type: 'plugin' + + /** + * Unique identifier of the plugin. + */ + id: string + + /** + * Name of the plugin. + */ + name: string + + /** + * Indicates if the object is a plugin is available on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' + } + + /** + * A Figma widget + */ + export type ActivityLogWidgetEntity = { + /** + * The type of entity. + */ + type: 'widget' + + /** + * Unique identifier of the widget. + */ + id: string + + /** + * Name of the widget. + */ + name: string + + /** + * Indicates if the object is a widget available on Figma Design or FigJam. + */ + editor_type: 'figma' | 'figjam' + } + + /** + * An event returned by the Activity Logs API. + */ + export type ActivityLog = { + /** + * The ID of the event. + */ + id: string + + /** + * The timestamp of the event in seconds since the Unix epoch. + */ + timestamp: number + + /** + * The user who performed the action. + */ + actor: object | null + + /** + * The task or activity the actor performed. + */ + action: { + /** + * The type of the action. + */ + type: string + + /** + * Metadata of the action. Each action type supports its own metadata attributes. + */ + details: object | null + } + + /** + * The resource the actor took the action on. It can be a user, file, project or other resource + * types. + */ + entity: + | ActivityLogUserEntity + | ActivityLogFileEntity + | ActivityLogFileRepoEntity + | ActivityLogProjectEntity + | ActivityLogTeamEntity + | ActivityLogWorkspaceEntity + | ActivityLogOrgEntity + | ActivityLogPluginEntity + | ActivityLogWidgetEntity + + /** + * Contextual information about the event. + */ + context: { + /** + * The third-party application that triggered the event, if applicable. + */ + client_name: string | null + + /** + * The IP address from of the client that sent the event request. + */ + ip_address: string + + /** + * If Figma's Support team triggered the event. This is either true or false. + */ + is_figma_support_team_action: boolean + + /** + * The id of the organization where the event took place. + */ + org_id: string + + /** + * The id of the team where the event took place -- if this took place in a specific team. + */ + team_id: string | null + } + } + + /** + * An object describing the user's payment status. + */ + export type PaymentStatus = { + /** + * The current payment status of the user on the resource, as a string enum: + * + * - `UNPAID`: user has not paid for the resource + * - `PAID`: user has an active purchase on the resource + * - `TRIAL`: user is in the trial period for a subscription resource + */ + type?: 'UNPAID' | 'PAID' | 'TRIAL' + } + + /** + * An object describing a user's payment information for a plugin, widget, or Community file. + */ + export type PaymentInformation = { + /** + * The ID of the user whose payment information was queried. Can be used to verify the validity of a + * response. + */ + user_id: string + + /** + * The ID of the plugin, widget, or Community file that was queried. Can be used to verify the + * validity of a response. + */ + resource_id: string + + /** + * The type of the resource. + */ + resource_type: 'PLUGIN' | 'WIDGET' | 'COMMUNITY_FILE' + + payment_status: PaymentStatus + + /** + * The UTC ISO 8601 timestamp indicating when the user purchased the resource. No value is given if + * the user has never purchased the resource. + * + * Note that a value will still be returned if the user had purchased the resource, but no longer + * has active access to it (e.g. purchase refunded, subscription ended). + */ + date_of_purchase?: string + } + + /** + * Scopes allow a variable to be shown or hidden in the variable picker for various fields. This + * declutters the Figma UI if you have a large number of variables. Variable scopes are currently + * supported on `FLOAT`, `STRING`, and `COLOR` variables. + * + * `ALL_SCOPES` is a special scope that means that the variable will be shown in the variable picker + * for all variable fields. If `ALL_SCOPES` is set, no additional scopes can be set. + * + * `ALL_FILLS` is a special scope that means that the variable will be shown in the variable picker + * for all fill fields. If `ALL_FILLS` is set, no additional fill scopes can be set. + * + * Valid scopes for `FLOAT` variables: + * + * - `ALL_SCOPES` + * - `TEXT_CONTENT` + * - `WIDTH_HEIGHT` + * - `GAP` + * - `STROKE_FLOAT` + * - `EFFECT_FLOAT` + * - `OPACITY` + * - `FONT_WEIGHT` + * - `FONT_SIZE` + * - `LINE_HEIGHT` + * - `LETTER_SPACING` + * - `PARAGRAPH_SPACING` + * - `PARAGRAPH_INDENT` + * + * Valid scopes for `STRING` variables: + * + * - `ALL_SCOPES` + * - `TEXT_CONTENT` + * - `FONT_FAMILY` + * - `FONT_STYLE` + * + * Valid scopes for `COLOR` variables: + * + * - `ALL_SCOPES` + * - `ALL_FILLS` + * - `FRAME_FILL` + * - `SHAPE_FILL` + * - `TEXT_FILL` + * - `STROKE_COLOR` + * - `EFFECT_COLOR` + */ + export type VariableScope = + | 'ALL_SCOPES' + | 'TEXT_CONTENT' + | 'CORNER_RADIUS' + | 'WIDTH_HEIGHT' + | 'GAP' + | 'ALL_FILLS' + | 'FRAME_FILL' + | 'SHAPE_FILL' + | 'TEXT_FILL' + | 'STROKE_COLOR' + | 'STROKE_FLOAT' + | 'EFFECT_FLOAT' + | 'EFFECT_COLOR' + | 'OPACITY' + | 'FONT_FAMILY' + | 'FONT_STYLE' + | 'FONT_WEIGHT' + | 'FONT_SIZE' + | 'LINE_HEIGHT' + | 'LETTER_SPACING' + | 'PARAGRAPH_SPACING' + | 'PARAGRAPH_INDENT' + + /** + * An object containing platform-specific code syntax definitions for a variable. All platforms are + * optional. + */ + export type VariableCodeSyntax = { + WEB?: string + + ANDROID?: string + + iOS?: string + } + + /** + * A grouping of related Variable objects each with the same modes. + */ + export type LocalVariableCollection = { + /** + * The unique identifier of this variable collection. + */ + id: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The key of this variable collection. + */ + key: string + + /** + * The modes of this variable collection. + */ + modes: { + /** + * The unique identifier of this mode. + */ + modeId: string + + /** + * The name of this mode. + */ + name: string + }[] + + /** + * The id of the default mode. + */ + defaultModeId: string + + /** + * Whether this variable collection is remote. + */ + remote: boolean + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing: boolean + + /** + * The ids of the variables in the collection. Note that the order of these variables is roughly the + * same as what is shown in Figma Design, however it does not account for groups. As a result, the + * order of these variables may not exactly reflect the exact ordering and grouping shown in the + * authoring UI. + */ + variableIds: string[] + } + + /** + * A Variable is a single design token that defines values for each of the modes in its + * VariableCollection. These values can be applied to various kinds of design properties. + */ + export type LocalVariable = { + /** + * The unique identifier of this variable. + */ + id: string + + /** + * The name of this variable. + */ + name: string + + /** + * The key of this variable. + */ + key: string + + /** + * The id of the variable collection that contains this variable. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The values for each mode of this variable. + */ + valuesByMode: { [key: string]: boolean | number | string | RGBA | VariableAlias } + + /** + * Whether this variable is remote. + */ + remote: boolean + + /** + * The description of this variable. + */ + description: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + * + * If the parent `VariableCollection` is marked as `hiddenFromPublishing`, then this variable will + * also be hidden from publishing via the UI. `hiddenFromPublishing` is independently toggled for a + * variable and collection. However, both must be true for a given variable to be publishable. + */ + hiddenFromPublishing: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + * + * Setting scopes for a variable does not prevent that variable from being bound in other scopes + * (for example, via the Plugin API). This only limits the variables that are shown in pickers + * within the Figma UI. + */ + scopes: VariableScope[] + + codeSyntax: VariableCodeSyntax + + /** + * Indicates that the variable was deleted in the editor, but the document may still contain + * references to the variable. References to the variable may exist through bound values or variable + * aliases. + */ + deletedButReferenced?: boolean + } + + /** + * A grouping of related Variable objects each with the same modes. + */ + export type PublishedVariableCollection = { + /** + * The unique identifier of this variable collection. + */ + id: string + + /** + * The ID of the variable collection that is used by subscribing files. This ID changes every time + * the variable collection is modified and published. + */ + subscribed_id: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The key of this variable collection. + */ + key: string + + /** + * The UTC ISO 8601 time at which the variable collection was last updated. + * + * This timestamp will change any time a variable in the collection is changed. + */ + updatedAt: string + } + + /** + * A Variable is a single design token that defines values for each of the modes in its + * VariableCollection. These values can be applied to various kinds of design properties. + */ + export type PublishedVariable = { + /** + * The unique identifier of this variable. + */ + id: string + + /** + * The ID of the variable that is used by subscribing files. This ID changes every time the variable + * is modified and published. + */ + subscribed_id: string + + /** + * The name of this variable. + */ + name: string + + /** + * The key of this variable. + */ + key: string + + /** + * The id of the variable collection that contains this variable. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedDataType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The UTC ISO 8601 time at which the variable was last updated. + */ + updatedAt: string + } + + /** + * An object that contains details about creating a `VariableCollection`. + */ + export type VariableCollectionCreate = { + /** + * The action to perform for the variable collection. + */ + action: 'CREATE' + + /** + * A temporary id for this variable collection. + */ + id?: string + + /** + * The name of this variable collection. + */ + name: string + + /** + * The initial mode refers to the mode that is created by default. You can set a temporary id here, + * in order to reference this mode later in this request. + */ + initialModeId?: string + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + } + + /** + * An object that contains details about updating a `VariableCollection`. + */ + export type VariableCollectionUpdate = { + /** + * The action to perform for the variable collection. + */ + action: 'UPDATE' + + /** + * The id of the variable collection to update. + */ + id: string + + /** + * The name of this variable collection. + */ + name?: string + + /** + * Whether this variable collection is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + } + + /** + * An object that contains details about deleting a `VariableCollection`. + */ + export type VariableCollectionDelete = { + /** + * The action to perform for the variable collection. + */ + action: 'DELETE' + + /** + * The id of the variable collection to delete. + */ + id: string + } + + export type VariableCollectionChange = + | VariableCollectionCreate + | VariableCollectionUpdate + | VariableCollectionDelete + + /** + * An object that contains details about creating a `VariableMode`. + */ + export type VariableModeCreate = { + /** + * The action to perform for the variable mode. + */ + action: 'CREATE' + + /** + * A temporary id for this variable mode. + */ + id?: string + + /** + * The name of this variable mode. + */ + name: string + + /** + * The variable collection that will contain the mode. You can use the temporary id of a variable + * collection. + */ + variableCollectionId: string + } + + /** + * An object that contains details about updating a `VariableMode`. + */ + export type VariableModeUpdate = { + /** + * The action to perform for the variable mode. + */ + action: 'UPDATE' + + /** + * The id of the variable mode to update. + */ + id: string + + /** + * The name of this variable mode. + */ + name?: string + + /** + * The variable collection that contains the mode. + */ + variableCollectionId: string + } + + /** + * An object that contains details about deleting a `VariableMode`. + */ + export type VariableModeDelete = { + /** + * The action to perform for the variable mode. + */ + action: 'DELETE' + + /** + * The id of the variable mode to delete. + */ + id: string + } + + export type VariableModeChange = VariableModeCreate | VariableModeUpdate | VariableModeDelete + + /** + * An object that contains details about creating a `Variable`. + */ + export type VariableCreate = { + /** + * The action to perform for the variable. + */ + action: 'CREATE' + + /** + * A temporary id for this variable. + */ + id?: string + + /** + * The name of this variable. + */ + name: string + + /** + * The variable collection that will contain the variable. You can use the temporary id of a + * variable collection. + */ + variableCollectionId: string + + /** + * The resolved type of the variable. + */ + resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' + + /** + * The description of this variable. + */ + description?: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + */ + scopes?: VariableScope[] + + codeSyntax?: VariableCodeSyntax + } + + /** + * An object that contains details about updating a `Variable`. + */ + export type VariableUpdate = { + /** + * The action to perform for the variable. + */ + action: 'UPDATE' + + /** + * The id of the variable to update. + */ + id: string + + /** + * The name of this variable. + */ + name?: string + + /** + * The description of this variable. + */ + description?: string + + /** + * Whether this variable is hidden when publishing the current file as a library. + */ + hiddenFromPublishing?: boolean + + /** + * An array of scopes in the UI where this variable is shown. Setting this property will show/hide + * this variable in the variable picker UI for different fields. + */ + scopes?: VariableScope[] + + codeSyntax?: VariableCodeSyntax + } + + /** + * An object that contains details about deleting a `Variable`. + */ + export type VariableDelete = { + /** + * The action to perform for the variable. + */ + action: 'DELETE' + + /** + * The id of the variable to delete. + */ + id: string + } + + export type VariableChange = VariableCreate | VariableUpdate | VariableDelete + + /** + * An object that represents a value for a given mode of a variable. All properties are required. + */ + export type VariableModeValue = { + /** + * The target variable. You can use the temporary id of a variable. + */ + variableId: string + + /** + * Must correspond to a mode in the variable collection that contains the target variable. + */ + modeId: string + + value: VariableValue + } + + /** + * The value for the variable. The value must match the variable's type. If setting to a variable + * alias, the alias must resolve to this type. + */ + export type VariableValue = boolean | number | string | RGB | RGBA | VariableAlias + + /** + * A dev resource in a file + */ + export type DevResource = { + /** + * Unique identifier of the dev resource + */ + id: string + + /** + * The name of the dev resource. + */ + name: string + + /** + * The URL of the dev resource. + */ + url: string + + /** + * The file key where the dev resource belongs. + */ + file_key: string + + /** + * The target node to attach the dev resource to. + */ + node_id: string + } + + /** + * Library analytics component actions data broken down by asset. + */ + export type LibraryAnalyticsComponentActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the component. + */ + component_key: string + + /** + * Name of the component. + */ + component_name: string + + /** + * Unique, stable id of the component set that this component belongs to. + */ + component_set_key?: string + + /** + * Name of the component set that this component belongs to. + */ + component_set_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics action data broken down by team. + */ + export type LibraryAnalyticsComponentActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics component usage data broken down by component. + */ + export type LibraryAnalyticsComponentUsagesByAsset = { + /** + * Unique, stable id of the component. + */ + component_key: string + + /** + * Name of the component. + */ + component_name: string + + /** + * Unique, stable id of the component set that this component belongs to. + */ + component_set_key?: string + + /** + * Name of the component set that this component belongs to. + */ + component_set_name?: string + + /** + * The number of instances of the component within the organization. + */ + usages: number + + /** + * The number of teams using the component within the organization. + */ + teams_using: number + + /** + * The number of files using the component within the organization. + */ + files_using: number + } + + /** + * Library analytics component usage data broken down by file. + */ + export type LibraryAnalyticsComponentUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of component instances from the library used within the file. + */ + usages: number + } + + /** + * Library analytics style actions data broken down by asset. + */ + export type LibraryAnalyticsStyleActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the style. + */ + style_key: string + + /** + * The name of the style. + */ + style_name: string + + /** + * The type of the style. + */ + style_type: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics style action data broken down by team. + */ + export type LibraryAnalyticsStyleActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics style usage data broken down by component. + */ + export type LibraryAnalyticsStyleUsagesByAsset = { + /** + * Unique, stable id of the style. + */ + style_key: string + + /** + * The name of the style. + */ + style_name: string + + /** + * The type of the style. + */ + style_type: string + + /** + * The number of usages of the style within the organization. + */ + usages: number + + /** + * The number of teams using the style within the organization. + */ + teams_using: number + + /** + * The number of files using the style within the organization. + */ + files_using: number + } + + /** + * Library analytics style usage data broken down by file. + */ + export type LibraryAnalyticsStyleUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of times styles from this library are used within the file. + */ + usages: number + } + + /** + * Library analytics variable actions data broken down by asset. + */ + export type LibraryAnalyticsVariableActionsByAsset = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * Unique, stable id of the variable. + */ + variable_key: string + + /** + * The name of the variable. + */ + variable_name: string + + /** + * The type of the variable. + */ + variable_type: string + + /** + * Unique, stable id of the collection the variable belongs to. + */ + collection_key: string + + /** + * The name of the collection the variable belongs to. + */ + collection_name: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics variable action data broken down by team. + */ + export type LibraryAnalyticsVariableActionsByTeam = { + /** + * The date in ISO 8601 format. e.g. 2023-12-13 + */ + week: string + + /** + * The name of the team using the library. + */ + team_name: string + + /** + * The name of the workspace that the team belongs to. + */ + workspace_name?: string + + /** + * The number of detach events for this period. + */ + detachments: number + + /** + * The number of insertion events for this period. + */ + insertions: number + } + + /** + * Library analytics variable usage data broken down by component. + */ + export type LibraryAnalyticsVariableUsagesByAsset = { + /** + * Unique, stable id of the variable. + */ + variable_key: string + + /** + * The name of the variable. + */ + variable_name: string + + /** + * The type of the variable. + */ + variable_type: string + + /** + * Unique, stable id of the collection the variable belongs to. + */ + collection_key: string + + /** + * The name of the collection the variable belongs to. + */ + collection_name: string + + /** + * The number of usages of the variable within the organization. + */ + usages: number + + /** + * The number of teams using the variable within the organization. + */ + teams_using: number + + /** + * The number of files using the variable within the organization. + */ + files_using: number + } + + /** + * Library analytics variable usage data broken down by file. + */ + export type LibraryAnalyticsVariableUsagesByFile = { + /** + * The name of the file using the library. + */ + file_name: string + + /** + * The name of the team the file belongs to. + */ + team_name: string + + /** + * The name of the workspace that the file belongs to. + */ + workspace_name?: string + + /** + * The number of times variables from this library are used within the file. + */ + usages: number + } + + /** + * If pagination is needed due to the length of the response, identifies the next and previous + * pages. + */ + export type ResponsePagination = { + /** + * A URL that calls the previous page of the response. + */ + prev_page?: string + + /** + * A URL that calls the next page of the response. + */ + next_page?: string + } + + /** + * Pagination cursor + */ + export type ResponseCursor = { + before?: number + + after?: number + } + + /** + * A response indicating an error occurred. + */ + export type ErrorResponsePayloadWithErrMessage = { + /** + * Status code + */ + status: number + + /** + * A string describing the error + */ + err: string + } + + /** + * A response indicating an error occurred. + */ + export type ErrorResponsePayloadWithErrorBoolean = { + /** + * For erroneous requests, this value is always `true`. + */ + error: true + + /** + * Status code + */ + status: number + + /** + * A string describing the error + */ + message: string + } + + /** + * Response from the GET /v1/files/{file_key} endpoint. + */ + export type GetFileResponse = { + /** + * The name of the file as it appears in the editor. + */ + name: string + + /** + * The role of the user making the API request in relation to the file. + */ + role: 'owner' | 'editor' | 'viewer' + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + lastModified: string + + /** + * The type of editor associated with this file. + */ + editorType: 'figma' | 'figjam' + + /** + * A URL to a thumbnail image of the file. + */ + thumbnailUrl?: string + + /** + * The version number of the file. This number is incremented when a file is modified and can be + * used to check if the file has changed between requests. + */ + version: string + + document: DocumentNode + + /** + * A mapping from component IDs to component metadata. + */ + components: { [key: string]: Component } + + /** + * A mapping from component set IDs to component set metadata. + */ + componentSets: { [key: string]: ComponentSet } + + /** + * The version of the file schema that this file uses. + */ + schemaVersion: number + + /** + * A mapping from style IDs to style metadata. + */ + styles: { [key: string]: Style } + + /** + * The share permission level of the file link. + */ + linkAccess?: string + + /** + * The key of the main file for this file. If present, this file is a component or component set. + */ + mainFileKey?: string + + /** + * A list of branches for this file. + */ + branches?: { + /** + * The key of the branch. + */ + key: string + + /** + * The name of the branch. + */ + name: string + + /** + * A URL to a thumbnail image of the branch. + */ + thumbnail_url: string + + /** + * The UTC ISO 8601 time at which the branch was last modified. + */ + last_modified: string + }[] + } + + /** + * Response from the GET /v1/files/{file_key}/nodes endpoint. + */ + export type GetFileNodesResponse = { + /** + * The name of the file as it appears in the editor. + */ + name: string + + /** + * The role of the user making the API request in relation to the file. + */ + role: 'owner' | 'editor' | 'viewer' + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + lastModified: string + + /** + * The type of editor associated with this file. + */ + editorType: 'figma' | 'figjam' + + /** + * A URL to a thumbnail image of the file. + */ + thumbnailUrl: string + + /** + * The version number of the file. This number is incremented when a file is modified and can be + * used to check if the file has changed between requests. + */ + version: string + + /** + * A mapping from node IDs to node metadata. + */ + nodes: { + [key: string]: { + document: Node + + /** + * A mapping from component IDs to component metadata. + */ + components: { [key: string]: Component } + + /** + * A mapping from component set IDs to component set metadata. + */ + componentSets: { [key: string]: ComponentSet } + + /** + * The version of the file schema that this file uses. + */ + schemaVersion: number + + /** + * A mapping from style IDs to style metadata. + */ + styles: { [key: string]: Style } + } + } + } + + /** + * Response from the GET /v1/images/{file_key} endpoint. + */ + export type GetImagesResponse = { + /** + * For successful requests, this value is always `null`. + */ + err: null + + /** + * A map from node IDs to URLs of the rendered images. + */ + images: { [key: string]: string | null } + } + + /** + * Response from the GET /v1/files/{file_key}/images endpoint. + */ + export type GetImageFillsResponse = { + /** + * For successful requests, this value is always `false`. + */ + error: false + + /** + * Status code + */ + status: 200 + + meta: { + /** + * A map of image references to URLs of the image fills. + */ + images: { [key: string]: string } + } + } + + /** + * Response from the GET /v1/teams/{team_id}/projects endpoint. + */ + export type GetTeamProjectsResponse = { + /** + * The team's name. + */ + name: string + + /** + * An array of projects. + */ + projects: Project[] + } + + /** + * Response from the GET /v1/projects/{project_id}/files endpoint. + */ + export type GetProjectFilesResponse = { + /** + * The project's name. + */ + name: string + + /** + * An array of files. + */ + files: { + /** + * The file's key. + */ + key: string + + /** + * The file's name. + */ + name: string + + /** + * The file's thumbnail URL. + */ + thumbnail_url?: string + + /** + * The UTC ISO 8601 time at which the file was last modified. + */ + last_modified: string + }[] + } + + /** + * Response from the GET /v1/files/{file_key}/versions endpoint. + */ + export type GetFileVersionsResponse = { + /** + * An array of versions. + */ + versions: Version[] + + pagination: ResponsePagination + } + + /** + * Response from the GET /v1/files/{file_key}/comments endpoint. + */ + export type GetCommentsResponse = { + /** + * An array of comments. + */ + comments: Comment[] + } + + /** + * Response from the POST /v1/files/{file_key}/comments endpoint. + */ + export type PostCommentResponse = Comment + + /** + * Response from the DELETE /v1/files/{file_key}/comments/{comment_id} endpoint. + */ + export type DeleteCommentResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + } + + /** + * Response from the GET /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ + export type GetCommentReactionsResponse = { + /** + * An array of reactions. + */ + reactions: Reaction[] + + pagination: ResponsePagination + } + + /** + * Response from the POST /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ + export type PostCommentReactionResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + } + + /** + * Response from the DELETE /v1/files/{file_key}/comments/{comment_id}/reactions endpoint. + */ + export type DeleteCommentReactionResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + } + + /** + * Response from the GET /v1/me endpoint. + */ + export type GetMeResponse = User & { + /** + * Email associated with the user's account. This property is only present on the /v1/me endpoint. + */ + email: string + } + + /** + * Response from the GET /v1/teams/{team_id}/components endpoint. + */ + export type GetTeamComponentsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + components: PublishedComponent[] + + cursor?: ResponseCursor + } + } + + /** + * Response from the GET /v1/files/{file_key}/components endpoint. + */ + export type GetFileComponentsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { components: PublishedComponent[] } + } + + /** + * Response from the GET /v1/components/{key} endpoint. + */ + export type GetComponentResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedComponent + } + + /** + * Response from the GET /v1/teams/{team_id}/component_sets endpoint. + */ + export type GetTeamComponentSetsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + component_sets: PublishedComponentSet[] + + cursor?: ResponseCursor + } + } + + /** + * Response from the GET /v1/files/{file_key}/component_sets endpoint. + */ + export type GetFileComponentSetsResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { component_sets: PublishedComponentSet[] } + } + + /** + * Response from the GET /v1/component_sets/{key} endpoint. + */ + export type GetComponentSetResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedComponentSet + } + + /** + * Response from the GET /v1/teams/{team_id}/styles endpoint. + */ + export type GetTeamStylesResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + styles: PublishedStyle[] + + cursor?: ResponseCursor + } + } + + /** + * Response from the GET /v1/files/{file_key}/styles endpoint. + */ + export type GetFileStylesResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { styles: PublishedStyle[] } + } + + /** + * Response from the GET /v1/styles/{key} endpoint. + */ + export type GetStyleResponse = { + /** + * The status of the request. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PublishedStyle + } + + /** + * Response from the POST /v2/webhooks endpoint. + */ + export type PostWebhookResponse = WebhookV2 + + /** + * Response from the GET /v2/webhooks/{webhook_id} endpoint. + */ + export type GetWebhookResponse = WebhookV2 + + /** + * Response from the PUT /v2/webhooks/{webhook_id} endpoint. + */ + export type PutWebhookResponse = WebhookV2 + + /** + * Response from the DELETE /v2/webhooks/{webhook_id} endpoint. + */ + export type DeleteWebhookResponse = WebhookV2 + + /** + * Response from the GET /v2/teams/{team_id}/webhooks endpoint. + */ + export type GetTeamWebhooksResponse = { + /** + * An array of webhooks. + */ + webhooks: WebhookV2[] + } + + /** + * Response from the GET /v2/webhooks/{webhook_id}/requests endpoint. + */ + export type GetWebhookRequestsResponse = { + /** + * An array of webhook requests. + */ + requests: WebhookV2Request[] + } + + /** + * Response from the GET /v1/activity_logs endpoint. + */ + export type GetActivityLogsResponse = { + /** + * The response status code. + */ + status?: 200 + + /** + * For successful requests, this value is always `false`. + */ + error?: false + + meta?: { + /** + * An array of activity logs sorted by timestamp in ascending order by default. + */ + activity_logs?: ActivityLog[] + + /** + * Encodes the last event (the most recent event) + */ + cursor?: string + + /** + * Whether there is a next page of events + */ + next_page?: boolean + } + } + + /** + * Response from the GET /v1/payments endpoint. + */ + export type GetPaymentsResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: PaymentInformation + } + + /** + * Response from the GET /v1/files/{file_key}/variables/local endpoint. + */ + export type GetLocalVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of variable ids to variables + */ + variables: { [key: string]: LocalVariable } + + /** + * A map of variable collection ids to variable collections + */ + variableCollections: { [key: string]: LocalVariableCollection } + } + } + + /** + * Response from the GET /v1/files/{file_key}/variables/published endpoint. + */ + export type GetPublishedVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of variable ids to variables + */ + variables: { [key: string]: PublishedVariable } + + /** + * A map of variable collection ids to variable collections + */ + variableCollections: { [key: string]: PublishedVariableCollection } + } + } + + /** + * Response from the POST /v1/files/{file_key}/variables endpoint. + */ + export type PostVariablesResponse = { + /** + * The response status code. + */ + status: 200 + + /** + * For successful requests, this value is always `false`. + */ + error: false + + meta: { + /** + * A map of temporary ids in the request to the real ids of the newly created objects + */ + tempIdToRealId: { [key: string]: string } + } + } + + /** + * Response from the GET /v1/files/{file_key}/dev_resources endpoint. + */ + export type GetDevResourcesResponse = { + /** + * An array of dev resources. + */ + dev_resources: DevResource[] + } + + /** + * Response from the POST /v1/dev_resources endpoint. + */ + export type PostDevResourcesResponse = { + /** + * An array of links created. + */ + links_created: DevResource[] + + /** + * An array of errors. + */ + errors?: { + /** + * The file key. + */ + file_key?: string | null + + /** + * The node id. + */ + node_id?: string | null + + /** + * The error message. + */ + error: string + }[] + } + + /** + * Response from the PUT /v1/dev_resources endpoint. + */ + export type PutDevResourcesResponse = { + /** + * An array of links updated. + */ + links_updated?: DevResource[] + + /** + * An array of errors. + */ + errors?: { + /** + * The id of the dev resource. + */ + id?: string + + /** + * The error message. + */ + error: string + }[] + } + + /** + * Response from the DELETE /v1/files/{file_key}/dev_resources/{dev_resource_id} endpoint. + */ + export type DeleteDevResourceResponse = void + + /** + * Response from the GET /v1/analytics/libraries/{file_key}/component/actions. + */ + export type GetLibraryAnalyticsComponentActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsComponentActionsByAsset[] | LibraryAnalyticsComponentActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Response from the PUT /v1/analytics/libraries/{file_key}/component/usages. + */ + export type GetLibraryAnalyticsComponentUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsComponentUsagesByAsset[] | LibraryAnalyticsComponentUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Response from the GET /v1/analytics/libraries/{file_key}/style/actions. + */ + export type GetLibraryAnalyticsStyleActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsStyleActionsByAsset[] | LibraryAnalyticsStyleActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Response from the PUT /v1/analytics/libraries/{file_key}/style/usages. + */ + export type GetLibraryAnalyticsStyleUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsStyleUsagesByAsset[] | LibraryAnalyticsStyleUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Response from the GET /v1/analytics/libraries/{file_key}/variable/actions. + */ + export type GetLibraryAnalyticsVariableActionsResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsVariableActionsByAsset[] | LibraryAnalyticsVariableActionsByTeam[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Response from the PUT /v1/analytics/libraries/{file_key}/variable/usages. + */ + export type GetLibraryAnalyticsVariableUsagesResponse = { + /** + * An array of analytics data. + */ + rows: LibraryAnalyticsVariableUsagesByAsset[] | LibraryAnalyticsVariableUsagesByFile[] + + /** + * Whether there is a next page of data that can be fetched. + */ + next_page: boolean + + /** + * The cursor to use to fetch the next page of data. Not present if next_page is false. + */ + cursor?: string + } + + /** + * Bad request. Parameters are invalid or malformed. Please check the input formats. This error can + * also happen if the requested resources are too large to complete the request, which results in a + * timeout. Please reduce the number and size of objects requested. + */ + export type BadRequestErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 400 + } + + /** + * Bad request. Parameters are invalid or malformed. Please check the input formats. This error can + * also happen if the requested resources are too large to complete the request, which results in a + * timeout. Please reduce the number and size of objects requested. + */ + export type BadRequestErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 400 + } + + /** + * Token is missing or incorrect. + */ + export type UnauthorizedErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 401 + } + + /** + * The request was valid, but the server is refusing action. The user might not have the necessary + * permissions for a resource, or may need an account of some sort. + */ + export type ForbiddenErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 403 + } + + /** + * The request was valid, but the server is refusing action. The user might not have the necessary + * permissions for a resource, or may need an account of some sort. + */ + export type ForbiddenErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 403 + } + + /** + * The requested file or resource was not found. + */ + export type NotFoundErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 404 + } + + /** + * The requested file or resource was not found. + */ + export type NotFoundErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 404 + } + + /** + * In some cases API requests may be throttled or rate limited. Please wait a while before + * attempting the request again (typically a minute). + */ + export type TooManyRequestsErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 429 + } + + /** + * In some cases API requests may be throttled or rate limited. Please wait a while before + * attempting the request again (typically a minute). + */ + export type TooManyRequestsErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 429 + } + + /** + * An internal server error occurred. + */ + export type InternalServerErrorResponseWithErrMessage = ErrorResponsePayloadWithErrMessage & { + /** + * Status code + */ + status: 500 + } + + /** + * An internal server error occurred. + */ + export type InternalServerErrorResponseWithErrorBoolean = ErrorResponsePayloadWithErrorBoolean & { + /** + * Status code + */ + status: 500 + } + + /** + * Path parameters for GET /v1/files/{file_key} + */ + export type GetFilePathParams = { + /** + * File to export JSON from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/files/{file_key} + */ + export type GetFileQueryParams = { + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * Comma separated list of nodes that you care about in the document. If specified, only a subset of + * the document will be returned corresponding to the nodes listed, their children, and everything + * between the root node and the listed nodes. + * + * Note: There may be other nodes included in the returned JSON that are outside the ancestor chains + * of the desired nodes. The response may also include dependencies of anything in the nodes' + * subtrees. For example, if a node subtree contains an instance of a local component that lives + * elsewhere in that file, that component and its ancestor chain will also be included. + * + * For historical reasons, top-level canvas nodes are always returned, regardless of whether they + * are listed in the `ids` parameter. This quirk may be removed in a future version of the API. + */ + ids?: string + /** + * Positive integer representing how deep into the document tree to traverse. For example, setting + * this to 1 returns only Pages, setting it to 2 returns Pages and all top level objects on each + * page. Not setting this parameter returns all nodes. + */ + depth?: number + /** + * Set to "paths" to export vector data. + */ + geometry?: string + /** + * A comma separated list of plugin IDs and/or the string "shared". Any data present in the document + * written by those plugins will be included in the result in the `pluginData` and + * `sharedPluginData` properties. + */ + plugin_data?: string + /** + * Returns branch metadata for the requested file. If the file is a branch, the main file's key will + * be included in the returned response. If the file has branches, their metadata will be included + * in the returned response. Default: false. + */ + branch_data?: boolean + } + + /** + * Path parameters for GET /v1/files/{file_key}/nodes + */ + export type GetFileNodesPathParams = { + /** + * File to export JSON from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/files/{file_key}/nodes + */ + export type GetFileNodesQueryParams = { + /** + * A comma separated list of node IDs to retrieve and convert. + */ + ids: string + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * Positive integer representing how deep into the node tree to traverse. For example, setting this + * to 1 will return only the children directly underneath the desired nodes. Not setting this + * parameter returns all nodes. + * + * Note: this parameter behaves differently from the same parameter in the `GET /v1/files/:key` + * endpoint. In this endpoint, the depth will be counted starting from the desired node rather than + * the document root node. + */ + depth?: number + /** + * Set to "paths" to export vector data. + */ + geometry?: string + /** + * A comma separated list of plugin IDs and/or the string "shared". Any data present in the document + * written by those plugins will be included in the result in the `pluginData` and + * `sharedPluginData` properties. + */ + plugin_data?: string + } + + /** + * Path parameters for GET /v1/images/{file_key} + */ + export type GetImagesPathParams = { + /** + * File to export images from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/images/{file_key} + */ + export type GetImagesQueryParams = { + /** + * A comma separated list of node IDs to render. + */ + ids: string + /** + * A specific version ID to get. Omitting this will get the current version of the file. + */ + version?: string + /** + * A number between 0.01 and 4, the image scaling factor. + */ + scale?: number + /** + * A string enum for the image output format. + */ + format?: 'jpg' | 'png' | 'svg' | 'pdf' + /** + * Whether text elements are rendered as outlines (vector paths) or as `` elements in SVGs. + * + * Rendering text elements as outlines guarantees that the text looks exactly the same in the SVG as + * it does in the browser/inside Figma. + * + * Exporting as `` allows text to be selectable inside SVGs and generally makes the SVG easier + * to read. However, this relies on the browser's rendering engine which can vary between browsers + * and/or operating systems. As such, visual accuracy is not guaranteed as the result could look + * different than in Figma. + */ + svg_outline_text?: boolean + /** + * Whether to include id attributes for all SVG elements. Adds the layer name to the `id` attribute + * of an svg element. + */ + svg_include_id?: boolean + /** + * Whether to include node id attributes for all SVG elements. Adds the node id to a `data-node-id` + * attribute of an svg element. + */ + svg_include_node_id?: boolean + /** + * Whether to simplify inside/outside strokes and use stroke attribute if possible instead of + * ``. + */ + svg_simplify_stroke?: boolean + /** + * Whether content that overlaps the node should be excluded from rendering. Passing false (i.e., + * rendering overlaps) may increase processing time, since more of the document must be included in + * rendering. + */ + contents_only?: boolean + /** + * Use the full dimensions of the node regardless of whether or not it is cropped or the space + * around it is empty. Use this to export text nodes without cropping. + */ + use_absolute_bounds?: boolean + } + + /** + * Path parameters for GET /v1/files/{file_key}/images + */ + export type GetImageFillsPathParams = { + /** + * File to get image URLs from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Path parameters for GET /v1/teams/{team_id}/projects + */ + export type GetTeamProjectsPathParams = { + /** + * ID of the team to list projects from + */ + team_id: string + } + + /** + * Path parameters for GET /v1/projects/{project_id}/files + */ + export type GetProjectFilesPathParams = { + /** + * ID of the project to list files from + */ + project_id: string + } + + /** + * Query parameters for GET /v1/projects/{project_id}/files + */ + export type GetProjectFilesQueryParams = { + /** + * Returns branch metadata in the response for each main file with a branch inside the project. + */ + branch_data?: boolean + } + + /** + * Path parameters for GET /v1/files/{file_key}/versions + */ + export type GetFileVersionsPathParams = { + /** + * File to get version history from. This can be a file key or branch key. Use `GET /v1/files/:key` + * with the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/files/{file_key}/versions + */ + export type GetFileVersionsQueryParams = { + /** + * The number of items returned in a page of the response. If not included, `page_size` is `30`. + */ + page_size?: number + /** + * A version ID for one of the versions in the history. Gets versions before this ID. Used for + * paginating. If the response is not paginated, this link returns the same data in the current + * response. + */ + before?: number + /** + * A version ID for one of the versions in the history. Gets versions after this ID. Used for + * paginating. If the response is not paginated, this property is not included. + */ + after?: number + } + + /** + * Path parameters for GET /v1/files/{file_key}/comments + */ + export type GetCommentsPathParams = { + /** + * File to get comments from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/files/{file_key}/comments + */ + export type GetCommentsQueryParams = { + /** + * If enabled, will return comments as their markdown equivalents when applicable. + */ + as_md?: boolean + } + + /** + * Path parameters for POST /v1/files/{file_key}/comments + */ + export type PostCommentPathParams = { + /** + * File to add comments in. This can be a file key or branch key. Use `GET /v1/files/:key` with the + * `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Request body parameters for POST /v1/files/{file_key}/comments + */ + export type PostCommentRequestBody = { + /** + * The text contents of the comment to post. + */ + message: string + + /** + * The ID of the comment to reply to, if any. This must be a root comment. You cannot reply to other + * replies (a comment that has a parent_id). + */ + comment_id?: string + + /** + * The position where to place the comment. + */ + client_meta?: Vector | FrameOffset | Region | FrameOffsetRegion + } + + /** + * Path parameters for DELETE /v1/files/{file_key}/comments/{comment_id} + */ + export type DeleteCommentPathParams = { + /** + * File to delete comment from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * Comment id of comment to delete + */ + comment_id: string + } + + /** + * Path parameters for DELETE /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type DeleteCommentReactionPathParams = { + /** + * File to delete comment reaction from. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to delete reaction from. + */ + comment_id: string + } + + /** + * Query parameters for DELETE /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type DeleteCommentReactionQueryParams = { emoji: Emoji } + + /** + * Path parameters for GET /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type GetCommentReactionsPathParams = { + /** + * File to get comment containing reactions from. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to get reactions from. + */ + comment_id: string + } + + /** + * Query parameters for GET /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type GetCommentReactionsQueryParams = { + /** + * Cursor for pagination, retrieved from the response of the previous call. + */ + cursor?: string + } + + /** + * Path parameters for POST /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type PostCommentReactionPathParams = { + /** + * File to post comment reactions to. This can be a file key or branch key. Use `GET + * /v1/files/:key` with the `branch_data` query param to get the branch key. + */ + file_key: string + /** + * ID of comment to react to. + */ + comment_id: string + } + + /** + * Request body parameters for POST /v1/files/{file_key}/comments/{comment_id}/reactions + */ + export type PostCommentReactionRequestBody = { emoji: Emoji } + + /** + * Path parameters for GET /v1/teams/{team_id}/components + */ + export type GetTeamComponentsPathParams = { + /** + * Id of the team to list components from. + */ + team_id: string + } + + /** + * Query parameters for GET /v1/teams/{team_id}/components + */ + export type GetTeamComponentsQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving components for. Exclusive with before. + * The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving components for. Exclusive with after. + * The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number + } + + /** + * Path parameters for GET /v1/files/{file_key}/components + */ + export type GetFileComponentsPathParams = { + /** + * File to list components from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string + } + + /** + * Path parameters for GET /v1/components/{key} + */ + export type GetComponentPathParams = { + /** + * The unique identifier of the component. + */ + key: string + } + + /** + * Path parameters for GET /v1/teams/{team_id}/component_sets + */ + export type GetTeamComponentSetsPathParams = { + /** + * Id of the team to list component sets from. + */ + team_id: string + } + + /** + * Query parameters for GET /v1/teams/{team_id}/component_sets + */ + export type GetTeamComponentSetsQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving component sets for. Exclusive with + * before. The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving component sets for. Exclusive with + * after. The cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number + } + + /** + * Path parameters for GET /v1/files/{file_key}/component_sets + */ + export type GetFileComponentSetsPathParams = { + /** + * File to list component sets from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string + } + + /** + * Path parameters for GET /v1/component_sets/{key} + */ + export type GetComponentSetPathParams = { + /** + * The unique identifier of the component set. + */ + key: string + } + + /** + * Path parameters for GET /v1/teams/{team_id}/styles + */ + export type GetTeamStylesPathParams = { + /** + * Id of the team to list styles from. + */ + team_id: string + } + + /** + * Query parameters for GET /v1/teams/{team_id}/styles + */ + export type GetTeamStylesQueryParams = { + /** + * Number of items to return in a paged list of results. Defaults to 30. + */ + page_size?: number + /** + * Cursor indicating which id after which to start retrieving styles for. Exclusive with before. The + * cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + after?: number + /** + * Cursor indicating which id before which to start retrieving styles for. Exclusive with after. The + * cursor value is an internally tracked integer that doesn't correspond to any Ids. + */ + before?: number + } + + /** + * Path parameters for GET /v1/files/{file_key}/styles + */ + export type GetFileStylesPathParams = { + /** + * File to list styles from. This must be a main file key, not a branch key, as it is not possible + * to publish from branches. + */ + file_key: string + } + + /** + * Path parameters for GET /v1/styles/{key} + */ + export type GetStylePathParams = { + /** + * The unique identifier of the style. + */ + key: string + } + + /** + * Request body parameters for POST /v2/webhooks + */ + export type PostWebhookRequestBody = { + event_type: WebhookV2Event + + /** + * Team id to receive updates about + */ + team_id: string + + /** + * The HTTP endpoint that will receive a POST request when the event triggers. Max length 2048 + * characters. + */ + endpoint: string + + /** + * String that will be passed back to your webhook endpoint to verify that it is being called by + * Figma. Max length 100 characters. + */ + passcode: string + + /** + * State of the webhook, including any error state it may be in + */ + status?: WebhookV2Status + + /** + * User provided description or name for the webhook. Max length 150 characters. + */ + description?: string + } + + /** + * Path parameters for DELETE /v2/webhooks/{webhook_id} + */ + export type DeleteWebhookPathParams = { + /** + * ID of webhook to delete + */ + webhook_id: string + } + + /** + * Path parameters for GET /v2/webhooks/{webhook_id} + */ + export type GetWebhookPathParams = { + /** + * ID of webhook to get + */ + webhook_id: string + } + + /** + * Path parameters for PUT /v2/webhooks/{webhook_id} + */ + export type PutWebhookPathParams = { + /** + * ID of webhook to update + */ + webhook_id: string + } + + /** + * Request body parameters for PUT /v2/webhooks/{webhook_id} + */ + export type PutWebhookRequestBody = { + event_type: WebhookV2Event + + /** + * The HTTP endpoint that will receive a POST request when the event triggers. Max length 2048 + * characters. + */ + endpoint: string + + /** + * String that will be passed back to your webhook endpoint to verify that it is being called by + * Figma. Max length 100 characters. + */ + passcode: string + + /** + * State of the webhook, including any error state it may be in + */ + status?: WebhookV2Status + + /** + * User provided description or name for the webhook. Max length 150 characters. + */ + description?: string + } + + /** + * Path parameters for GET /v2/teams/{team_id}/webhooks + */ + export type GetTeamWebhooksPathParams = { + /** + * ID of team to get webhooks for + */ + team_id: string + } + + /** + * Path parameters for GET /v2/webhooks/{webhook_id}/requests + */ + export type GetWebhookRequestsPathParams = { + /** + * The id of the webhook subscription you want to see events from + */ + webhook_id: string + } + + /** + * Query parameters for GET /v1/activity_logs + */ + export type GetActivityLogsQueryParams = { + /** + * Event type(s) to include in the response. Can have multiple values separated by comma. All + * events are returned by default. + */ + events?: string + /** + * Unix timestamp of the least recent event to include. This param defaults to one year ago if + * unspecified. Events prior to one year ago are not available. + */ + start_time?: number + /** + * Unix timestamp of the most recent event to include. This param defaults to the current timestamp + * if unspecified. + */ + end_time?: number + /** + * Maximum number of events to return. This param defaults to 1000 if unspecified. + */ + limit?: number + /** + * Event order by timestamp. This param can be either "asc" (default) or "desc". + */ + order?: 'asc' | 'desc' + } + + /** + * Query parameters for GET /v1/payments + */ + export type GetPaymentsQueryParams = { + /** + * Short-lived token returned from "getPluginPaymentTokenAsync" in the plugin payments API and used + * to authenticate to this endpoint. Read more about generating this token through "Calling the + * Payments REST API from a plugin or widget" below. + */ + plugin_payment_token?: string + /** + * The ID of the user to query payment information about. You can get the user ID by having the user + * OAuth2 to the Figma REST API. + */ + user_id?: number + /** + * The ID of the Community file to query a user's payment information on. You can get the Community + * file ID from the file's Community page (look for the number after "file/" in the URL). Provide + * exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + community_file_id?: number + /** + * The ID of the plugin to query a user's payment information on. You can get the plugin ID from the + * plugin's manifest, or from the plugin's Community page (look for the number after "plugin/" in + * the URL). Provide exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + plugin_id?: number + /** + * The ID of the widget to query a user's payment information on. You can get the widget ID from the + * widget's manifest, or from the widget's Community page (look for the number after "widget/" in + * the URL). Provide exactly one of "community_file_id", "plugin_id", or "widget_id". + */ + widget_id?: number + } + + /** + * Path parameters for GET /v1/files/{file_key}/variables/local + */ + export type GetLocalVariablesPathParams = { + /** + * File to get variables from. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Path parameters for GET /v1/files/{file_key}/variables/published + */ + export type GetPublishedVariablesPathParams = { + /** + * File to get variables from. This must be a main file key, not a branch key, as it is not + * possible to publish from branches. + */ + file_key: string + } + + /** + * Path parameters for POST /v1/files/{file_key}/variables + */ + export type PostVariablesPathParams = { + /** + * File to modify variables in. This can be a file key or branch key. Use `GET /v1/files/:key` with + * the `branch_data` query param to get the branch key. + */ + file_key: string + } + + /** + * Request body parameters for POST /v1/files/{file_key}/variables + */ + export type PostVariablesRequestBody = { + /** + * For creating, updating, and deleting variable collections. + */ + variableCollections?: VariableCollectionChange[] + + /** + * For creating, updating, and deleting modes within variable collections. + */ + variableModes?: VariableModeChange[] + + /** + * For creating, updating, and deleting variables. + */ + variables?: VariableChange[] + + /** + * For setting a specific value, given a variable and a mode. + */ + variableModeValues?: VariableModeValue[] + } + + /** + * Path parameters for GET /v1/files/{file_key}/dev_resources + */ + export type GetDevResourcesPathParams = { + /** + * The file to get the dev resources from. This must be a main file key, not a branch key. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/files/{file_key}/dev_resources + */ + export type GetDevResourcesQueryParams = { + /** + * Comma separated list of nodes that you care about in the document. If specified, only dev + * resources attached to these nodes will be returned. If not specified, all dev resources in the + * file will be returned. + */ + node_ids?: string + } + + /** + * Request body parameters for POST /v1/dev_resources + */ + export type PostDevResourcesRequestBody = { + /** + * An array of dev resources. + */ + dev_resources: { + /** + * The name of the dev resource. + */ + name: string + + /** + * The URL of the dev resource. + */ + url: string + + /** + * The file key where the dev resource belongs. + */ + file_key: string + + /** + * The target node to attach the dev resource to. + */ + node_id: string + }[] + } + + /** + * Request body parameters for PUT /v1/dev_resources + */ + export type PutDevResourcesRequestBody = { + /** + * An array of dev resources. + */ + dev_resources: { + /** + * Unique identifier of the dev resource + */ + id: string + + /** + * The name of the dev resource. + */ + name?: string + + /** + * The URL of the dev resource. + */ + url?: string + }[] + } + + /** + * Path parameters for DELETE /v1/files/{file_key}/dev_resources/{dev_resource_id} + */ + export type DeleteDevResourcePathParams = { + /** + * The file to delete the dev resource from. This must be a main file key, not a branch key. + */ + file_key: string + /** + * The id of the dev resource to delete. + */ + dev_resource_id: string + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/component/actions + */ + export type GetLibraryAnalyticsComponentActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/component/actions + */ + export type GetLibraryAnalyticsComponentActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'component' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/component/usages + */ + export type GetLibraryAnalyticsComponentUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/component/usages + */ + export type GetLibraryAnalyticsComponentUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'component' | 'file' + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/style/actions + */ + export type GetLibraryAnalyticsStyleActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/style/actions + */ + export type GetLibraryAnalyticsStyleActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'style' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/style/usages + */ + export type GetLibraryAnalyticsStyleUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/style/usages + */ + export type GetLibraryAnalyticsStyleUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'style' | 'file' + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/variable/actions + */ + export type GetLibraryAnalyticsVariableActionsPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/variable/actions + */ + export type GetLibraryAnalyticsVariableActionsQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'variable' | 'team' + /** + * ISO 8601 date string (YYYY-MM-DD) of the earliest week to include. Dates are rounded back to the + * nearest start of a week. Defaults to one year prior. + */ + start_date?: string + /** + * ISO 8601 date string (YYYY-MM-DD) of the latest week to include. Dates are rounded forward to the + * nearest end of a week. Defaults to the latest computed week. + */ + end_date?: string + } + + /** + * Path parameters for GET /v1/analytics/libraries/{file_key}/variable/usages + */ + export type GetLibraryAnalyticsVariableUsagesPathParams = { + /** + * File key of the library to fetch analytics data for. + */ + file_key: string + } + + /** + * Query parameters for GET /v1/analytics/libraries/{file_key}/variable/usages + */ + export type GetLibraryAnalyticsVariableUsagesQueryParams = { + /** + * Cursor indicating what page of data to fetch. Obtained from prior API call. + */ + cursor?: string + /** + * A dimension to group returned analytics data by. + */ + group_by: 'variable' | 'file' + } diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 3017aded..9b4518fb 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -1,20 +1,113 @@ -import { convertNodesToAltNodes } from "./altNodes/altConversion"; import { + retrieveGenericLinearGradients, retrieveGenericSolidUIColors, - retrieveGenericLinearGradients as retrieveGenericGradients, } from "./common/retrieveUI/retrieveColors"; -import { generateHTMLPreview, htmlMain } from "./html/htmlMain"; -import { postConversionComplete, postEmptyMessage } from "./messaging"; -import { clearWarnings, warnings } from "./common/commonConversionWarnings"; +import { + addWarning, + clearWarnings, + warnings, +} from "./common/commonConversionWarnings"; +import { postConversionComplete, postEmptyMessage, postError } from "./messaging"; import { PluginSettings } from "types"; import { convertToCode } from "./common/retrieveUI/convertToCode"; +import { generateHTMLPreview } from "./html/htmlMain"; +import { oldConvertNodesToAltNodes } from "./altNodes/oldAltConversion"; +import { + getNodeByIdAsyncCalls, + getNodeByIdAsyncTime, + getStyledTextSegmentsCalls, + getStyledTextSegmentsTime, + nodesToJSON, + processColorVariablesCalls, + processColorVariablesTime, + resetPerformanceCounters, +} from "./altNodes/jsonNodeConversion"; export const run = async (settings: PluginSettings) => { + resetPerformanceCounters(); clearWarnings(); - const { framework } = settings; + + const { framework, useOldPluginVersion2025 } = settings; const selection = figma.currentPage.selection; - const convertedSelection = convertNodesToAltNodes(selection, null); + if (selection.length === 0) { + postEmptyMessage(); + 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( + "[debug] convertedSelection count (old conversion):", + convertedSelection.length, + ); + } else { + convertedSelection = await nodesToJSON(selection, settings); + console.log(`[benchmark] nodesToJSON: ${Date.now() - nodeToJSONStart}ms`); + console.log( + "[debug] convertedSelection count:", + convertedSelection.length, + ); + // const removeParentRecursive = (obj: any): any => { + // if (Array.isArray(obj)) { + // return obj.map(removeParentRecursive); + // } + // if (obj && typeof obj === 'object') { + // const newObj = { ...obj }; + // delete newObj.parent; + // for (const key in newObj) { + // newObj[key] = removeParentRecursive(newObj[key]); + // } + // return newObj; + // } + // return obj; + // }; + // console.log("nodeJson without parent refs:", removeParentRecursive(convertedSelection)); + } + + 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. @@ -23,14 +116,52 @@ export const run = async (settings: PluginSettings) => { return; } + const convertToCodeStart = Date.now(); const code = await convertToCode(convertedSelection, settings); - const htmlPreview = await generateHTMLPreview( - convertedSelection, - settings, - code, + console.log( + `[benchmark] convertToCode: ${Date.now() - convertToCodeStart}ms`, + ); + + let htmlPreview = { size: { width: 0, height: 0 }, content: "" }; + let colors: Awaited> = []; + let gradients: Awaited> = []; + + 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`, + ); + + // Log performance statistics + console.log( + `[benchmark] getNodeByIdAsync: ${getNodeByIdAsyncTime}ms (${getNodeByIdAsyncCalls} calls, avg: ${(getNodeByIdAsyncTime / getNodeByIdAsyncCalls || 1).toFixed(2)}ms)`, + ); + console.log( + `[benchmark] getStyledTextSegments: ${getStyledTextSegmentsTime}ms (${getStyledTextSegmentsCalls} calls, avg: ${ + getStyledTextSegmentsCalls > 0 + ? (getStyledTextSegmentsTime / getStyledTextSegmentsCalls).toFixed(2) + : 0 + }ms)`, + ); + console.log( + `[benchmark] processColorVariables: ${processColorVariablesTime}ms (${processColorVariablesCalls} calls, avg: ${ + processColorVariablesCalls > 0 + ? (processColorVariablesTime / processColorVariablesCalls).toFixed(2) + : 0 + }ms)`, ); - const colors = retrieveGenericSolidUIColors(framework); - const gradients = retrieveGenericGradients(framework); postConversionComplete({ code, diff --git a/packages/backend/src/common/color.ts b/packages/backend/src/common/color.ts index 79f07d94..3a620944 100644 --- a/packages/backend/src/common/color.ts +++ b/packages/backend/src/common/color.ts @@ -1,3 +1,7 @@ +import { GradientPaint } from "../api_types"; +import { numberToFixedString } from "./numToAutoFixed"; + +// ---- Color Format Conversion ---- export const rgbTo6hex = (color: RGB | RGBA): string => { const hex = ((color.r * 255) | (1 << 8)).toString(16).slice(1) + @@ -19,15 +23,63 @@ export const rgbTo8hex = (color: RGB, alpha: number): string => { return hex; }; +/** + * Converts RGB values to CSS hex or rgba format + * @param color The RGB color object + * @param alpha The opacity value + * @returns A CSS color string + */ +export const rgbToCssColor = (color: RGB | RGBA, alpha: number = 1): string => { + // Special cases for common colors + if (color.r === 1 && color.g === 1 && color.b === 1 && alpha === 1) { + return "white"; + } + + if (color.r === 0 && color.g === 0 && color.b === 0 && alpha === 1) { + return "black"; + } + + // Return hex when possible (no transparency) + if (alpha === 1) { + const r = Math.round(color.r * 255); + const g = Math.round(color.g * 255); + const b = Math.round(color.b * 255); + + const toHex = (num: number): string => num.toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); + } + + // Use rgba for transparent colors + const r = numberToFixedString(color.r * 255); + const g = numberToFixedString(color.g * 255); + const b = numberToFixedString(color.b * 255); + const a = numberToFixedString(alpha); + + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +// ---- Gradient Transformation ---- export const gradientAngle = (fill: GradientPaint): number => { - // Thanks Gleb and Liam for helping! - const decomposed = decomposeRelativeTransform( - fill.gradientTransform[0], - fill.gradientTransform[1], - ); + const [start, end] = fill.gradientHandlePositions; + return calculateAngle(start, end); +}; - return (decomposed.rotation * 180) / Math.PI; +/** + * Calculate the angle between two points in degrees + * @param start Starting point {x, y} in normalized coordinates (0-1) + * @param end Ending point {x, y} in normalized coordinates (0-1) + * @returns Angle in degrees (0-360) + */ +export const calculateAngle = ( + start: { x: number; y: number }, + end: { x: number; y: number }, +): number => { + const dx = end.x - start.x; + const dy = end.y - start.y; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); + return (angle + 360) % 360; // Normalize to 0-360 degrees }; + // from https://math.stackexchange.com/a/2888105 export const decomposeRelativeTransform = ( t1: [number, number, number], @@ -79,3 +131,34 @@ export const decomposeRelativeTransform = ( return result; }; + +// ---- Common color check helpers ---- + +/** + * Checks if color is black + */ +export const isBlack = (color: RGB, opacity: number = 1): boolean => + color.r === 0 && color.g === 0 && color.b === 0 && opacity === 1; + +/** + * Checks if color is white + */ +export const isWhite = (color: RGB, opacity: number = 1): boolean => + color.r === 1 && color.g === 1 && color.b === 1 && opacity === 1; + +/** + * Helper for calculating gradient stops in a consistent way across frameworks + */ +export const processGradientStops = ( + stops: ReadonlyArray, + opacity: number = 1, + colorFormatter: (color: RGB | RGBA, alpha: number) => string, +): string => { + return stops + .map((stop) => { + const color = colorFormatter(stop.color, stop.color.a * opacity); + const position = `${(stop.position * 100).toFixed(0)}%`; + return `${color} ${position}`; + }) + .join(", "); +}; diff --git a/packages/backend/src/common/commonChildrenOrder.ts b/packages/backend/src/common/commonChildrenOrder.ts deleted file mode 100644 index d7a93965..00000000 --- a/packages/backend/src/common/commonChildrenOrder.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const commonSortChildrenWhenInferredAutoLayout = ( - node: SceneNode & ChildrenMixin, - optimize: boolean, -) => { - if (node.children.length <= 1) { - return node.children; - } - - if ( - optimize && - "inferredAutoLayout" in node && - node.inferredAutoLayout !== null - ) { - const children = [...node.children]; - switch (node.inferredAutoLayout.layoutMode) { - case "HORIZONTAL": - return children.sort((a, b) => a.x - b.x); - // NONE is a bug from Figma. - case "NONE": - case "VERTICAL": - console.log( - "ordering", - children.map((c) => c.name), - children.sort((a, b) => a.y - b.y).map((c) => c.name), - ); - return children.sort((a, b) => a.y - b.y); - } - } - return node.children; -}; diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts index d157b7ac..3f858262 100644 --- a/packages/backend/src/common/commonFormatAttributes.ts +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -1,4 +1,4 @@ -import { stringToClassName as stringToClassName } from "./numToAutoFixed"; +import { lowercaseFirstLetter } from "./lowercaseFirstLetter"; export const getClassLabel = (isJSX: boolean = false) => isJSX ? "className" : "class"; @@ -18,7 +18,10 @@ export const formatStyleAttribute = ( }; export const formatDataAttribute = (label: string, value?: string) => - ` data-${label}${value === undefined ? `` : `="${value}"`}`; + ` 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[], diff --git a/packages/backend/src/common/commonPosition.ts b/packages/backend/src/common/commonPosition.ts index acdb9007..2f1df00f 100644 --- a/packages/backend/src/common/commonPosition.ts +++ b/packages/backend/src/common/commonPosition.ts @@ -1,100 +1,21 @@ -import { LayoutMode } from "types"; -import { parentCoordinates } from "./parentCoordinates"; - -export const commonPosition = ( - node: SceneNode & DimensionAndPositionMixin, -): LayoutMode => { - // if node is same size as height, position is not necessary - - // detect if Frame's width is same as Child when Frame has Padding. - // warning: this may return true even when false, if size is same, but position is different. However, it would be an unexpected layout. - let hPadding = 0; - let vPadding = 0; - if (node.parent && "layoutMode" in node.parent) { - hPadding = node.parent.paddingLeft + node.parent.paddingRight; - vPadding = node.parent.paddingTop + node.parent.paddingBottom; - } - - if ( - !node.parent || - !("width" in node.parent) || - (node.width === node.parent.width - hPadding && - node.height === node.parent.height - vPadding) - ) { - return ""; - } - - // position is absolute, parent is relative - // return "absolute inset-0 m-auto "; - - const [parentX, parentY] = parentCoordinates(node.parent); - - // if view is too small, anything will be detected; this is necessary to reduce the tolerance. - let threshold = 8; - if (node.width < 16 || node.height < 16) { - threshold = 1; - } - - // < 4 is a threshold. If === is used, there can be rounding errors (28.002 !== 28) - const centerX = - Math.abs(2 * (node.x - parentX) + node.width - node.parent.width) < - threshold; - const centerY = - Math.abs(2 * (node.y - parentY) + node.height - node.parent.height) < - threshold; - - const minX = node.x - parentX < threshold; - const minY = node.y - parentY < threshold; - - const maxX = node.parent.width - (node.x - parentX + node.width) < threshold; - const maxY = - node.parent.height - (node.y - parentY + node.height) < threshold; - - // this needs to be on top, because Tailwind is incompatible with Center, so this will give preference. - if (minX && minY) { - // x left, y top - return "TopStart"; - } else if (minX && maxY) { - // x left, y bottom - return "BottomStart"; - } else if (maxX && minY) { - // x right, y top - return "TopEnd"; - } else if (maxX && maxY) { - // x right, y bottom - return "BottomEnd"; - } - - if (centerX && centerY) { - return "Center"; - } - - if (centerX) { - if (minY) { - // x center, y top - return "TopCenter"; - } - if (maxY) { - // x center, y bottom - return "BottomCenter"; - } - } else if (centerY) { - if (minX) { - // x left, y center - return "CenterStart"; - } - if (maxX) { - // x right, y center - return "CenterEnd"; - } - } - - return "Absolute"; -}; +import { HTMLSettings, TailwindSettings } from "types"; export const getCommonPositionValue = ( node: SceneNode, + settings?: HTMLSettings | TailwindSettings, ): { x: number; y: number } => { + if (node.parent && node.parent.absoluteBoundingBox) { + if (settings?.embedVectors && node.svg) { + // When embedding vectors, we need to use the absolute position, since it already includes the rotation. + return { + x: node.absoluteBoundingBox.x - node.parent.absoluteBoundingBox.x, + y: node.absoluteBoundingBox.y - node.parent.absoluteBoundingBox.y, + }; + } + + return { x: node.x, y: node.y }; + } + if (node.parent && node.parent.type === "GROUP") { return { x: node.x - node.parent.x, @@ -108,36 +29,81 @@ export const getCommonPositionValue = ( }; }; -export const commonIsAbsolutePosition = ( - node: SceneNode, - optimizeLayout: boolean, -) => { - // No position when parent is inferred auto layout. - if ( - optimizeLayout && - node.parent && - "layoutMode" in node.parent && - node.parent.inferredAutoLayout !== null - ) { - return false; - } +interface BoundingBox { + width: number; // w_b + height: number; // h_b + x: number; // x_b + y: number; // y_b +} + +interface RectangleStyle { + width: number; // Original width (w) + height: number; // Original height (h) + left: number; // Final CSS left + top: number; // Final CSS top + rotation: number; // Rotation in degrees +} + +export function calculateRectangleFromBoundingBox( + boundingBox: BoundingBox, + figmaRotationDegrees: number, +): RectangleStyle { + const cssRotationDegrees = -figmaRotationDegrees; // Direct CSS mapping + const theta = (cssRotationDegrees * Math.PI) / 180; + const cosTheta = Math.cos(theta); + const sinTheta = Math.sin(theta); + const absCosTheta = Math.abs(cosTheta); + const absSinTheta = Math.abs(sinTheta); + + const { width: w_b, height: h_b, x: x_b, y: y_b } = boundingBox; + + // For top-left origin, bounding box depends on rotation direction + const denominator = absCosTheta * absCosTheta - absSinTheta * absSinTheta; + const h = (w_b * absSinTheta - h_b * absCosTheta) / -denominator; + const w = (w_b - h * absSinTheta) / absCosTheta; + + // Rotate corners to find bounding box offsets + const corners = [ + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: w, y: h }, + { x: 0, y: h }, + ]; + const rotatedCorners = corners.map(({ x, y }) => ({ + x: x * cosTheta + y * sinTheta, + y: -x * sinTheta + y * cosTheta, + })); + + const minX = Math.min(...rotatedCorners.map((c) => c.x)); + const minY = Math.min(...rotatedCorners.map((c) => c.y)); + + const left = x_b - minX; + const top = y_b - minY; - if ("layoutAlign" in node) { - if (!node.parent || node.parent === undefined) { - return false; - } + return { + width: parseFloat(w.toFixed(2)), + height: parseFloat(h.toFixed(2)), + left: parseFloat(left.toFixed(2)), + top: parseFloat(top.toFixed(2)), + rotation: cssRotationDegrees, + }; +} - const parentLayoutIsNone = - "layoutMode" in node.parent && node.parent.layoutMode === "NONE"; - const hasNoLayoutMode = !("layoutMode" in node.parent); +export const commonIsAbsolutePosition = (node: SceneNode) => { + if ("layoutPositioning" in node && node.layoutPositioning === "ABSOLUTE") { + return true; + } - if ( - node.layoutPositioning === "ABSOLUTE" || - parentLayoutIsNone || - hasNoLayoutMode - ) { - return true; - } + if (!node.parent || node.parent === undefined) { + return false; } + + if ( + ("layoutMode" in node.parent && node.parent.layoutMode === "NONE") || + !("layoutMode" in node.parent) + ) { + return true; + } + return false; }; diff --git a/packages/backend/src/common/commonRadius.ts b/packages/backend/src/common/commonRadius.ts index 90ece57a..5f8070eb 100644 --- a/packages/backend/src/common/commonRadius.ts +++ b/packages/backend/src/common/commonRadius.ts @@ -1,6 +1,25 @@ import { CornerRadius } from "types"; export const getCommonRadius = (node: SceneNode): CornerRadius => { + if ("rectangleCornerRadii" in node) { + const [topLeft, topRight, bottomRight, bottomLeft] = + node.rectangleCornerRadii as any; + if ( + topLeft === topRight && + topLeft === bottomRight && + topLeft === bottomLeft + ) { + return { all: topLeft }; + } + + return { + topLeft, + topRight, + bottomRight, + bottomLeft, + }; + } + if ( "cornerRadius" in node && node.cornerRadius !== figma.mixed && diff --git a/packages/backend/src/common/convertFontWeight.ts b/packages/backend/src/common/convertFontWeight.ts index 7306161a..3231dd51 100644 --- a/packages/backend/src/common/convertFontWeight.ts +++ b/packages/backend/src/common/convertFontWeight.ts @@ -3,7 +3,7 @@ import { FontWeightNumber } from "types"; // Convert generic named weights to numbers, which is the way tailwind understands export const convertFontWeight = (weight: string): FontWeightNumber | null => { // change extra-light to extralight - weight = weight.replace(" ", "").replace("-", "").toLowerCase(); + weight = weight.replaceAll(" ", "").replaceAll("-", "").toLowerCase(); switch (weight) { case "thin": return "100"; diff --git a/packages/backend/src/common/exportAsyncProxy.ts b/packages/backend/src/common/exportAsyncProxy.ts new file mode 100644 index 00000000..04af43ab --- /dev/null +++ b/packages/backend/src/common/exportAsyncProxy.ts @@ -0,0 +1,46 @@ +import { postConversionStart } from "../messaging"; + +let isRunning = false; + +/* + * This is a wrapper for exportAsync() This allows us to pass a message to the UI every time + * this rather costly operation gets run so that it can display a loading message. This avoids + * showing a loading message every time anything in the UI changes and only showing it when + * exportAsync() is called. + */ +export const exportAsyncProxy = async < + T extends string | Uint8Array = Uint8Array /* | Object */, +>( + node: SceneNode, + settings: ExportSettings | ExportSettingsSVGString /*| ExportSettingsREST*/, +): Promise => { + if (isRunning === false) { + isRunning = true; + postConversionStart(); + // force postMessage to run right now. + await new Promise((resolve) => setTimeout(resolve, 30)); + } + + const figmaNode = (await figma.getNodeByIdAsync(node.id)) as ExportMixin; + // console.log("getting figma id for", figmaNode); + + if (figmaNode.exportAsync === undefined) { + // console.log(node); + throw new TypeError( + "Something went wrong. This node doesn't have an exportAsync() function. Maybe check the type before calling this function.", + ); + } + + // The following is necessary for typescript to not lose its mind. + let result; + if (settings.format === "SVG_STRING") { + result = await figmaNode.exportAsync(settings as ExportSettingsSVGString); + // } else if (settings.format === "JSON_REST_V1") { + // result = await node.exportAsync(settings as ExportSettingsREST); + } else { + result = await figmaNode.exportAsync(settings as ExportSettings); + } + + isRunning = false; + return result as T; +}; diff --git a/packages/backend/src/common/images.ts b/packages/backend/src/common/images.ts new file mode 100644 index 00000000..89d510c1 --- /dev/null +++ b/packages/backend/src/common/images.ts @@ -0,0 +1,133 @@ +import { AltNode, ExportableNode } from "types"; +import { btoa } from "js-base64"; +import { addWarning } from "./commonConversionWarnings"; +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}`; +}; + +const fillIsImage = ({ type }: Paint) => type === "IMAGE"; + +export const getImageFills = (node: MinimalFillsMixin): ImagePaint[] => { + try { + return (node.fills as ImagePaint[]).filter(fillIsImage); + } catch (e) { + return []; + } +}; + +export const nodeHasImageFill = (node: MinimalFillsMixin): Boolean => + getImageFills(node).length > 0; + +export const nodeHasMultipleFills = (node: MinimalFillsMixin) => + node.fills instanceof Array && node.fills.length > 1; + +const imageBytesToBase64 = (bytes: Uint8Array): string => { + // Convert Uint8Array to binary string + const binaryString = bytes.reduce((data, byte) => { + return data + String.fromCharCode(byte); + }, ""); + + // Encode binary string to base64 + const b64 = btoa(binaryString); + + return `data:image/png;base64,${b64}`; +}; + +export const exportNodeAsBase64PNG = async ( + node: AltNode, + excludeChildren: boolean, +) => { + // Shorcut export if the node has already been converted. + if (node.base64 !== undefined && node.base64 !== "") { + return node.base64; + } + + const n: ExportableNode = node; + + const temporarilyHideChildren = + excludeChildren && "children" in n && n.children.length > 0; + const parent = n as ChildrenMixin; + const originalVisibility = new Map(); + + if (temporarilyHideChildren) { + // Store the original visible state of children + parent.children.map((child: SceneNode) => + originalVisibility.set(child, child.visible), + ), + // Temporarily hide all children + parent.children.forEach((child) => { + child.visible = false; + }); + } + + // export the image as bytes + const exportSettings: ExportSettingsImage = { + format: "PNG", + constraint: { type: "SCALE", value: 1 }, + }; + const bytes = await exportAsyncProxy(n, exportSettings); + + if (temporarilyHideChildren) { + // After export, restore visibility + parent.children.forEach((child) => { + child.visible = originalVisibility.get(child) ?? false; + }); + } + + addWarning("Some images exported as Base64 PNG"); + + // Encode binary string to base64 + const base64 = imageBytesToBase64(bytes); + // Save the value so it's only calculated once. + node.base64 = base64; + return base64; +}; diff --git a/packages/backend/src/common/lowercaseFirstLetter.ts b/packages/backend/src/common/lowercaseFirstLetter.ts new file mode 100644 index 00000000..b7215dc3 --- /dev/null +++ b/packages/backend/src/common/lowercaseFirstLetter.ts @@ -0,0 +1,7 @@ +export function lowercaseFirstLetter(str: string): string { + if (!str || str.length === 0) { + return str; + } + + return str.charAt(0).toLowerCase() + str.slice(1); +} diff --git a/packages/backend/src/common/nodeVisibility.ts b/packages/backend/src/common/nodeVisibility.ts index 8e3959d7..7e4eb6f4 100644 --- a/packages/backend/src/common/nodeVisibility.ts +++ b/packages/backend/src/common/nodeVisibility.ts @@ -1,4 +1,2 @@ -type VisibilityMixin = { visible: boolean }; -const isVisible = (node: VisibilityMixin) => node.visible; export const getVisibleNodes = (nodes: readonly SceneNode[]) => - nodes.filter(isVisible); + nodes.filter((d) => d.visible ?? true); diff --git a/packages/backend/src/common/nodeWidthHeight.ts b/packages/backend/src/common/nodeWidthHeight.ts index 5f2b583b..670a86b5 100644 --- a/packages/backend/src/common/nodeWidthHeight.ts +++ b/packages/backend/src/common/nodeWidthHeight.ts @@ -1,51 +1,23 @@ import { Size } from "types"; -export const nodeSize = (node: SceneNode, optimizeLayout: boolean): Size => { - const hasLayout = - "layoutAlign" in node && node.parent && "layoutMode" in node.parent; +export const nodeSize = (node: SceneNode): Size => { + if ("layoutSizingHorizontal" in node && "layoutSizingVertical" in node) { + const width = + node.layoutSizingHorizontal === "FILL" + ? "fill" + : node.layoutSizingHorizontal === "HUG" + ? null + : node.width; - if (!hasLayout) { - return { width: node.width, height: node.height }; - } - - const nodeAuto = - (optimizeLayout && "inferredAutoLayout" in node - ? node.inferredAutoLayout - : null) ?? node; + const height = + node.layoutSizingVertical === "FILL" + ? "fill" + : node.layoutSizingVertical === "HUG" + ? null + : node.height; - if ("layoutMode" in nodeAuto && nodeAuto.layoutMode === "NONE") { - return { width: node.width, height: node.height }; + return { width, height }; } - // const parentLayoutMode = node.parent.layoutMode; - const parentLayoutMode = optimizeLayout - ? node.parent.inferredAutoLayout?.layoutMode - : (null ?? node.parent.layoutMode); - - const isWidthFill = - (parentLayoutMode === "HORIZONTAL" && nodeAuto.layoutGrow === 1) || - (parentLayoutMode === "VERTICAL" && nodeAuto.layoutAlign === "STRETCH"); - const isHeightFill = - (parentLayoutMode === "HORIZONTAL" && nodeAuto.layoutAlign === "STRETCH") || - (parentLayoutMode === "VERTICAL" && nodeAuto.layoutGrow === 1); - const modesSwapped = parentLayoutMode === "HORIZONTAL"; - const primaryAxisMode = modesSwapped - ? "counterAxisSizingMode" - : "primaryAxisSizingMode"; - const counterAxisMode = modesSwapped - ? "primaryAxisSizingMode" - : "counterAxisSizingMode"; - - return { - width: isWidthFill - ? "fill" - : "layoutMode" in nodeAuto && nodeAuto[primaryAxisMode] === "AUTO" - ? null - : node.width, - height: isHeightFill - ? "fill" - : "layoutMode" in nodeAuto && nodeAuto[counterAxisMode] === "AUTO" - ? null - : node.height, - }; + return { width: node.width, height: node.height }; }; diff --git a/packages/backend/src/common/numToAutoFixed.ts b/packages/backend/src/common/numToAutoFixed.ts index adba91db..d18f2f71 100644 --- a/packages/backend/src/common/numToAutoFixed.ts +++ b/packages/backend/src/common/numToAutoFixed.ts @@ -1,10 +1,14 @@ import { indentStringFlutter } from "./indentString"; // this is necessary to avoid a height of 4.999999523162842. -export const sliceNum = (num: number): string => { +export const numberToFixedString = (num: number): string => { return num.toFixed(2).replace(/\.00$/, ""); }; +export const roundToNearestDecimal = (decimal: number) => (n: number) => + Math.round(n * 10 ** decimal) / 10 ** decimal; +export const roundToNearestHundreth = roundToNearestDecimal(2); + export const printPropertyIfNotDefault = ( propertyName: string, propertyValue: any, @@ -41,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)) { @@ -54,14 +57,14 @@ export const generateWidgetCode = ( return `${key}: [\n${indentStringFlutter(value.join(",\n"))},\n],`; } else { return `${key}: ${ - typeof value === "number" ? sliceNum(value) : value + typeof value === "number" ? numberToFixedString(value) : value },`; } }); const positionedValuesString = (positionedValues || []) .map((value) => { - return typeof value === "number" ? sliceNum(value) : value; + return typeof value === "number" ? numberToFixedString(value) : value; }) .join(", "); diff --git a/packages/backend/src/common/parseJSX.ts b/packages/backend/src/common/parseJSX.ts index dab7adab..5445679d 100644 --- a/packages/backend/src/common/parseJSX.ts +++ b/packages/backend/src/common/parseJSX.ts @@ -1,4 +1,5 @@ -import { sliceNum } from "./numToAutoFixed"; +import { encode } from "html-entities"; +import { numberToFixedString } from "./numToAutoFixed"; export const formatWithJSX = ( property: string, @@ -13,9 +14,9 @@ export const formatWithJSX = ( if (typeof value === "number") { if (isJsx) { - return `${jsx_property}: ${sliceNum(value)}`; + return `${jsx_property}: ${numberToFixedString(value)}`; } else { - return `${property}: ${sliceNum(value)}px`; + return `${property}: ${numberToFixedString(value)}px`; } } else if (isJsx) { return `${jsx_property}: '${value}'`; @@ -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/retrieveFill.ts b/packages/backend/src/common/retrieveFill.ts index 233c2edb..2fe1cd4a 100644 --- a/packages/backend/src/common/retrieveFill.ts +++ b/packages/backend/src/common/retrieveFill.ts @@ -1,12 +1,16 @@ +import { Paint } from "../api_types"; + /** * Retrieve the first visible color that is being used by the layer, in case there are more than one. */ export const retrieveTopFill = ( - fills: ReadonlyArray | PluginAPI["mixed"] | undefined, + fills: ReadonlyArray | undefined, ): Paint | undefined => { - if (fills && fills !== figma.mixed && fills.length > 0) { + if (fills && Array.isArray(fills) && fills.length > 0) { // on Figma, the top layer is always at the last position // reverse, then try to find the first layer that is visible, if any. return [...fills].reverse().find((d) => d.visible !== false); } + + return undefined; }; diff --git a/packages/backend/src/common/retrieveUI/convertToCode.ts b/packages/backend/src/common/retrieveUI/convertToCode.ts index a8298be3..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,8 +16,10 @@ 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); + return (await htmlMain(nodes, settings)).html; } }; diff --git a/packages/backend/src/common/retrieveUI/retrieveColors.ts b/packages/backend/src/common/retrieveUI/retrieveColors.ts index 579cf7d2..d071fe04 100644 --- a/packages/backend/src/common/retrieveUI/retrieveColors.ts +++ b/packages/backend/src/common/retrieveUI/retrieveColors.ts @@ -12,7 +12,7 @@ import { flutterGradient, } from "../../flutter/builderImpl/flutterColor"; import { - htmlColor, + htmlColorFromFill, htmlGradientFromFills, } from "../../html/builderImpl/htmlColor"; import { calculateContrastRatio } from "./commonUI"; @@ -21,31 +21,41 @@ import { SolidColorConversion, Framework, } from "types"; +import { processColorVariables } from "../../altNodes/jsonNodeConversion"; -export const retrieveGenericSolidUIColors = ( +export const retrieveGenericSolidUIColors = async ( framework: Framework, -): Array => { +): Promise> => { const selectionColors = figma.getSelectionColors(); if (!selectionColors || selectionColors.paints.length === 0) return []; const colors: Array = []; - selectionColors.paints.forEach((paint) => { - const fill = convertSolidColor(paint, framework); - if (fill) { - const exists = colors.find((col) => col.exportValue === fill.exportValue); - if (!exists) { - colors.push(fill); + + // Process all paints in parallel to handle variables + await Promise.all( + selectionColors.paints.map(async (d) => { + const paint = { ...d } as Paint; + await processColorVariables(paint as any); + + const fill = await convertSolidColor(paint, framework); + if (fill) { + const exists = colors.find( + (col) => col.exportValue === fill.exportValue, + ); + if (!exists) { + colors.push(fill); + } } - } - }); + }), + ); return colors.sort((a, b) => a.hex.localeCompare(b.hex)); }; -const convertSolidColor = ( +const convertSolidColor = async ( fill: Paint, framework: Framework, -): SolidColorConversion | null => { +): Promise => { const black = { r: 0, g: 0, b: 0 }; const white = { r: 1, g: 1, b: 1 }; @@ -63,9 +73,10 @@ const convertSolidColor = ( if (framework === "Flutter") { output.exportValue = flutterColor(fill.color, opacity); } else if (framework === "HTML") { - output.exportValue = htmlColor(fill.color, opacity); + output.exportValue = htmlColorFromFill(fill as any); } else if (framework === "Tailwind") { - Object.assign(output, tailwindColor(fill)); + // Pass true to use CSS variable syntax for variables + output.exportValue = tailwindColor(fill as any, true).exportValue; } else if (framework === "SwiftUI") { output.exportValue = swiftuiColor(fill.color, opacity); } @@ -73,35 +84,69 @@ const convertSolidColor = ( return output; }; -export const retrieveGenericLinearGradients = ( +export const retrieveGenericLinearGradients = async ( framework: Framework, -): Array => { +): Promise> => { const selectionColors = figma.getSelectionColors(); const colorStr: Array = []; - selectionColors?.paints.forEach((paint) => { - if (paint.type === "GRADIENT_LINEAR") { - let exportValue = ""; - switch (framework) { - case "Flutter": - exportValue = flutterGradient(paint); - break; - case "HTML": - exportValue = htmlGradientFromFills([paint]); - break; - case "Tailwind": - exportValue = tailwindGradient(paint); - break; - case "SwiftUI": - exportValue = swiftuiGradient(paint); - break; + if (!selectionColors || selectionColors.paints.length === 0) return []; + + // Process all gradient paints + await Promise.all( + selectionColors.paints.map(async (paint) => { + if (paint.type === "GRADIENT_LINEAR") { + let fill = { ...paint }; + const t = fill.gradientTransform; + fill.gradientHandlePositions = [ + { x: t[0][2], y: t[1][2] }, // Start: (e, f) + { x: t[0][0] + t[0][2], y: t[1][0] + t[1][2] }, // End: (a + e, b + f) + ]; + + // Process gradient stops for variables + if (fill.gradientStops) { + for (const stop of fill.gradientStops) { + if (stop.boundVariables?.color) { + try { + const variableId = stop.boundVariables.color.id; + const variable = figma.variables.getVariableById(variableId); + if (variable) { + (stop as any).variableColorName = variable.name + .replace(/\s+/g, "-") + .toLowerCase(); + } + } catch (e) { + console.error( + "Error retrieving variable for gradient stop:", + e, + ); + } + } + } + } + + let exportValue = ""; + switch (framework) { + case "Flutter": + exportValue = flutterGradient(fill); + break; + case "HTML": + exportValue = htmlGradientFromFills(fill); + break; + case "Tailwind": + exportValue = tailwindGradient(fill); + break; + case "SwiftUI": + exportValue = swiftuiGradient(fill); + break; + } + colorStr.push({ + cssPreview: htmlGradientFromFills(fill), + exportValue, + }); } - colorStr.push({ - cssPreview: htmlGradientFromFills([paint]), - exportValue, - }); - } - }); + }), + ); return colorStr; }; 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/flutterAutoLayout.ts b/packages/backend/src/flutter/builderImpl/flutterAutoLayout.ts index da72ae5e..8e17f79a 100644 --- a/packages/backend/src/flutter/builderImpl/flutterAutoLayout.ts +++ b/packages/backend/src/flutter/builderImpl/flutterAutoLayout.ts @@ -2,6 +2,7 @@ export const getMainAxisAlignment = ( node: InferredAutoLayoutResult, ): string => { switch (node.primaryAxisAlignItems) { + case undefined: case "MIN": return "MainAxisAlignment.start"; case "CENTER": @@ -17,6 +18,7 @@ export const getCrossAxisAlignment = ( node: InferredAutoLayoutResult, ): string => { switch (node.counterAxisAlignItems) { + case undefined: case "MIN": return "CrossAxisAlignment.start"; case "CENTER": @@ -28,6 +30,40 @@ export const getCrossAxisAlignment = ( } }; +export const getWrapAlignment = ( + node: InferredAutoLayoutResult, +): string => { + switch (node.primaryAxisAlignItems) { + case undefined: + case "MIN": + return "WrapAlignment.start"; + case "CENTER": + return "WrapAlignment.center"; + case "MAX": + return "WrapAlignment.end"; + case "SPACE_BETWEEN": + return "WrapAlignment.spaceBetween"; + } +}; + +export const getWrapRunAlignment = ( + node: InferredAutoLayoutResult, +): string => { + if (node.counterAxisAlignContent == "SPACE_BETWEEN") { + return "WrapAlignment.spaceBetween"; + } + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "WrapAlignment.start"; + case "CENTER": + case "BASELINE": + return "WrapAlignment.center"; + case "MAX": + return "WrapAlignment.end"; + } +}; + const getFlex = ( node: SceneNode, autoLayout: InferredAutoLayoutResult, diff --git a/packages/backend/src/flutter/builderImpl/flutterBlend.ts b/packages/backend/src/flutter/builderImpl/flutterBlend.ts index ce5892a6..faa8b7d1 100644 --- a/packages/backend/src/flutter/builderImpl/flutterBlend.ts +++ b/packages/backend/src/flutter/builderImpl/flutterBlend.ts @@ -1,4 +1,8 @@ -import { generateWidgetCode, sliceNum } from "../../common/numToAutoFixed"; +import { AltNode } from "../../alt_api_types"; +import { + generateWidgetCode, + numberToFixedString, +} from "../../common/numToAutoFixed"; /** * https://api.flutter.dev/flutter/widgets/Opacity-class.html @@ -9,7 +13,7 @@ export const flutterOpacity = ( ): string => { if (node.opacity !== undefined && node.opacity !== 1 && child !== "") { return generateWidgetCode("Opacity", { - opacity: sliceNum(node.opacity), + opacity: numberToFixedString(node.opacity), child: child, }); } @@ -34,20 +38,35 @@ export const flutterVisibility = (node: SceneNode, child: string): string => { /** * https://api.flutter.dev/flutter/widgets/Transform-class.html * that's how you convert angles to clockwise radians: angle * -pi/180 - * using 3.14159 as Pi for enough precision and to avoid importing math lib. */ -export const flutterRotation = (node: LayoutMixin, child: string): string => { +export const flutterRotation = (node: AltNode, child: string): string => { if ( node.rotation !== undefined && child !== "" && Math.round(node.rotation) !== 0 ) { - return generateWidgetCode("Transform", { - transform: `Matrix4.identity()..translate(0.0, 0.0)..rotateZ(${sliceNum( - node.rotation * (-3.14159 / 180), - )})`, - child: child, - }); + const matrix = generateRotationMatrix(node); + if (matrix) { + return generateWidgetCode("Transform", { + transform: matrix, + child: child, + }); + } } return child; }; + +/** + * Generates a rotation matrix string for Flutter transforms + */ +export const generateRotationMatrix = (node: AltNode): string => { + const rotation = (node.rotation || 0) + (node.cumulativeRotation || 0); + + if (Math.round(rotation) === 0) { + return ""; + } + + return `Matrix4.identity()..translate(0.0, 0.0)..rotateZ(${numberToFixedString( + rotation * (-Math.PI / 180), + )})`; +}; diff --git a/packages/backend/src/flutter/builderImpl/flutterBorder.ts b/packages/backend/src/flutter/builderImpl/flutterBorder.ts index 8073f4ec..f3e22b1d 100644 --- a/packages/backend/src/flutter/builderImpl/flutterBorder.ts +++ b/packages/backend/src/flutter/builderImpl/flutterBorder.ts @@ -17,7 +17,7 @@ export const flutterBorder = (node: SceneNode): string => { } const color = skipDefaultProperty( - flutterColorFromFills(node.strokes), + flutterColorFromFills(node, "strokes"), "Colors.black", ); diff --git a/packages/backend/src/flutter/builderImpl/flutterColor.ts b/packages/backend/src/flutter/builderImpl/flutterColor.ts index bbc4c4f3..a4d12377 100644 --- a/packages/backend/src/flutter/builderImpl/flutterColor.ts +++ b/packages/backend/src/flutter/builderImpl/flutterColor.ts @@ -1,19 +1,44 @@ -import { rgbTo8hex, gradientAngle } from "../../common/color"; +import { StarNode } from "./../../api_types"; +import { rgbTo8hex } from "../../common/color"; import { addWarning } from "../../common/commonConversionWarnings"; -import { generateWidgetCode, sliceNum } from "../../common/numToAutoFixed"; +import { + generateWidgetCode, + numberToFixedString, +} from "../../common/numToAutoFixed"; import { retrieveTopFill } from "../../common/retrieveFill"; -import { nearestValue } from "../../tailwind/conversionTables"; +import { getPlaceholderImage } from "../../common/images"; +import { GradientPaint, ImagePaint, Paint } from "../../api_types"; /** * Retrieve the SOLID color for Flutter when existent, otherwise "" + * @param node SceneNode containing the property to examine + * @param propertyPath Property path to extract fills from (e.g., 'fills', 'strokes') or direct fills array */ export const flutterColorFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"], + node: SceneNode, + propertyPath: string, +): string => { + let fills: ReadonlyArray = node[ + propertyPath as keyof SceneNode + ] as ReadonlyArray; + return flutterColorFromDirectFills(fills); +}; + +/** + * Retrieve the SOLID color for Flutter directly from fills when existent, otherwise "" + * @param fills The fills array to process + */ +export const flutterColorFromDirectFills = ( + fills: ReadonlyArray, ): string => { const fill = retrieveTopFill(fills); if (fill && fill.type === "SOLID") { - return flutterColor(fill.color, fill.opacity ?? 1.0); + return flutterColor( + fill.color, + fill.opacity ?? 1.0, + (fill as any).variableColorName, + ); } else if ( fill && (fill.type === "GRADIENT_LINEAR" || @@ -21,22 +46,35 @@ export const flutterColorFromFills = ( fill.type === "GRADIENT_RADIAL") ) { if (fill.gradientStops.length > 0) { - return flutterColor(fill.gradientStops[0].color, fill.opacity ?? 1.0); + const stop = fill.gradientStops[0]; + return flutterColor( + stop.color, + fill.opacity ?? 1.0, + (stop as any).variableColorName, + ); } } return ""; }; +/** + * Get box decoration properties for a Flutter node + */ export const flutterBoxDecorationColor = ( node: SceneNode, - fills: ReadonlyArray | PluginAPI["mixed"], + propertyPath: string, ): Record => { + let fills: ReadonlyArray; + fills = node[propertyPath as keyof SceneNode] as ReadonlyArray; + const fill = retrieveTopFill(fills); if (fill && fill.type === "SOLID") { const opacity = fill.opacity ?? 1.0; - return { color: flutterColor(fill.color, opacity) }; + return { + color: flutterColor(fill.color, opacity, (fill as any).variableColorName), + }; } else if ( fill?.type === "GRADIENT_LINEAR" || fill?.type === "GRADIENT_RADIAL" || @@ -53,9 +91,7 @@ export const flutterBoxDecorationColor = ( export const flutterDecorationImage = (node: SceneNode, fill: ImagePaint) => { addWarning("Image fills are replaced with placeholders"); return generateWidgetCode("DecorationImage", { - image: `NetworkImage("https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}")`, + image: `NetworkImage("${getPlaceholderImage(node.width, node.height)}")`, fit: fitToBoxFit(fill), }); }; @@ -63,13 +99,13 @@ export const flutterDecorationImage = (node: SceneNode, fill: ImagePaint) => { const fitToBoxFit = (fill: ImagePaint): string => { switch (fill.scaleMode) { case "FILL": - return "BoxFit.fill"; + return "BoxFit.cover"; // FILL in Figma covers the entire area, similar to BoxFit.cover case "FIT": - return "BoxFit.contain"; - case "CROP": - return "BoxFit.cover"; + return "BoxFit.contain"; // FIT in Figma fits the image while maintaining aspect ratio, like BoxFit.contain + case "STRETCH": + return "BoxFit.fill"; // STRETCH in Figma stretches the image, like BoxFit.fill case "TILE": - return "BoxFit.none"; + return "BoxFit.none"; // TILE doesn't have a direct equivalent, but BoxFit.none is closest default: return "BoxFit.cover"; } @@ -89,101 +125,126 @@ export const flutterGradient = (fill: GradientPaint): string => { } }; -const gradientDirection = (angle: number): string => { - const radians = (angle * Math.PI) / 180; - const x = Math.cos(radians).toFixed(2); - const y = Math.sin(radians).toFixed(2); - return `begin: Alignment(${x}, ${y}), end: Alignment(${-x}, ${-y})`; +/** + * Generate a Flutter LinearGradient widget + * @param fill The linear gradient fill + * @returns LinearGradient widget code + */ +const flutterLinearGradient = (fill: GradientPaint): string => { + const [start, end] = fill.gradientHandlePositions; + const colors = fill.gradientStops + .map((d) => flutterColor(d.color, d.color.a, (d as any).variableColorName)) + .join(", "); + return generateWidgetCode("LinearGradient", { + begin: `Alignment(${start.x.toFixed(2)}, ${start.y.toFixed(2)})`, + end: `Alignment(${end.x.toFixed(2)}, ${end.y.toFixed(2)})`, + colors: `[${colors}]`, + }); }; +/** + * Generate a Flutter RadialGradient widget + * @param fill The radial gradient fill + * @returns RadialGradient widget code + */ const flutterRadialGradient = (fill: GradientPaint): string => { + const [center, h1, h2] = (fill as any).gradientHandlePositions; + const radius1 = Math.sqrt((h1.x - center.x) ** 2 + (h1.y - center.y) ** 2); + const radius2 = Math.sqrt((h2.x - center.x) ** 2 + (h2.y - center.y) ** 2); + const radius = Math.max(radius1, radius2); const colors = fill.gradientStops - .map((d) => flutterColor(d.color, d.color.a)) + .map((d) => flutterColor(d.color, d.color.a, (d as any).variableColorName)) .join(", "); - - const x = sliceNum(fill.gradientTransform[0][2]); - const y = sliceNum(fill.gradientTransform[1][2]); - const scaleX = fill.gradientTransform[0][0]; - const scaleY = fill.gradientTransform[1][1]; - const r = sliceNum(Math.sqrt(scaleX * scaleX + scaleY * scaleY)); - return generateWidgetCode("RadialGradient", { - center: `Alignment(${x}, ${y})`, - radius: r, + center: `Alignment(${center.x.toFixed(2)}, ${center.y.toFixed(2)})`, + radius: radius.toFixed(2), colors: `[${colors}]`, }); }; -const flutterAngularGradient = (fill: GradientPaint): string => { - const colors = fill.gradientStops - .map((d) => flutterColor(d.color, d.color.a)) - .join(", "); +/** + * Convert Figma's normalized coordinates (0 to 1) to Flutter's Alignment (-1 to 1) + * @param x Figma's x coordinate (0 to 1) + * @param y Figma's y coordinate (0 to 1) + * @returns Flutter's Alignment string + */ +const figmaToFlutterAlignment = (x: number, y: number): string => { + const alignmentX = x * 2 - 1; + const alignmentY = y * 2 - 1; + return `Alignment(${numberToFixedString(alignmentX)}, ${numberToFixedString(alignmentY)})`; +}; - const x = sliceNum(fill.gradientTransform[0][2]); - const y = sliceNum(fill.gradientTransform[1][2]); - const startAngle = sliceNum(-fill.gradientTransform[0][0]); - const endAngle = sliceNum(-fill.gradientTransform[0][1]); +/** + * Generate a Flutter SweepGradient widget (for angular gradients) + * @param fill The angular gradient fill + * @returns SweepGradient widget code + */ +export const flutterAngularGradient = (fill: GradientPaint): string => { + // TODO This function is not 100% perfect but gets close. It is hard to get AngularGradient in Flutter. + const [center, _, startDirection] = fill.gradientHandlePositions; - return generateWidgetCode("SweepGradient", { - center: `Alignment(${x}, ${y})`, - startAngle: startAngle, - endAngle: endAngle, - colors: `[${colors}]`, - }); -}; + // Center alignment + const centerAlignment = figmaToFlutterAlignment(center.x, center.y); -const flutterLinearGradient = (fill: GradientPaint): string => { - const radians = (-gradientAngle(fill) * Math.PI) / 180; - const x = Math.cos(radians).toFixed(2); - const y = Math.sin(radians).toFixed(2); + // Starting angle + const dx = startDirection.x - center.x; + const dy = startDirection.y - center.y; + const startAngle = -(90 * Math.PI) / 180 + Math.atan2(dy, dx); + // Generate colors and stops const colors = fill.gradientStops - .map((d) => flutterColor(d.color, d.color.a)) + .map((stop) => flutterColor(stop.color, stop.color.a)) .join(", "); - return generateWidgetCode("LinearGradient", { - begin: `Alignment(${x}, ${y})`, - end: `Alignment(${-x}, ${-y})`, + const stops = fill.gradientStops + .map((stop) => numberToFixedString(stop.position)) + .join(", "); + + // Generate SweepGradient code + return generateWidgetCode("SweepGradient", { + center: centerAlignment, + startAngle: numberToFixedString(startAngle), + endAngle: numberToFixedString(startAngle + 2 * Math.PI), colors: `[${colors}]`, + stops: `[${stops}]`, + transform: `GradientRotation(${numberToFixedString(startAngle)})`, }); }; -const gradientDirectionReadable = (angle: number): string => { - switch (nearestValue(angle, [-180, -135, -90, -45, 0, 45, 90, 135, 180])) { - case 0: - return "begin: Alignment.centerLeft, end: Alignment.centerRight"; - case 45: - return "begin: Alignment.topLeft, end: Alignment.bottomRight"; - case 90: - return "begin: Alignment.topCenter, end: Alignment.bottomCenter"; - case 135: - return "begin: Alignment.topRight, end: Alignment.bottomLeft"; - case -45: - return "begin: Alignment.bottomLeft, end: Alignment.topRight"; - case -90: - return "begin: Alignment.bottomCenter, end: Alignment.topCenter"; - case -135: - return "begin: Alignment.bottomRight, end: Alignment.topLeft"; - default: - // 180 and -180 - return "begin: Alignment.centerRight, end: Alignment.centerLeft"; - } +/** + * Convert opacity (0-1) to alpha (0-255) + */ +const opacityToAlpha = (opacity: number) => { + return numberToFixedString(opacity); }; -export const flutterColor = (color: RGB, opacity: number): string => { +export const flutterColor = ( + color: RGB, + opacity: number, + variableColorName?: string, +): string => { const sum = color.r + color.g + color.b; + let colorCode = ""; if (sum === 0) { - return opacity === 1 - ? "Colors.black" - : `Colors.black.withOpacity(${opacity})`; + colorCode = + opacity === 1 + ? "Colors.black" + : `Colors.black.withValues(alpha: ${opacityToAlpha(opacity)})`; + } else if (sum === 3) { + colorCode = + opacity === 1 + ? "Colors.white" + : `Colors.white.withValues(alpha: ${opacityToAlpha(opacity)})`; + } else { + // Always use full 8-digit hex which includes alpha channel + colorCode = `const Color(0x${rgbTo8hex(color, opacity).toUpperCase()})`; } - if (sum === 3) { - return opacity === 1 - ? "Colors.white" - : `Colors.white.withOpacity(${opacity})`; + // Add variable name as a comment if it exists + if (variableColorName) { + return `${colorCode} /* ${variableColorName} */`; } - return `Color(0x${rgbTo8hex(color, opacity).toUpperCase()})`; + return colorCode; }; diff --git a/packages/backend/src/flutter/builderImpl/flutterPadding.ts b/packages/backend/src/flutter/builderImpl/flutterPadding.ts index d48db14f..b440d83e 100644 --- a/packages/backend/src/flutter/builderImpl/flutterPadding.ts +++ b/packages/backend/src/flutter/builderImpl/flutterPadding.ts @@ -1,7 +1,7 @@ import { generateWidgetCode, skipDefaultProperty, - sliceNum, + numberToFixedString, } from "../../common/numToAutoFixed"; import { commonPadding } from "../../common/commonPadding"; @@ -18,22 +18,25 @@ export const flutterPadding = (node: InferredAutoLayoutResult): string => { if ("all" in padding) { return skipDefaultProperty( - `const EdgeInsets.all(${sliceNum(padding.all)})`, + `const EdgeInsets.all(${numberToFixedString(padding.all)})`, "const EdgeInsets.all(0)", ); } if ("horizontal" in padding) { return generateWidgetCode("const EdgeInsets.symmetric", { - horizontal: skipDefaultProperty(sliceNum(padding.horizontal), "0"), - vertical: skipDefaultProperty(sliceNum(padding.vertical), "0"), + horizontal: skipDefaultProperty( + numberToFixedString(padding.horizontal), + "0", + ), + vertical: skipDefaultProperty(numberToFixedString(padding.vertical), "0"), }); } return generateWidgetCode("const EdgeInsets.only", { - top: skipDefaultProperty(sliceNum(padding.top), "0"), - left: skipDefaultProperty(sliceNum(padding.left), "0"), - right: skipDefaultProperty(sliceNum(padding.right), "0"), - bottom: skipDefaultProperty(sliceNum(padding.bottom), "0"), + top: skipDefaultProperty(numberToFixedString(padding.top), "0"), + left: skipDefaultProperty(numberToFixedString(padding.left), "0"), + right: skipDefaultProperty(numberToFixedString(padding.right), "0"), + bottom: skipDefaultProperty(numberToFixedString(padding.bottom), "0"), }); }; diff --git a/packages/backend/src/flutter/builderImpl/flutterShadow.ts b/packages/backend/src/flutter/builderImpl/flutterShadow.ts index c1f99a1f..04dc8824 100644 --- a/packages/backend/src/flutter/builderImpl/flutterShadow.ts +++ b/packages/backend/src/flutter/builderImpl/flutterShadow.ts @@ -1,5 +1,8 @@ import { rgbTo8hex } from "../../common/color"; -import { generateWidgetCode, sliceNum } from "../../common/numToAutoFixed"; +import { + generateWidgetCode, + numberToFixedString, +} from "../../common/numToAutoFixed"; import { indentStringFlutter } from "../../common/indentString"; // TODO Document it can't do flutter shadows. @@ -19,11 +22,13 @@ export const flutterShadow = (node: SceneNode): string => { effect.color, effect.color.a, ).toUpperCase()})`, - blurRadius: sliceNum(effect.radius), - offset: `Offset(${sliceNum(effect.offset.x)}, ${sliceNum( + blurRadius: numberToFixedString(effect.radius), + offset: `Offset(${numberToFixedString(effect.offset.x)}, ${numberToFixedString( effect.offset.y, )})`, - spreadRadius: effect.spread ? sliceNum(effect.spread) : "0", + spreadRadius: effect.spread + ? numberToFixedString(effect.spread) + : "0", }); } }); diff --git a/packages/backend/src/flutter/builderImpl/flutterSize.ts b/packages/backend/src/flutter/builderImpl/flutterSize.ts index 685bf685..ccd4f2bd 100644 --- a/packages/backend/src/flutter/builderImpl/flutterSize.ts +++ b/packages/backend/src/flutter/builderImpl/flutterSize.ts @@ -1,29 +1,30 @@ import { nodeSize } from "../../common/nodeWidthHeight"; -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; // Used in tests. export const flutterSizeWH = (node: SceneNode): string => { - const fSize = flutterSize(node, false); + const fSize = flutterSize(node); const size = fSize.width + fSize.height; return size; }; export const flutterSize = ( node: SceneNode, - optimizeLayout: boolean, -): { width: string; height: string; isExpanded: boolean } => { - const size = nodeSize(node, optimizeLayout); +): { + width: string; + height: string; + isExpanded: boolean; + constraints: Record; +} => { + const size = nodeSize(node); let isExpanded: boolean = false; - const nodeParent = - (node.parent && optimizeLayout && "inferredAutoLayout" in node.parent - ? node.parent.inferredAutoLayout - : null) ?? node.parent; + const nodeParent = node.parent; // this cast will always be true, since nodeWidthHeight was called with false to relative. let propWidth = ""; if (typeof size.width === "number") { - propWidth = sliceNum(size.width); + propWidth = numberToFixedString(size.width); } else if (size.width === "fill") { // When parent is a Row, child must be Expanded. if ( @@ -39,7 +40,7 @@ export const flutterSize = ( let propHeight = ""; if (typeof size.height === "number") { - propHeight = sliceNum(size.height); + propHeight = numberToFixedString(size.height); } else if (size.height === "fill") { // When parent is a Column, child must be Expanded. if ( @@ -53,5 +54,24 @@ export const flutterSize = ( } } - return { width: propWidth, height: propHeight, isExpanded }; + // Handle min/max constraints + const constraints: Record = {}; + + if (node.minWidth !== undefined && node.minWidth !== null) { + constraints.minWidth = numberToFixedString(node.minWidth); + } + + if (node.maxWidth !== undefined && node.maxWidth !== null) { + constraints.maxWidth = numberToFixedString(node.maxWidth); + } + + if (node.minHeight !== undefined && node.minHeight !== null) { + constraints.minHeight = numberToFixedString(node.minHeight); + } + + if (node.maxHeight !== undefined && node.maxHeight !== null) { + constraints.maxHeight = numberToFixedString(node.maxHeight); + } + + return { width: propWidth, height: propHeight, isExpanded, constraints }; }; diff --git a/packages/backend/src/flutter/flutterContainer.ts b/packages/backend/src/flutter/flutterContainer.ts index 7e65e5f7..65ee270b 100644 --- a/packages/backend/src/flutter/flutterContainer.ts +++ b/packages/backend/src/flutter/flutterContainer.ts @@ -10,15 +10,12 @@ import { generateWidgetCode, skipDefaultProperty, } from "../common/numToAutoFixed"; -import { sliceNum } from "../common/numToAutoFixed"; +import { numberToFixedString } from "../common/numToAutoFixed"; import { getCommonRadius } from "../common/commonRadius"; import { commonStroke } from "../common/commonStroke"; +import { generateRotationMatrix } from "./builderImpl/flutterBlend"; -export const flutterContainer = ( - node: SceneNode, - child: string, - optimizeLayout: boolean, -): string => { +export const flutterContainer = (node: SceneNode, child: string): string => { // ignore the view when size is zero or less // while technically it shouldn't get less than 0, due to rounding errors, // it can get to values like: -0.000004196293048153166 @@ -28,7 +25,7 @@ export const flutterContainer = ( // ignore for Groups const propBoxDecoration = getDecoration(node); - const { width, height, isExpanded } = flutterSize(node, optimizeLayout); + const { width, height, isExpanded, constraints } = flutterSize(node); const clipBehavior = "clipsContent" in node && node.clipsContent === true @@ -40,25 +37,43 @@ export const flutterContainer = ( // [propPadding] will be "padding: const EdgeInsets.symmetric(...)" or "" let propPadding = ""; if ("paddingLeft" in node) { - propPadding = flutterPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node, - ); + propPadding = flutterPadding(node); } let result: string; + const hasConstraints = constraints && Object.keys(constraints).length > 0; + + const properties: Record = {}; + + // If node has rotation, get the matrix for the transform property + if ("rotation" in node) { + const matrix = generateRotationMatrix(node); + if (matrix) { + properties.transform = matrix; + } + } + if (width || height || propBoxDecoration || clipBehavior) { + properties.width = skipDefaultProperty(width, "0"); + properties.height = skipDefaultProperty(height, "0"); + properties.padding = propPadding; + properties.clipBehavior = clipBehavior; + const parsedDecoration = skipDefaultProperty( propBoxDecoration, "BoxDecoration()", ); - result = generateWidgetCode("Container", { - width: skipDefaultProperty(width, "0"), - height: skipDefaultProperty(height, "0"), - padding: propPadding, - clipBehavior: clipBehavior, - decoration: clipBehavior ? propBoxDecoration : parsedDecoration, - child: child, - }); + properties.decoration = clipBehavior ? propBoxDecoration : parsedDecoration; + + const isEmptyProps = hasEmptyProps(properties); + if (isEmptyProps) { + result = child; + } else { + properties.child = child; + result = generateWidgetCode("Container", { + ...properties, + }); + } } else if (propPadding) { // if there is just a padding, add Padding result = generateWidgetCode("Padding", { @@ -69,6 +84,14 @@ export const flutterContainer = ( result = child; } + // Apply constraints if any exist + if (hasConstraints) { + result = generateWidgetCode("ConstrainedBox", { + constraints: generateWidgetCode("BoxConstraints", constraints), + child: result, + }); + } + // Add Expanded() when parent is a Row/Column and width is full. if (isExpanded) { result = generateWidgetCode("Expanded", { @@ -79,13 +102,23 @@ export const flutterContainer = ( return result; }; +const hasEmptyProps = (props: Record): boolean => { + let isEmpty = true; + for (const key in props) { + const value = props[key]; + const defValue = value.length > 0 ? "0" : ""; + isEmpty = isEmpty && skipDefaultProperty(value, defValue).length == 0; + } + return isEmpty; +} + const getDecoration = (node: SceneNode): string => { if (!("fills" in node)) { return ""; } const propBoxShadow = flutterShadow(node); - const decorationBackground = flutterBoxDecorationColor(node, node.fills); + const decorationBackground = flutterBoxDecorationColor(node, "fills"); let shapeDecorationBorder = ""; if (node.type === "STAR") { @@ -139,7 +172,7 @@ const generateBorderSideCode = ( "BorderSide.strokeAlignInside", ), color: skipDefaultProperty( - flutterColorFromFills(node.strokes), + flutterColorFromFills(node, "strokes"), "Colors.black", ), }), @@ -180,12 +213,12 @@ const generateStarBorder = (node: StarNode): string => { return generateWidgetCode("StarBorder", { side: generateBorderSideCode(node), - points: sliceNum(points), - innerRadiusRatio: sliceNum(innerRadiusRatio), - pointRounding: sliceNum(pointRounding), - valleyRounding: sliceNum(valleyRounding), - rotation: sliceNum(rotation), - squash: sliceNum(squash), + points: numberToFixedString(points), + innerRadiusRatio: numberToFixedString(innerRadiusRatio), + pointRounding: numberToFixedString(pointRounding), + valleyRounding: numberToFixedString(valleyRounding), + rotation: numberToFixedString(rotation), + squash: numberToFixedString(squash), }); }; @@ -219,7 +252,7 @@ const generatePolygonBorder = (node: PolygonNode): string => { return generateWidgetCode("StarBorder.polygon", { side: generateBorderSideCode(node), - sides: sliceNum(points), + sides: numberToFixedString(points), borderRadius: generateBorderRadius(node), }); }; @@ -230,24 +263,24 @@ const generateBorderRadius = (node: SceneNode): string => { if (radius.all === 0) { return ""; } - return `BorderRadius.circular(${sliceNum(radius.all)})`; + return `BorderRadius.circular(${numberToFixedString(radius.all)})`; } return generateWidgetCode("BorderRadius.only", { topLeft: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.topLeft)})`, + `Radius.circular(${numberToFixedString(radius.topLeft)})`, "Radius.circular(0)", ), topRight: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.topRight)})`, + `Radius.circular(${numberToFixedString(radius.topRight)})`, "Radius.circular(0)", ), bottomLeft: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.bottomLeft)})`, + `Radius.circular(${numberToFixedString(radius.bottomLeft)})`, "Radius.circular(0)", ), bottomRight: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.bottomRight)})`, + `Radius.circular(${numberToFixedString(radius.bottomRight)})`, "Radius.circular(0)", ), }); diff --git a/packages/backend/src/flutter/flutterDefaultBuilder.ts b/packages/backend/src/flutter/flutterDefaultBuilder.ts index 2cc9f52d..4445172e 100644 --- a/packages/backend/src/flutter/flutterDefaultBuilder.ts +++ b/packages/backend/src/flutter/flutterDefaultBuilder.ts @@ -13,27 +13,35 @@ import { generateWidgetCode } from "../common/numToAutoFixed"; export class FlutterDefaultBuilder { child: string; + rotationApplied: boolean = false; constructor(optChild: string) { this.child = optChild; } - createContainer(node: SceneNode, optimizeLayout: boolean): this { - this.child = flutterContainer(node, this.child, optimizeLayout); + createContainer(node: SceneNode): this { + this.child = flutterContainer(node, this.child); + this.rotationApplied = true; + return this; } blendAttr(node: SceneNode): this { - if ("layoutAlign" in node && "opacity" in node && "visible" in node) { - this.child = flutterVisibility(node, this.child); + // Only apply rotation via Transform if it wasn't already handled in the container + if ("rotation" in node && !this.rotationApplied) { this.child = flutterRotation(node, this.child); + } + + if ("visible" in node) { + this.child = flutterVisibility(node, this.child); + } else if ("opacity" in node) { this.child = flutterOpacity(node, this.child); } return this; } - position(node: SceneNode, optimizeLayout: boolean): this { - if (commonIsAbsolutePosition(node, optimizeLayout)) { + position(node: SceneNode): this { + if (commonIsAbsolutePosition(node)) { const { x, y } = getCommonPositionValue(node); this.child = generateWidgetCode("Positioned", { left: x, diff --git a/packages/backend/src/flutter/flutterMain.ts b/packages/backend/src/flutter/flutterMain.ts index 0abee72f..26a472bd 100644 --- a/packages/backend/src/flutter/flutterMain.ts +++ b/packages/backend/src/flutter/flutterMain.ts @@ -10,10 +10,12 @@ import { indentString } from "../common/indentString"; import { getCrossAxisAlignment, getMainAxisAlignment, + getWrapAlignment, + getWrapRunAlignment, } from "./builderImpl/flutterAutoLayout"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; import { PluginSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; +import { getVisibleNodes } from "../common/nodeVisibility"; let localSettings: PluginSettings; let previousExecutionCache: string[]; @@ -71,10 +73,14 @@ export const flutterMain = ( case "snippet": return result; case "stateless": - result = generateWidgetCode("Column", { children: [result] }); + if (!result.startsWith("Column")) { + result = generateWidgetCode("Column", { children: [result] }); + } return getStatelessTemplate(stringToClassName(sceneNode[0].name), result); case "fullApp": - result = generateWidgetCode("Column", { children: [result] }); + if (!result.startsWith("Column")) { + result = generateWidgetCode("Column", { children: [result] }); + } return getFullAppTemplate(stringToClassName(sceneNode[0].name), result); } @@ -87,11 +93,10 @@ const flutterWidgetGenerator = ( let comp: string[] = []; // filter non visible nodes. This is necessary at this step because conversion already happened. - const visibleSceneNode = sceneNode.filter((d) => d.visible); - const sceneLen = visibleSceneNode.length; + const visibleSceneNode = getVisibleNodes(sceneNode); - visibleSceneNode.forEach((node, index) => { - switch (node.type) { + visibleSceneNode.forEach((node) => { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": case "STAR": @@ -106,6 +111,7 @@ const flutterWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(flutterFrame(node)); break; case "SECTION": @@ -117,16 +123,10 @@ const flutterWidgetGenerator = ( case "VECTOR": addWarning("VectorNodes are not supported in Flutter"); break; + case "SLICE": default: // do nothing } - - if (index !== sceneLen - 1) { - const spacing = addSpacingIfNeeded(node, localSettings.optimizeLayout); - if (spacing) { - comp.push(spacing); - } - } }); return comp.join(",\n"); @@ -145,12 +145,8 @@ const flutterGroup = (node: GroupNode): string => { const flutterContainer = (node: SceneNode, child: string): string => { let propChild = ""; - let image = ""; if ("fills" in node && retrieveTopFill(node.fills)?.type === "IMAGE") { addWarning("Image fills are replaced with placeholders"); - image = `Image.network("https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}")`; } if (child.length > 0) { @@ -158,9 +154,9 @@ const flutterContainer = (node: SceneNode, child: string): string => { } const builder = new FlutterDefaultBuilder(propChild) - .createContainer(node, localSettings.optimizeLayout) + .createContainer(node) .blendAttr(node) - .position(node, localSettings.optimizeLayout); + .position(node); return builder.child; }; @@ -169,35 +165,53 @@ const flutterText = (node: TextNode): string => { const builder = new FlutterTextBuilder().createText(node); previousExecutionCache.push(builder.child); - return builder - .blendAttr(node) - .textAutoSize(node) - .position(node, localSettings.optimizeLayout).child; + return builder.blendAttr(node).textAutoSize(node).position(node).child; }; const flutterFrame = ( node: SceneNode & BaseFrameMixin & MinimalBlendMixin, ): string => { - const children = flutterWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout( - node, - localSettings.optimizeLayout, - ), + // Check if any direct children need absolute positioning + const hasAbsoluteChildren = node.children.some( + (child: any) => (child as any).layoutPositioning === "ABSOLUTE", ); + // Add warning if we need to use Stack due to absolute positioning + if (hasAbsoluteChildren && node.layoutMode !== "NONE") { + addWarning( + `Frame "${node.name}" has absolute positioned children. Using Stack instead of ${ + node.layoutMode === "HORIZONTAL" ? "Row" : "Column" + }.`, + ); + } + + // Generate widget code for children + const children = flutterWidgetGenerator(node.children); + + // Force Stack for any frame that has absolute positioned children + if (hasAbsoluteChildren) { + return flutterContainer( + node, + generateWidgetCode("Stack", { + children: children !== "" ? [children] : [], + }), + ); + } + if (node.layoutMode !== "NONE") { - const rowColumn = makeRowColumn(node, children); - return flutterContainer(node, rowColumn); + const rowColumnWrap = makeRowColumnWrap(node, children); + return flutterContainer(node, rowColumnWrap); } else { - if (localSettings.optimizeLayout && node.inferredAutoLayout) { - const rowColumn = makeRowColumn(node.inferredAutoLayout, children); - return flutterContainer(node, rowColumn); + if (node.inferredAutoLayout) { + const rowColumnWrap = makeRowColumnWrap(node.inferredAutoLayout, children); + return flutterContainer(node, rowColumnWrap); } if (node.isAsset) { return flutterContainer(node, generateWidgetCode("FlutterLogo", {})); } + // Default to Stack for frames without any layout return flutterContainer( node, generateWidgetCode("Stack", { @@ -207,53 +221,45 @@ const flutterFrame = ( } }; -const makeRowColumn = ( +const makeRowColumnWrap = ( autoLayout: InferredAutoLayoutResult, children: string, ): string => { - const rowOrColumn = autoLayout.layoutMode === "HORIZONTAL" ? "Row" : "Column"; + const rowOrColumn = autoLayout.layoutWrap == "WRAP" && autoLayout.primaryAxisSizingMode == "FIXED" ? + "Wrap" : autoLayout.layoutMode === "HORIZONTAL" ? "Row" : "Column"; + + const widgetProps: Record = autoLayout.layoutWrap == "WRAP" + ? { + alignment: getWrapAlignment(autoLayout), + runAlignment: getWrapRunAlignment(autoLayout), + } : + { + mainAxisSize: "MainAxisSize.min", + // mainAxisSize: getFlex(node, autoLayout), + mainAxisAlignment: getMainAxisAlignment(autoLayout), + crossAxisAlignment: getCrossAxisAlignment(autoLayout), + + }; + + // Add spacing parameter if itemSpacing is set + if (autoLayout.layoutWrap == "WRAP") { + if (autoLayout.primaryAxisAlignItems != "SPACE_BETWEEN" && autoLayout.itemSpacing != undefined) { + widgetProps.spacing = autoLayout.itemSpacing; + } + if (autoLayout.counterAxisAlignContent != "SPACE_BETWEEN" && autoLayout.counterAxisSpacing != undefined) { + widgetProps.runSpacing = autoLayout.counterAxisSpacing; + } + } else if (autoLayout.itemSpacing > 0) { + widgetProps.spacing = autoLayout.itemSpacing; + } else if (autoLayout.itemSpacing < 0) { + addWarning("Flutter doesn't support negative itemSpacing"); + } - const widgetProps = { - mainAxisSize: "MainAxisSize.min", - // mainAxisSize: getFlex(node, autoLayout), - mainAxisAlignment: getMainAxisAlignment(autoLayout), - crossAxisAlignment: getCrossAxisAlignment(autoLayout), - children: [children], - }; + widgetProps.children = [children]; return generateWidgetCode(rowOrColumn, widgetProps); }; -const addSpacingIfNeeded = ( - node: SceneNode, - optimizeLayout: boolean, -): string => { - const nodeParentLayout = - optimizeLayout && node.parent && "itemSpacing" in node.parent - ? node.parent.inferredAutoLayout - : node.parent; - - if ( - nodeParentLayout && - node.parent?.type === "FRAME" && - "itemSpacing" in nodeParentLayout && - nodeParentLayout.layoutMode !== "NONE" - ) { - if (nodeParentLayout.itemSpacing > 0) { - if (nodeParentLayout.layoutMode === "HORIZONTAL") { - return generateWidgetCode("const SizedBox", { - width: nodeParentLayout.itemSpacing, - }); - } else if (nodeParentLayout.layoutMode === "VERTICAL") { - return generateWidgetCode("const SizedBox", { - height: nodeParentLayout.itemSpacing, - }); - } - } - } - return ""; -}; - export const flutterCodeGenTextStyles = () => { const result = previousExecutionCache .map((style) => `${style}`) diff --git a/packages/backend/src/flutter/flutterTextBuilder.ts b/packages/backend/src/flutter/flutterTextBuilder.ts index b67e7eee..40c334e3 100644 --- a/packages/backend/src/flutter/flutterTextBuilder.ts +++ b/packages/backend/src/flutter/flutterTextBuilder.ts @@ -1,18 +1,23 @@ import { generateWidgetCode, skipDefaultProperty, - sliceNum, + numberToFixedString, } from "./../common/numToAutoFixed"; import { FlutterDefaultBuilder } from "./flutterDefaultBuilder"; -import { flutterColorFromFills } from "./builderImpl/flutterColor"; +import { + flutterColorFromDirectFills, + flutterColorFromFills, +} from "./builderImpl/flutterColor"; import { flutterSize } from "./builderImpl/flutterSize"; -import { globalTextStyleSegments } from "../altNodes/altConversion"; import { commonLetterSpacing, commonLineHeight, } from "../common/commonTextHeightSpacing"; +import { StyledTextSegmentSubset } from "types/src/types"; export class FlutterTextBuilder extends FlutterDefaultBuilder { + node?: TextNode; + constructor(optChild: string = "") { super(optChild); } @@ -22,6 +27,7 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { } createText(node: TextNode): this { + this.node = node; let alignHorizontal = node.textAlignHorizontal?.toString()?.toLowerCase() ?? "left"; alignHorizontal = @@ -32,7 +38,7 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { alignHorizontal !== "left" ? `TextAlign.${alignHorizontal}` : "", }; - const segments = this.getTextSegments(node.id); + const segments = this.getTextSegments(node); if (segments.length === 1) { this.child = generateWidgetCode( "Text", @@ -58,20 +64,21 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { return this; } - getTextSegments(id: string): { + getTextSegments(node: TextNode): { style: string; text: string; openTypeFeatures: { [key: string]: boolean }; }[] { - const segments = globalTextStyleSegments[id]; + const segments = (node as any) + .styledTextSegments as StyledTextSegmentSubset[]; if (!segments) { return []; } return segments.map((segment) => { - const color = flutterColorFromFills(segment.fills); + const color = flutterColorFromDirectFills(segment.fills); - const fontSize = `${sliceNum(segment.fontSize)}`; + const fontSize = `${numberToFixedString(segment.fontSize)}`; const fontStyle = this.fontStyle(segment.fontName); const fontFamily = `'${segment.fontName.family}'`; const fontWeight = `FontWeight.w${segment.fontWeight}`; @@ -106,6 +113,12 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { styleProperties.fontFeatures = `[FontFeature.enable("sups")]`; } + // Add text-shadow if a drop shadow is applied + const shadow = this.textShadow(); + if (shadow) { + styleProperties.shadows = shadow; + } + const style = generateWidgetCode("TextStyle", styleProperties); let text = segment.characters; @@ -139,22 +152,71 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { case "AUTO": return ""; case "PIXELS": - return sliceNum(lineHeight.value / fontSize); + return numberToFixedString(lineHeight.value / fontSize); case "PERCENT": - return sliceNum(lineHeight.value / 100); + return numberToFixedString(lineHeight.value / 100); } } letterSpacing(letterSpacing: LetterSpacing, fontSize: number): string { const value = commonLetterSpacing(letterSpacing, fontSize); if (value) { - return sliceNum(value); + return numberToFixedString(value); } return ""; } textAutoSize(node: TextNode): this { - this.child = wrapTextAutoResize(node, this.child); + let result = this.child; + + // Get constraints for the node + const constraints: Record = {}; + + if (node.minWidth !== undefined && node.minWidth !== null) { + constraints.minWidth = numberToFixedString(node.minWidth); + } + + if (node.maxWidth !== undefined && node.maxWidth !== null) { + constraints.maxWidth = numberToFixedString(node.maxWidth); + } + + if (node.minHeight !== undefined && node.minHeight !== null) { + constraints.minHeight = numberToFixedString(node.minHeight); + } + + if (node.maxHeight !== undefined && node.maxHeight !== null) { + constraints.maxHeight = numberToFixedString(node.maxHeight); + } + + const hasConstraints = Object.keys(constraints).length > 0; + if (hasConstraints) { + result = generateWidgetCode("ConstrainedBox", { + constraints: generateWidgetCode("BoxConstraints", constraints), + child: result, + }); + } + + switch (node.textAutoResize) { + case "WIDTH_AND_HEIGHT": + break; + case "HEIGHT": + result = generateWidgetCode("SizedBox", { + width: node.width, + child: result, + }); + break; + case "NONE": + case "TRUNCATE": + result = generateWidgetCode("SizedBox", { + width: node.width, + height: node.height, + child: result, + }); + break; + } + + result = wrapTextWithLayerBlur(node, result); + this.child = result; return this; } @@ -165,39 +227,57 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { } return ""; }; -} -export const wrapTextAutoResize = (node: TextNode, child: string): string => { - const { width, height, isExpanded } = flutterSize(node, false); - let result = ""; + /** + * New method to handle text shadow. + * Checks if a drop shadow effect is applied to the node and + * returns Flutter code for the TextStyle "shadows" property. + */ + textShadow(): string { + if (this.node && (this.node as TextNode).effects) { + const effects = (this.node as TextNode).effects; + const dropShadow = effects.find( + (effect) => effect.type === "DROP_SHADOW" && effect.visible !== false, + ); + if (dropShadow) { + const ds = dropShadow as DropShadowEffect; + const offsetX = Math.round(ds.offset.x); + const offsetY = Math.round(ds.offset.y); + const blurRadius = Math.round(ds.radius); + const r = Math.round(ds.color.r * 255); + const g = Math.round(ds.color.g * 255); + const b = Math.round(ds.color.b * 255); + const hex = ((1 << 24) + (r << 16) + (g << 8) + b) + .toString(16) + .slice(1) + .toUpperCase(); + return `[Shadow(offset: Offset(${offsetX}, ${offsetY}), blurRadius: ${blurRadius}, color: Color(0xFF${hex}).withOpacity(${ds.color.a.toFixed( + 2, + )}))]`; + } + } + return ""; + } +} - switch (node.textAutoResize) { - case "WIDTH_AND_HEIGHT": - break; - case "HEIGHT": - result = generateWidgetCode("SizedBox", { - width: width, - child: child, - }); - break; - case "NONE": - case "TRUNCATE": - result = generateWidgetCode("SizedBox", { - width: width, - height: height, +export const wrapTextWithLayerBlur = ( + node: TextNode, + child: string, +): string => { + if (node.effects) { + const blurEffect = node.effects.find( + (effect) => + effect.type === "LAYER_BLUR" && + effect.visible !== false && + effect.radius > 0, + ); + if (blurEffect) { + return generateWidgetCode("ImageFiltered", { + imageFilter: `ImageFilter.blur(sigmaX: ${blurEffect.radius}, sigmaY: ${blurEffect.radius})`, child: child, }); - break; - } - - if (isExpanded) { - return generateWidgetCode("Expanded", { - child: result, - }); - } else if (result.length > 0) { - return result; + } } - return child; }; diff --git a/packages/backend/src/html/builderImpl/htmlAutoLayout.ts b/packages/backend/src/html/builderImpl/htmlAutoLayout.ts index 5df954ef..2fb246c3 100644 --- a/packages/backend/src/html/builderImpl/htmlAutoLayout.ts +++ b/packages/backend/src/html/builderImpl/htmlAutoLayout.ts @@ -1,4 +1,4 @@ -import { HTMLSettings, PluginSettings } from "types"; +import { HTMLSettings } from "types"; import { formatMultipleJSXArray } from "../../common/parseJSX"; const getFlexDirection = (node: InferredAutoLayoutResult): string => @@ -6,6 +6,7 @@ const getFlexDirection = (node: InferredAutoLayoutResult): string => const getJustifyContent = (node: InferredAutoLayoutResult): string => { switch (node.primaryAxisAlignItems) { + case undefined: case "MIN": return "flex-start"; case "CENTER": @@ -19,6 +20,7 @@ const getJustifyContent = (node: InferredAutoLayoutResult): string => { const getAlignItems = (node: InferredAutoLayoutResult): string => { switch (node.counterAxisAlignItems) { + case undefined: case "MIN": return "flex-start"; case "CENTER": @@ -35,6 +37,27 @@ const getGap = (node: InferredAutoLayoutResult): string | number => ? node.itemSpacing : ""; +const getFlexWrap = (node: InferredAutoLayoutResult): string => + node.layoutWrap === "WRAP" ? "wrap" : ""; + +const getAlignContent = (node: InferredAutoLayoutResult): string => { + if (node.layoutWrap !== "WRAP") return ""; + + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "flex-start"; + case "CENTER": + return "center"; + case "MAX": + return "flex-end"; + case "BASELINE": + return "baseline"; + default: + return "normal"; + } +}; + const getFlex = ( node: SceneNode, autoLayout: InferredAutoLayoutResult, @@ -46,17 +69,18 @@ const getFlex = ( : "inline-flex"; export const htmlAutoLayoutProps = ( - node: SceneNode, - autoLayout: InferredAutoLayoutResult, + node: SceneNode & InferredAutoLayoutResult, settings: HTMLSettings, ): string[] => formatMultipleJSXArray( { - "flex-direction": getFlexDirection(autoLayout), - "justify-content": getJustifyContent(autoLayout), - "align-items": getAlignItems(autoLayout), - gap: getGap(autoLayout), - display: getFlex(node, autoLayout), + "flex-direction": getFlexDirection(node), + "justify-content": getJustifyContent(node), + "align-items": getAlignItems(node), + gap: getGap(node), + display: getFlex(node, node), + "flex-wrap": getFlexWrap(node), + "align-content": getAlignContent(node), }, - settings.jsx, + settings.htmlGenerationMode === "jsx", ); diff --git a/packages/backend/src/html/builderImpl/htmlBlend.ts b/packages/backend/src/html/builderImpl/htmlBlend.ts index 15037e38..637659b5 100644 --- a/packages/backend/src/html/builderImpl/htmlBlend.ts +++ b/packages/backend/src/html/builderImpl/htmlBlend.ts @@ -1,5 +1,6 @@ -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { formatWithJSX } from "../../common/parseJSX"; +import { AltNode } from "../../alt_api_types"; /** * https://tailwindcss.com/docs/opacity/ @@ -15,9 +16,9 @@ export const htmlOpacity = ( if (node.opacity !== undefined && node.opacity !== 1) { // formatWithJSX is not called here because opacity unit doesn't end in px. if (isJsx) { - return `opacity: ${sliceNum(node.opacity)}`; + return `opacity: ${numberToFixedString(node.opacity)}`; } else { - return `opacity: ${sliceNum(node.opacity)}`; + return `opacity: ${numberToFixedString(node.opacity)}`; } } return ""; @@ -108,17 +109,18 @@ export const htmlVisibility = ( * default is [-180, -90, -45, 0, 45, 90, 180], but '0' will be ignored: * if rotation was changed, let it be perceived. Therefore, 1 => 45 */ -export const htmlRotation = (node: LayoutMixin, isJsx: boolean): string[] => { - // that's how you convert angles to clockwise radians: angle * -pi/180 - // using 3.14159 as Pi for enough precision and to avoid importing math lib. - if (node.rotation !== undefined && Math.round(node.rotation) !== 0) { +export const htmlRotation = (node: AltNode, isJsx: boolean): string[] => { + const rotation = + -Math.round((node.rotation || 0) + (node.cumulativeRotation || 0)) || 0; + + if (rotation !== 0) { return [ formatWithJSX( "transform", isJsx, - `rotate(${sliceNum(-node.rotation)}deg)`, + `rotate(${numberToFixedString(rotation)}deg)`, ), - formatWithJSX("transform-origin", isJsx, "0 0"), + formatWithJSX("transform-origin", isJsx, "top left"), ]; } return []; diff --git a/packages/backend/src/html/builderImpl/htmlBorderRadius.ts b/packages/backend/src/html/builderImpl/htmlBorderRadius.ts index f2f7fc39..3ba1094d 100644 --- a/packages/backend/src/html/builderImpl/htmlBorderRadius.ts +++ b/packages/backend/src/html/builderImpl/htmlBorderRadius.ts @@ -2,56 +2,55 @@ import { getCommonRadius } from "../../common/commonRadius"; import { formatWithJSX } from "../../common/parseJSX"; export const htmlBorderRadius = (node: SceneNode, isJsx: boolean): string[] => { - const radius = getCommonRadius(node); + let comp: string[] = []; + + if ( + "children" in node && + node.children.length > 0 && + "clipsContent" in node && + node.clipsContent === true + ) { + comp.push(formatWithJSX("overflow", isJsx, "hidden")); + } + if (node.type === "ELLIPSE") { - return [formatWithJSX("border-radius", isJsx, 9999)]; + comp.push(formatWithJSX("border-radius", isJsx, 9999)); + return comp; } - let comp: string[] = []; - let cornerValues: number[] = [0, 0, 0, 0]; + const radius = getCommonRadius(node); + let singleCorner: number = 0; if ("all" in radius) { if (radius.all === 0) { - return []; + return comp; } singleCorner = radius.all; comp.push(formatWithJSX("border-radius", isJsx, radius.all)); - } else if ("topLeftRadius" in node) { - cornerValues = handleIndividualRadius(node); - comp.push( - ...cornerValues - .filter((d) => d > 0) - .map((value, index) => { - const property = [ - "border-top-left-radius", - "border-top-right-radius", - "border-bottom-right-radius", - "border-bottom-left-radius", - ][index]; - return formatWithJSX(property, isJsx, value); - }), - ); - } + } else { + const cornerValues = [ + radius.topLeft, + radius.topRight, + radius.bottomRight, + radius.bottomLeft, + ]; - if ( - "children" in node && - "clipsContent" in node && - node.children.length > 0 && - node.clipsContent === true - ) { - comp.push(formatWithJSX("overflow", isJsx, "hidden")); + // Map each corner value to its corresponding CSS property + const cornerProperties = [ + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + ]; + + // Add CSS properties for non-zero corner values + for (let i = 0; i < 4; i++) { + if (cornerValues[i] > 0) { + comp.push(formatWithJSX(cornerProperties[i], isJsx, cornerValues[i])); + } + } } return comp; }; - -const handleIndividualRadius = (node: RectangleCornerMixin): number[] => { - const cornerValues = [ - node.topLeftRadius, - node.topRightRadius, - node.bottomRightRadius, - node.bottomLeftRadius, - ]; - return cornerValues; -}; diff --git a/packages/backend/src/html/builderImpl/htmlColor.ts b/packages/backend/src/html/builderImpl/htmlColor.ts index 561d3280..a941b7bc 100644 --- a/packages/backend/src/html/builderImpl/htmlColor.ts +++ b/packages/backend/src/html/builderImpl/htmlColor.ts @@ -1,163 +1,284 @@ -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { retrieveTopFill } from "../../common/retrieveFill"; +import { GradientPaint, Paint } from "../../api_types"; -// retrieve the SOLID color on HTML -export const htmlColorFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"] | undefined, -): string => { - // kind can be text, bg, border... - // [when testing] fills can be undefined +/** + * Helper to process a color with variable binding if present + */ +export const processColorWithVariable = (fill: { + color: RGB; + opacity?: number; + variableColorName?: string; +}): string => { + const opacity = fill.opacity ?? 1; - const fill = retrieveTopFill(fills); - if (fill && fill.type === "SOLID") { - // if fill isn't visible, it shouldn't be painted. - return htmlColor(fill.color, fill.opacity); + if (fill.variableColorName) { + const varName = fill.variableColorName; + const fallbackColor = htmlColor(fill.color, opacity); + return `var(--${varName}, ${fallbackColor})`; } - if ( - fill && + return htmlColor(fill.color, opacity); +}; + +/** + * Extract color, opacity, and bound variable from a fill + */ +const getColorAndVariable = ( + fill: Paint, +): { + color: RGB; + opacity: number; + variableColorName?: string; +} => { + if (fill.type === "SOLID") { + return { + color: fill.color, + opacity: fill.opacity ?? 1, + variableColorName: (fill as any).variableColorName, + }; + } else if ( (fill.type === "GRADIENT_LINEAR" || - fill.type === "GRADIENT_ANGULAR" || fill.type === "GRADIENT_RADIAL" || - fill.type === "GRADIENT_DIAMOND") + fill.type === "GRADIENT_ANGULAR" || + fill.type === "GRADIENT_DIAMOND") && + fill.gradientStops.length > 0 ) { - if (fill.gradientStops.length > 0) { - return htmlColor(fill.gradientStops[0].color, fill.opacity); - } + const firstStop = fill.gradientStops[0]; + return { + color: firstStop.color, + opacity: fill.opacity ?? 1, + variableColorName: (firstStop as any).variableColorName, + }; } + return { color: { r: 0, g: 0, b: 0 }, opacity: 0 }; +}; +/** + * Convert fills to an HTML color string + */ +export const htmlColorFromFills = ( + fills: ReadonlyArray | undefined, +): string => { + const fill = retrieveTopFill(fills); + if (fill) { + const colorInfo = getColorAndVariable(fill); + return processColorWithVariable(colorInfo); + } return ""; }; +/** + * Convert fills to an HTML color string + */ +export const htmlColorFromFill = (fill: Paint): string => { + return processColorWithVariable(fill as any); +}; + +/** + * Convert RGB color to CSS color string + */ export const htmlColor = (color: RGB, alpha: number = 1): string => { if (color.r === 1 && color.g === 1 && color.b === 1 && alpha === 1) { return "white"; } - if (color.r === 0 && color.g === 0 && color.b === 0 && alpha === 1) { return "black"; } - - // Return # when possible. if (alpha === 1) { const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); - const toHex = (num: number): string => num.toString(16).padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); } - - const r = sliceNum(color.r * 255); - const g = sliceNum(color.g * 255); - const b = sliceNum(color.b * 255); - const a = sliceNum(alpha); - + const r = numberToFixedString(color.r * 255); + const g = numberToFixedString(color.g * 255); + const b = numberToFixedString(color.b * 255); + const a = numberToFixedString(alpha); return `rgba(${r}, ${g}, ${b}, ${a})`; }; -export const htmlGradientFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"], +/** + * Process a single gradient stop + */ +const processGradientStop = ( + stop: ColorStop, + fillOpacity: number = 1, + positionMultiplier: number = 100, + unit: string = "%", ): string => { - const fill = retrieveTopFill(fills); - if (fill?.type === "GRADIENT_LINEAR") { - return htmlLinearGradient(fill); - } else if (fill?.type === "GRADIENT_ANGULAR") { - return htmlAngularGradient(fill); - } else if (fill?.type === "GRADIENT_RADIAL") { - return htmlRadialGradient(fill); - } - return ""; -}; - -export const gradientAngle2 = (fill: GradientPaint): number => { - const x1 = fill.gradientTransform[0][2]; - const y1 = fill.gradientTransform[1][2]; - const x2 = fill.gradientTransform[0][0] + x1; - const y2 = fill.gradientTransform[1][0] + y1; - const dx = x2 - x1; - const dy = y1 - y2; - const radians = Math.atan2(dy, dx); - const unadjustedAngle = (radians * 180) / Math.PI; - const adjustedAngle = unadjustedAngle + 90; - return adjustedAngle; -}; + const fillInfo = { + color: stop.color, + opacity: stop.color.a * fillOpacity, + boundVariables: stop.boundVariables, + variableColorName: (stop as any).variableColorName, + }; -export const cssGradientAngle = (angle: number): number => { - // Convert Figma angle to CSS angle. - const cssAngle = angle; // Subtract 235 to make it start from the correct angle. - // Normalize angle: if negative, add 360 to make it positive. - return cssAngle < 0 ? cssAngle + 360 : cssAngle; + const color = processColorWithVariable(fillInfo); + const position = `${(stop.position * positionMultiplier).toFixed(0)}${unit}`; + return `${color} ${position}`; }; -export const htmlLinearGradient = (fill: GradientPaint): string => { - // Adjust angle for CSS. - const figmaAngle = gradientAngle2(fill); - const angle = cssGradientAngle(figmaAngle).toFixed(0); - - const mappedFill = fill.gradientStops - .map((stop) => { - const color = htmlColor(stop.color, stop.color.a * (fill.opacity ?? 1)); - const position = `${(stop.position * 100).toFixed(0)}%`; - return `${color} ${position}`; - }) +/** + * Process all gradient stops for a gradient + */ +const processGradientStops = ( + stops: ReadonlyArray, + fillOpacity: number = 1, + positionMultiplier: number = 100, + unit: string = "%", +): string => { + return stops + .map((stop) => + processGradientStop(stop, fillOpacity, positionMultiplier, unit), + ) .join(", "); - - return `linear-gradient(${angle}deg, ${mappedFill})`; }; -export const invertYCoordinate = (y: number): number => 1 - y; - -export const getGradientTransformCoordinates = ( - gradientTransform: number[][], -): { centerX: string; centerY: string; radiusX: string; radiusY: string } => { - const a = gradientTransform[0][0]; - const b = gradientTransform[0][1]; - const c = gradientTransform[1][0]; - const d = gradientTransform[1][1]; - const e = gradientTransform[0][2]; - const f = gradientTransform[1][2]; - - const scaleX = Math.sqrt(a ** 2 + b ** 2); - const scaleY = Math.sqrt(c ** 2 + d ** 2); - - const rotationAngle = Math.atan2(b, a); +/** + * Determine the appropriate gradient function based on fill type + */ +export const htmlGradientFromFills = (fill: Paint): string => { + if (!fill) return ""; + switch (fill.type) { + case "GRADIENT_LINEAR": + return htmlLinearGradient(fill); + case "GRADIENT_ANGULAR": + return htmlAngularGradient(fill); + case "GRADIENT_RADIAL": + return htmlRadialGradient(fill); + case "GRADIENT_DIAMOND": + return htmlDiamondGradient(fill); + default: + return ""; + } +}; - const centerX = ((e * scaleX * 100) / (1 - scaleX)).toFixed(2); - const centerY = (((1 - f) * scaleY * 100) / (1 - scaleY)).toFixed(2); +/** + * Generate CSS linear gradient + */ +export const htmlLinearGradient = (fill: GradientPaint) => { + const [start, end] = fill.gradientHandlePositions; + const dx = end.x - start.x; + const dy = end.y - start.y; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); // Angle in degrees + angle = (angle + 360) % 360; // Normalize to 0-360 + const cssAngle = (angle + 90) % 360; // Adjust for CSS convention + const mappedFill = processGradientStops( + fill.gradientStops, + fill.opacity ?? 1, + ); + return `linear-gradient(${cssAngle.toFixed(0)}deg, ${mappedFill})`; +}; - const radiusX = (scaleX * 100).toFixed(2); - const radiusY = (scaleY * 100).toFixed(2); +/** + * Generate CSS radial gradient + */ +export const htmlRadialGradient = (fill: GradientPaint) => { + const [center, h1, h2] = fill.gradientHandlePositions; + const cx = center.x * 100; // Center X as percentage + const cy = center.y * 100; // Center Y as percentage + // Calculate horizontal radius (distance from center to h1) + const rx = Math.sqrt((h1.x - center.x) ** 2 + (h1.y - center.y) ** 2) * 100; + // Calculate vertical radius (distance from center to h2) + const ry = Math.sqrt((h2.x - center.x) ** 2 + (h2.y - center.y) ** 2) * 100; + const mappedStops = processGradientStops( + fill.gradientStops, + fill.opacity ?? 1, + ); + return `radial-gradient(ellipse ${rx.toFixed(2)}% ${ry.toFixed(2)}% at ${cx.toFixed(2)}% ${cy.toFixed(2)}%, ${mappedStops})`; +}; - return { centerX, centerY, radiusX, radiusY }; +/** + * Generate CSS conic (angular) gradient + */ +export const htmlAngularGradient = (fill: GradientPaint) => { + const [center, _, startDirection] = fill.gradientHandlePositions; + const cx = center.x * 100; // Center X as percentage + const cy = center.y * 100; // Center Y as percentage + // Calculate the starting angle + const dx = startDirection.x - center.x; + const dy = startDirection.y - center.y; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); // Convert to degrees + angle = (angle + 360) % 360; // Normalize to 0-360 degrees + const mappedFill = processGradientStops( + fill.gradientStops, + fill.opacity ?? 1, + 360, + "deg", + ); + return `conic-gradient(from ${angle.toFixed(0)}deg at ${cx.toFixed(2)}% ${cy.toFixed(2)}%, ${mappedFill})`; }; -export const htmlRadialGradient = (fill: GradientPaint): string => { - const mappedFill = fill.gradientStops - .map((stop) => { - const color = htmlColor(stop.color, stop.color.a * (fill.opacity ?? 1)); - const position = `${(stop.position * 100).toFixed(0)}%`; - return `${color} ${position}`; - }) +/** + * Generate CSS diamond gradient (approximation using four linear gradients) + */ +export const htmlDiamondGradient = (fill: GradientPaint) => { + const stops = processGradientStops( + fill.gradientStops, + fill.opacity ?? 1, + 50, + "%", + ); + const gradientConfigs = [ + { direction: "to bottom right", position: "bottom right" }, + { direction: "to bottom left", position: "bottom left" }, + { direction: "to top left", position: "top left" }, + { direction: "to top right", position: "top right" }, + ]; + return gradientConfigs + .map( + ({ direction, position }) => + `linear-gradient(${direction}, ${stops}) ${position} / 50% 50% no-repeat`, + ) .join(", "); +}; - const { centerX, centerY, radiusX, radiusY } = - getGradientTransformCoordinates(fill.gradientTransform); +/** + * Build CSS background value from an array of paints + */ +export const buildBackgroundValues = ( + paintArray: ReadonlyArray | PluginAPI["mixed"], +): string => { + if (paintArray === figma.mixed) { + return ""; + } - return `radial-gradient(${radiusX}% ${radiusY}% at ${centerX}% ${centerY}%, ${mappedFill})`; -}; + // If only one fill, use plain color or gradient + if (paintArray.length === 1) { + const paint = paintArray[0]; + if (paint.type === "SOLID") { + return htmlColorFromFills(paintArray); + } else if ( + paint.type === "GRADIENT_LINEAR" || + paint.type === "GRADIENT_RADIAL" || + paint.type === "GRADIENT_ANGULAR" || + paint.type === "GRADIENT_DIAMOND" + ) { + return htmlGradientFromFills(paint); + } + return ""; + } -export const htmlAngularGradient = (fill: GradientPaint): string => { - const angle = gradientAngle2(fill).toFixed(0); - const centerX = (fill.gradientTransform[0][2] * 100).toFixed(2); - const centerY = (fill.gradientTransform[1][2] * 100).toFixed(2); - - const mappedFill = fill.gradientStops - .map((stop) => { - const color = htmlColor(stop.color, stop.color.a * (fill.opacity ?? 1)); - const position = `${(stop.position * 360).toFixed(0)}deg`; - return `${color} ${position}`; - }) - .join(", "); + // For multiple fills, reverse to match CSS layering (first is top-most) + const styles = [...paintArray].reverse().map((paint, index) => { + if (paint.type === "SOLID") { + // Convert solid colors to gradients for proper layering + const color = htmlColorFromFills([paint]); + if (index === 0) { + return `linear-gradient(0deg, ${color} 0%, ${color} 100%)`; + } + return color; + } else if ( + paint.type === "GRADIENT_LINEAR" || + paint.type === "GRADIENT_RADIAL" || + paint.type === "GRADIENT_ANGULAR" || + paint.type === "GRADIENT_DIAMOND" + ) { + return htmlGradientFromFills(paint); + } + return ""; // Handle other paint types safely + }); - return `conic-gradient(from ${angle}deg at ${centerX}% ${centerY}%, ${mappedFill})`; + return styles.filter((value) => value !== "").join(", "); }; 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/builderImpl/htmlSize.ts b/packages/backend/src/html/builderImpl/htmlSize.ts index 97e9151d..ad30981c 100644 --- a/packages/backend/src/html/builderImpl/htmlSize.ts +++ b/packages/backend/src/html/builderImpl/htmlSize.ts @@ -5,20 +5,17 @@ import { isPreviewGlobal } from "../htmlMain"; export const htmlSizePartial = ( node: SceneNode, isJsx: boolean, - optimizeLayout: boolean, -): { width: string; height: string } => { +): { width: string; height: string; constraints: string[] } => { if (isPreviewGlobal && node.parent === undefined) { return { width: formatWithJSX("width", isJsx, "100%"), height: formatWithJSX("height", isJsx, "100%"), + constraints: [], }; } - const size = nodeSize(node, optimizeLayout); - const nodeParent = - (node.parent && optimizeLayout && "inferredAutoLayout" in node.parent - ? node.parent.inferredAutoLayout - : null) ?? node.parent; + const size = nodeSize(node); + const nodeParent = node.parent; let w = ""; if (typeof size.width === "number") { @@ -31,7 +28,11 @@ export const htmlSizePartial = ( ) { w = formatWithJSX("flex", isJsx, "1 1 0"); } else { - w = formatWithJSX("align-self", isJsx, "stretch"); + if (node.maxWidth) { + w = formatWithJSX("width", isJsx, "100%"); + } else { + w = formatWithJSX("align-self", isJsx, "stretch"); + } } } @@ -46,9 +47,37 @@ export const htmlSizePartial = ( ) { h = formatWithJSX("flex", isJsx, "1 1 0"); } else { - h = formatWithJSX("align-self", isJsx, "stretch"); + if (node.maxHeight) { + h = formatWithJSX("height", isJsx, "100%"); + } else { + h = formatWithJSX("align-self", isJsx, "stretch"); + } } } - return { width: w, height: h }; + // Handle min/max width/height constraints + const constraints = []; + + if (node.maxWidth !== undefined && node.maxWidth !== null) { + constraints.push(formatWithJSX("max-width", isJsx, node.maxWidth)); + } + + if (node.minWidth !== undefined && node.minWidth !== null) { + constraints.push(formatWithJSX("min-width", isJsx, node.minWidth)); + } + + if (node.maxHeight !== undefined && node.maxHeight !== null) { + constraints.push(formatWithJSX("max-height", isJsx, node.maxHeight)); + } + + if (node.minHeight !== undefined && node.minHeight !== null) { + constraints.push(formatWithJSX("min-height", isJsx, node.minHeight)); + } + + // Return constraints separately instead of appending to width/height + return { + width: w, + height: h, + constraints: constraints, + }; }; diff --git a/packages/backend/src/html/htmlDefaultBuilder.ts b/packages/backend/src/html/htmlDefaultBuilder.ts index 6fb998a2..e2ff6a3c 100644 --- a/packages/backend/src/html/htmlDefaultBuilder.ts +++ b/packages/backend/src/html/htmlDefaultBuilder.ts @@ -7,8 +7,8 @@ import { htmlBlendMode, } from "./builderImpl/htmlBlend"; import { + buildBackgroundValues, htmlColorFromFills, - htmlGradientFromFills, } from "./builderImpl/htmlColor"; import { htmlPadding } from "./builderImpl/htmlPadding"; import { htmlSizePartial } from "./builderImpl/htmlSize"; @@ -17,7 +17,10 @@ import { commonIsAbsolutePosition, getCommonPositionValue, } from "../common/commonPosition"; -import { sliceNum, stringToClassName } from "../common/numToAutoFixed"; +import { + numberToFixedString, + stringToClassName, +} from "../common/numToAutoFixed"; import { commonStroke } from "../common/commonStroke"; import { formatClassAttribute, @@ -25,24 +28,61 @@ import { formatStyleAttribute, } from "../common/commonFormatAttributes"; import { HTMLSettings } from "types"; +import { + cssCollection, + generateUniqueClassName, + stylesToCSS, + getComponentName, +} from "./htmlMain"; export class HtmlDefaultBuilder { styles: Array; data: Array; node: SceneNode; settings: HTMLSettings; + cssClassName: string | null = null; get name() { + if (this.settings.htmlGenerationMode === "styled-components") { + return this.settings.showLayerNames + ? (this.node as any).uniqueName || this.node.name + : ""; + } return this.settings.showLayerNames ? this.node.name : ""; } + get visible() { return this.node.visible; } + get isJSX() { - return this.settings.jsx; + return this.settings.htmlGenerationMode === "jsx"; } - get optimizeLayout() { - return this.settings.optimizeLayout; + + get exportCSS() { + 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"; + } + + get useInlineStyles() { + return ( + this.settings.htmlGenerationMode === "html" || + this.settings.htmlGenerationMode === "jsx" + ); + } + + // Get the appropriate HTML element based on node type + get htmlElement(): string { + if (this.node.type === "TEXT") return "p"; + return "div"; } constructor(node: SceneNode, settings: HTMLSettings) { @@ -50,6 +90,32 @@ export class HtmlDefaultBuilder { this.settings = settings; this.styles = []; this.data = []; + + // For both Svelte and styled-components, use sequential class names + if ( + this.settings.htmlGenerationMode === "svelte" || + this.settings.htmlGenerationMode === "styled-components" + ) { + // Use uniqueName (which already has _01, _02 suffixes) if available + let baseClassName = + (this.node as any).uniqueName || + this.node.name || + this.node.type.toLowerCase(); + + // Clean the name and create a valid CSS class name + baseClassName = baseClassName + .replace(/[^a-zA-Z0-9\s_-]/g, "") + .replace(/\s+/g, "-") + .toLowerCase(); + + // Make sure it's valid + if (!/^[a-z]/i.test(baseClassName)) { + baseClassName = `${this.node.type.toLowerCase()}-${baseClassName}`; + } + + // Generate unique class name with simple counter suffix + this.cssClassName = generateUniqueClassName(baseClassName); + } } commonPositionStyles(): this { @@ -68,7 +134,7 @@ export class HtmlDefaultBuilder { ); } this.shadow(); - this.border(); + this.border(this.settings); this.blur(); return this; } @@ -88,7 +154,7 @@ export class HtmlDefaultBuilder { return this; } - border(): this { + border(settings: HTMLSettings): this { const { node } = this; this.addStyles(...htmlBorderRadius(node, this.isJSX)); @@ -98,22 +164,62 @@ export class HtmlDefaultBuilder { } const strokes = ("strokes" in node && node.strokes) || undefined; - const color = htmlColorFromFills(strokes); + const color = htmlColorFromFills(strokes as any); + if (!color) { + return this; + } const borderStyle = "dashPattern" in node && node.dashPattern.length > 0 ? "dotted" : "solid"; + const strokeAlign = "strokeAlign" in node ? node.strokeAlign : "INSIDE"; + + // Function to create border value string const consolidateBorders = (border: number): string => - [`${sliceNum(border)}px`, color, borderStyle].filter((d) => d).join(" "); + [`${numberToFixedString(border)}px`, color, borderStyle] + .filter((d) => d) + .join(" "); if ("all" in commonBorder) { if (commonBorder.all === 0) { return this; } const weight = commonBorder.all; - this.addStyles( - formatWithJSX("border", this.isJSX, consolidateBorders(weight)), - ); + + if ( + strokeAlign === "CENTER" || + strokeAlign === "OUTSIDE" || + node.type === "FRAME" || + node.type === "INSTANCE" || + node.type === "COMPONENT" + ) { + this.addStyles( + formatWithJSX("outline", this.isJSX, consolidateBorders(weight)), + ); + if (strokeAlign === "CENTER") { + this.addStyles( + formatWithJSX( + "outline-offset", + this.isJSX, + `${numberToFixedString(-weight / 2)}px`, + ), + ); + } else if (strokeAlign === "INSIDE") { + this.addStyles( + formatWithJSX( + "outline-offset", + this.isJSX, + `${numberToFixedString(-weight)}px`, + ), + ); + } + } else { + // Default: use regular border on autolayout + strokeAlign: inside + this.addStyles( + formatWithJSX("border", this.isJSX, consolidateBorders(weight)), + ); + } } else { + // For non-uniform borders, always use individual border properties if (commonBorder.left !== 0) { this.addStyles( formatWithJSX( @@ -155,9 +261,10 @@ export class HtmlDefaultBuilder { } position(): this { - const { node, optimizeLayout, isJSX } = this; - if (commonIsAbsolutePosition(node, optimizeLayout)) { - const { x, y } = getCommonPositionValue(node); + const { node, isJSX } = this; + const isAbsolutePosition = commonIsAbsolutePosition(node); + if (isAbsolutePosition) { + const { x, y } = getCommonPositionValue(node, this.settings); this.addStyles( formatWithJSX("left", isJSX, x), @@ -165,12 +272,7 @@ export class HtmlDefaultBuilder { formatWithJSX("position", isJSX, "absolute"), ); } else { - if ( - node.type === "GROUP" || - ("layoutMode" in node && - ((optimizeLayout ? node.inferredAutoLayout : null) ?? node) - ?.layoutMode === "NONE") - ) { + if (node.type === "GROUP" || (node as any).isRelative) { this.addStyles(formatWithJSX("position", isJSX, "relative")); } } @@ -184,48 +286,53 @@ export class HtmlDefaultBuilder { ): this { if (property === "text") { this.addStyles( - formatWithJSX("text", this.isJSX, htmlColorFromFills(paintArray)), + formatWithJSX( + "text", + this.isJSX, + htmlColorFromFills(paintArray as any), + ), ); return this; } - const backgroundValues = this.buildBackgroundValues(paintArray); + const backgroundValues = buildBackgroundValues(paintArray as any); if (backgroundValues) { this.addStyles(formatWithJSX("background", this.isJSX, backgroundValues)); + + // Add blend mode property if multiple fills exist with different blend modes + if (paintArray !== figma.mixed) { + const blendModes = this.buildBackgroundBlendModes(paintArray); + if (blendModes) { + this.addStyles( + formatWithJSX("background-blend-mode", this.isJSX, blendModes), + ); + } + } } return this; } - buildBackgroundValues( - paintArray: ReadonlyArray | PluginAPI["mixed"], - ): string { - if (paintArray === figma.mixed) { + buildBackgroundBlendModes(paintArray: ReadonlyArray): string { + if ( + paintArray.length === 0 || + paintArray.every( + (d) => d.blendMode === "NORMAL" || d.blendMode === "PASS_THROUGH", + ) + ) { return ""; } - // If one fill and it's a solid, return the solid RGB color - if (paintArray.length === 1 && paintArray[0].type === "SOLID") { - return htmlColorFromFills(paintArray); - } - - // If multiple fills, deal with gradients and convert solid colors to a "dumb" linear-gradient - const styles = paintArray.map((paint) => { - if (paint.type === "SOLID") { - const color = htmlColorFromFills([paint]); - return `linear-gradient(0deg, ${color} 0%, ${color} 100%)`; - } else if ( - paint.type === "GRADIENT_LINEAR" || - paint.type === "GRADIENT_RADIAL" || - paint.type === "GRADIENT_ANGULAR" - ) { - return htmlGradientFromFills([paint]); + // Reverse the array to match the background order + const blendModes = [...paintArray].reverse().map((paint) => { + if (paint.blendMode === "PASS_THROUGH") { + return "normal"; } - return ""; // Handle other paint types safely + return paint.blendMode?.toLowerCase(); }); - return styles.filter((value) => value !== "").join(", "); + return blendModes.join(", "); } shadow(): this { @@ -241,10 +348,9 @@ export class HtmlDefaultBuilder { size(): this { const { node, settings } = this; - const { width, height } = htmlSizePartial( + const { width, height, constraints } = htmlSizePartial( node, - settings.jsx, - settings.optimizeLayout, + settings.htmlGenerationMode === "jsx", ); if (node.type === "TEXT") { @@ -263,18 +369,18 @@ export class HtmlDefaultBuilder { this.addStyles(width, height); } + // Add constraints as separate styles + if (constraints.length > 0) { + this.addStyles(...constraints); + } + return this; } autoLayoutPadding(): this { - const { node, isJSX, optimizeLayout } = this; + const { node, isJSX } = this; if ("paddingLeft" in node) { - this.addStyles( - ...htmlPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node, - isJSX, - ), - ); + this.addStyles(...htmlPadding(node, isJSX)); } return this; } @@ -290,7 +396,7 @@ export class HtmlDefaultBuilder { formatWithJSX( "filter", this.isJSX, - `blur(${sliceNum(blur.radius)}px)`, + `blur(${numberToFixedString(blur.radius / 2)}px)`, ), ); } @@ -303,7 +409,7 @@ export class HtmlDefaultBuilder { formatWithJSX( "backdrop-filter", this.isJSX, - `blur(${sliceNum(backgroundBlur.radius)}px)`, + `blur(${numberToFixedString(backgroundBlur.radius / 2)}px)`, ), ); } @@ -319,19 +425,114 @@ export class HtmlDefaultBuilder { build(additionalStyle: Array = []): string { this.addStyles(...additionalStyle); - let classAttribute = ""; + // Different handling based on generation mode + const mode = this.settings.htmlGenerationMode || "html"; + + // Early return for styled-components with no other attributes + if ( + mode === "styled-components" && + !this.data.length && + this.styles.length > 0 && + this.cssClassName + ) { + this.storeStyles(); + return ""; // Return empty string as we're using the component directly + } + + let classNames: string[] = []; if (this.name) { - this.addData("layer", this.name); - const layerNameClass = stringToClassName(this.name); - classAttribute = formatClassAttribute( - layerNameClass === "" ? [] : [layerNameClass], - this.isJSX, - ); + this.addData("layer", this.name.trim()); + + if (mode !== "svelte" && mode !== "styled-components") { + const layerNameClass = stringToClassName(this.name.trim()); + if (layerNameClass !== "") { + classNames.push(layerNameClass); + } + } + } + + if ("componentProperties" in this.node && this.node.componentProperties) { + Object.entries(this.node.componentProperties) + ?.map((prop) => { + if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN") { + const cleanName = prop[0] + .split("#")[0] + .replace(/\s+/g, "-") + .toLowerCase(); + + return formatDataAttribute(cleanName, String(prop[1].value)); + } + return ""; + }) + .filter(Boolean) + .sort() + .forEach((d) => this.data.push(d)); + } + + // For Svelte mode, we use classes + if (mode === "svelte" && this.styles.length > 0 && this.cssClassName) { + classNames.push(this.cssClassName); + this.storeStyles(); + this.styles = []; // Clear inline styles for Svelte + } + // For styled-components, we need the class but keep styles for the component + else if ( + mode === "styled-components" && + this.styles.length > 0 && + this.cssClassName + ) { + classNames.push(this.cssClassName); + this.storeStyles(); + // Keep styles for styled-components } const dataAttributes = this.data.join(""); + + // Class attributes + const classAttribute = + mode === "styled-components" + ? formatClassAttribute( + classNames.filter((c) => c !== this.cssClassName), + this.isJSX, + ) + : formatClassAttribute(classNames, this.isJSX); + + // Style attribute const styleAttribute = formatStyleAttribute(this.styles, this.isJSX); return `${dataAttributes}${classAttribute}${styleAttribute}`; } + + // Extract style storage into a method to avoid duplication + private storeStyles(): void { + if (!this.cssClassName || this.styles.length === 0) return; + + // Convert to CSS format if needed + const cssStyles = stylesToCSS(this.styles, this.isJSX); + + // Both modes use the standard div/span elements, no need for semantic HTML inference + // which causes conflicts with duplicate tag selectors + let element = this.node.type === "TEXT" ? "p" : "div"; + + // Only override for really obvious cases + if ((this.node as any).name?.toLowerCase().includes("button")) { + element = "button"; + } else if ( + (this.node as any).name?.toLowerCase().includes("img") || + (this.node as any).name?.toLowerCase().includes("image") + ) { + element = "img"; + } + + const nodeName = (this.node as any).uniqueName || this.node.name; + + const componentName = getComponentName(nodeName, this.cssClassName, element); + + cssCollection[this.cssClassName] = { + styles: cssStyles, + 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 92f2c026..d91d82c7 100644 --- a/packages/backend/src/html/htmlMain.ts +++ b/packages/backend/src/html/htmlMain.ts @@ -1,14 +1,23 @@ import { indentString } from "../common/indentString"; -import { retrieveTopFill } from "../common/retrieveFill"; import { HtmlTextBuilder } from "./htmlTextBuilder"; import { HtmlDefaultBuilder } from "./htmlDefaultBuilder"; import { htmlAutoLayoutProps } from "./builderImpl/htmlAutoLayout"; import { formatWithJSX } from "../common/parseJSX"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; -import { addWarning } from "../common/commonConversionWarnings"; -import { PluginSettings, HTMLPreview, AltNode, HTMLSettings } from "types"; +import { + PluginSettings, + HTMLPreview, + AltNode, + HTMLSettings, + ExportableNode, +} from "types"; import { renderAndAttachSVG } from "../altNodes/altNodeUtils"; import { getVisibleNodes } from "../common/nodeVisibility"; +import { + exportNodeAsBase64PNG, + getPlaceholderImage, + nodeHasImageFill, +} from "../common/images"; +import { addWarning } from "../common/commonConversionWarnings"; const selfClosingTags = ["img"]; @@ -16,52 +25,354 @@ export let isPreviewGlobal = false; let previousExecutionCache: { style: string; text: string }[]; +// Define better type for the output +export interface HtmlOutput { + html: string; + css?: string; +} + +// Define HTML generation modes for better type safety +export type HtmlGenerationMode = + | "html" + | "jsx" + | "styled-components" + | "svelte"; + +// CSS Collection for external stylesheet or styled-components +interface CSSCollection { + [className: string]: { + styles: string[]; + nodeType?: string; + element?: string; // Base HTML element to use + componentName: string; // Required for type safety, only used in styled-components mode + }; +} + +export let cssCollection: CSSCollection = {}; + +// Instance counters for class name generation - we keep this but primarily as a fallback +const classNameCounters: Map = new Map(); + +// Generate a class name - prefer direct uniqueId, but fall back to counter-based if needed +export function generateUniqueClassName(prefix = "figma"): string { + // Sanitize the prefix to ensure valid CSS class + const sanitizedPrefix = + prefix.replace(/[^a-zA-Z0-9_-]/g, "").replace(/^[0-9_-]/, "f") || // Ensure it doesn't start with a number or special char + "figma"; + + // Most of the time, we'll just use the prefix directly as it's pre-generated to be unique + // But keep the counter logic as a fallback + const count = classNameCounters.get(sanitizedPrefix) || 0; + classNameCounters.set(sanitizedPrefix, count + 1); + + // Only add suffix if this isn't the first instance + return count === 0 + ? sanitizedPrefix + : `${sanitizedPrefix}_${count.toString().padStart(2, "0")}`; +} + +// Reset all class name counters - call this at the start of processing +export function resetClassNameCounters(): void { + classNameCounters.clear(); +} + +// Convert styles to CSS format +export function stylesToCSS(styles: string[], isJSX: boolean): string[] { + return styles + .map((style) => { + // Skip empty styles + if (!style.trim()) return ""; + + // Handle JSX format if needed + if (isJSX) { + return style.replace(/^([a-zA-Z0-9]+):/, (match, prop) => { + // Convert camelCase to kebab-case for CSS + return ( + prop + .replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2") + .toLowerCase() + ":" + ); + }); + } + return style; + }) + .filter(Boolean); // Remove empty entries +} + +// Get proper component name from node info +export function getComponentName( + nodeName: string | undefined, + className: string, + nodeType: string, +): string { + // Start with Styled prefix + let name = "Styled"; + + // Try to use node name first + if (nodeName && nodeName.length > 0) { + // Clean up the node name and capitalize first letter + const cleanName = nodeName + .replace(/[^a-zA-Z0-9]/g, "") + .replace(/^[a-z]/, (match) => match.toUpperCase()); + + name += cleanName || nodeType.charAt(0).toUpperCase() + nodeType.slice(1); + } + // Fall back to className if provided + else if (className) { + const parts = className.split("-"); + if (parts.length > 0 && parts[0]) { + name += parts[0].charAt(0).toUpperCase() + parts[0].slice(1); + } else { + name += nodeType.charAt(0).toUpperCase() + nodeType.slice(1); + } + } + // Last resort + else { + name += nodeType.charAt(0).toUpperCase() + nodeType.slice(1); + } + + return name; +} + +// Get the collected CSS as a string with improved formatting +export function getCollectedCSS(): string { + if (Object.keys(cssCollection).length === 0) { + return ""; + } + + return Object.entries(cssCollection) + .map(([className, { styles }]) => { + if (!styles.length) return ""; + return `.${className} {\n ${styles.join(";\n ")}${styles.length ? ";" : ""}\n}`; + }) + .filter(Boolean) + .join("\n\n"); +} + +// Generate styled-components with improved naming and formatting +export function generateStyledComponents(): string { + const components: string[] = []; + + Object.entries(cssCollection).forEach( + ([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 styledComponent = `const ${componentName} = styled.${baseElement}\` + ${styles.join(";\n ")}${styles.length ? ";" : ""} +\`;`; + + components.push(styledComponent); + }, + ); + + if (components.length === 0) { + return ""; + } + + return `${components.join("\n\n")}`; +} + +// Get a valid React component name from a layer name +export function getReactComponentName(node: any): string { + // Use uniqueName if available, otherwise use name + const name: string = node?.uniqueName || node?.name; + + // Default name if nothing valid is provided + if (!name || name.trim() === "") { + return "App"; + } + + // Convert to PascalCase + let componentName = name + .replace(/[^a-zA-Z0-9_]/g, " ") // Replace non-alphanumeric chars with spaces + .split(/\s+/) // Split by spaces + .map((part) => + part ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : "", + ) + .join(""); + + // Ensure it starts with uppercase letter (React component convention) + componentName = + componentName.charAt(0).toUpperCase() + componentName.slice(1); + + // Ensure it's a valid identifier - if it starts with a number, prefix with 'Component' + if (/^[0-9]/.test(componentName)) { + componentName = "Component" + componentName; + } + + // If we ended up with nothing valid, use the default + return componentName || "App"; +} + +// Get a Svelte-friendly component name +export function getSvelteElementName( + elementType: string, + nodeName?: string, +): string { + // For Svelte, use semantic element names where possible + if (elementType === "TEXT" || elementType === "p") { + return "p"; + } else if (elementType === "img" || elementType === "IMAGE") { + return "img"; + } else if ( + nodeName && + (nodeName.toLowerCase().includes("button") || + nodeName.toLowerCase().includes("btn")) + ) { + return "button"; + } else if (nodeName && nodeName.toLowerCase().includes("link")) { + return "a"; + } else { + return "div"; // Default element + } +} + +// Generate semantic class names for Svelte +export function getSvelteClassName(prefix?: string, nodeType?: string): string { + if (!prefix) { + return nodeType?.toLowerCase() || "element"; + } + + // Clean and format the prefix + return prefix + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-{2,}/g, "-") // Replace multiple hyphens with a single one + .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens + .toLowerCase(); +} + +// Generate component code based on the specified mode +function generateComponentCode( + html: string, + sceneNode: Array, + mode: HtmlGenerationMode, +): string { + switch (mode) { + case "styled-components": + return generateReactComponent(html, sceneNode); + case "svelte": + return generateSvelteComponent(html); + case "html": + case "jsx": + default: + return html; + } +} + +// Generate React component from HTML, with optional styled-components +function generateReactComponent( + html: string, + sceneNode: Array, +): string { + const styledComponentsCode = generateStyledComponents(); + + const componentName = getReactComponentName(sceneNode[0]); + + const imports = [ + 'import React from "react";', + 'import styled from "styled-components";', + ]; + + return `${imports.join("\n")} +${styledComponentsCode ? `\n${styledComponentsCode}` : ""} + +export const ${componentName} = () => { + return ( +${indentString(html, 4)} + ); +};`; +} + +// Generate Svelte component from the collected styles and HTML +function generateSvelteComponent(html: string): string { + // Build CSS classes similar to styled-components but for Svelte + const cssRules: string[] = []; + + Object.entries(cssCollection).forEach(([className, { styles }]) => { + if (!styles.length) return; + + // Always use class selector to avoid conflicts + cssRules.push( + `.${className} {\n ${styles.join(";\n ")}${styles.length ? ";" : ""}\n}`, + ); + }); + + return `${html} + +`; +} + export const htmlMain = async ( sceneNode: Array, settings: PluginSettings, isPreview: boolean = false, -): Promise => { +): Promise => { isPreviewGlobal = isPreview; previousExecutionCache = []; + cssCollection = {}; + resetClassNameCounters(); // Reset counters for each new generation - let result = await htmlWidgetGenerator(sceneNode, settings); + let htmlContent = await htmlWidgetGenerator(sceneNode, settings); // remove the initial \n that is made in Container. - if (result.length > 0 && result.startsWith("\n")) { - result = result.slice(1, result.length); + if (htmlContent.length > 0 && htmlContent.startsWith("\n")) { + htmlContent = htmlContent.slice(1, htmlContent.length); } - return result; + // Always return an object with html property + const output: HtmlOutput = { html: htmlContent }; + + // Handle different HTML generation modes + const mode = settings.htmlGenerationMode || "html"; + + if (mode !== "html") { + // Generate component code for non-html modes + output.html = generateComponentCode(htmlContent, sceneNode, mode); + + // For svelte mode, we don't need separate CSS as it's included in the component + if (mode === "svelte" && Object.keys(cssCollection).length > 0) { + // CSS is already included in the Svelte component + } + } else if (Object.keys(cssCollection).length > 0) { + // For plain HTML with CSS, include CSS separately + output.css = getCollectedCSS(); + } + + return output; }; export const generateHTMLPreview = async ( nodes: SceneNode[], settings: PluginSettings, - code?: string, ): Promise => { - const htmlCodeAlreadyGenerated = - settings.framework === "HTML" && settings.jsx === false && code; - const htmlCode = htmlCodeAlreadyGenerated - ? code - : await htmlMain( - nodes, - { - ...settings, - jsx: false, - }, - true, - ); + let result = await htmlMain( + nodes, + { + ...settings, + htmlGenerationMode: "html", + }, + nodes.length > 1 ? false : true, + ); + + if (nodes.length > 1) { + result.html = `
${result.html}
`; + } return { size: { - width: nodes[0].width, - height: nodes[0].height, + width: Math.max(...nodes.map((node) => node.width)), + height: nodes.reduce((sum, node) => sum + node.height, 0), }, - content: htmlCode, + content: result.html, }; }; -// todo lint idea: replace BorderRadius.only(topleft: 8, topRight: 8) with BorderRadius.horizontal(8) const htmlWidgetGenerator = async ( sceneNode: ReadonlyArray, settings: HTMLSettings, @@ -75,32 +386,45 @@ const htmlWidgetGenerator = async ( }; const convertNode = (settings: HTMLSettings) => async (node: SceneNode) => { - const altNode = await renderAndAttachSVG(node); - if (altNode.svg) return htmlWrapSVG(altNode, settings); + if (settings.embedVectors && (node as any).canBeFlattened) { + const altNode = await renderAndAttachSVG(node); + if (altNode.svg) { + return htmlWrapSVG(altNode, settings); + } + } - switch (node.type) { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": - return htmlContainer(node, "", [], settings); + return await htmlContainer(node, "", [], settings); case "GROUP": - return htmlGroup(node, settings); + return await htmlGroup(node, settings); case "FRAME": case "COMPONENT": case "INSTANCE": case "COMPONENT_SET": - return htmlFrame(node, settings); + case "SLOT": + return await htmlFrame(node, settings); case "SECTION": - return htmlSection(node, settings); + return await htmlSection(node, settings); case "TEXT": return htmlText(node, settings); case "LINE": return htmlLine(node, settings); case "VECTOR": - addWarning("VectorNodes are not fully supported in HTML"); - return htmlAsset(node, settings); + if (!settings.embedVectors && !isPreviewGlobal) { + addWarning("Vector is not supported"); + } + return await htmlContainer( + { ...node, type: "RECTANGLE" } as any, + "", + [], + settings, + ); default: + addWarning(`${node.type} node is not supported`); + return ""; } - return ""; }; const htmlWrapSVG = ( @@ -108,11 +432,16 @@ const htmlWrapSVG = ( settings: HTMLSettings, ): string => { if (node.svg === "") return ""; + const builder = new HtmlDefaultBuilder(node, settings) .addData("svg-wrapper") .position(); - return `\n\n${node.svg ?? ""}
`; + // 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${indentString(node.svg ?? "")}
`; }; const htmlGroup = async ( @@ -132,27 +461,60 @@ const htmlGroup = async ( if (builder.styles) { const attr = builder.build(); - const generator = await htmlWidgetGenerator(node.children, settings); - return `\n${indentString(generator)}\n`; } - return await htmlWidgetGenerator(node.children, settings); }; -// this was split from htmlText to help the UI part, where the style is needed (without

). +// For htmlText and htmlContainer, use the htmlGenerationMode to determine styling approach const htmlText = (node: TextNode, settings: HTMLSettings): string => { let layoutBuilder = new HtmlTextBuilder(node, settings) .commonPositionStyles() - .textAlign(); + .textTrim() + .textAlignHorizontal() + .textAlignVertical(); - const styledHtml = layoutBuilder.getTextSegments(node.id); + const styledHtml = layoutBuilder.getTextSegments(node); previousExecutionCache.push(...styledHtml); + const mode = settings.htmlGenerationMode || "html"; + + // For styled-components mode + if (mode === "styled-components") { + // Build wrapper to store in cssCollection + layoutBuilder.build(); + + 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 let content = ""; if (styledHtml.length === 1) { - layoutBuilder.addStyles(styledHtml[0].style); + // For HTML and React modes, we use inline styles + if (mode === "html" || mode === "jsx") { + layoutBuilder.addStyles(styledHtml[0].style); + } + content = styledHtml[0].text; const additionalTag = @@ -164,10 +526,14 @@ const htmlText = (node: TextNode, settings: HTMLSettings): string => { if (additionalTag) { content = `<${additionalTag}>${content}`; + } else if (mode === "svelte" && styledHtml[0].className) { + // Use span just like styled-components for consistency + content = `${content}`; } } else { content = styledHtml .map((style) => { + // Always use span for multi-segment text in Svelte mode const tag = style.openTypeFeatures.SUBS === true ? "sub" @@ -175,11 +541,17 @@ const htmlText = (node: TextNode, settings: HTMLSettings): string => { ? "sup" : "span"; + // Use class name for Svelte with same approach as styled-components + if (mode === "svelte" && style.className) { + return `${style.text}`; + } + return `<${tag} style="${style.style}">${style.text}`; }) .join(""); } + // Always use div as container to be consistent with styled-components return `\n${content}`; }; @@ -187,59 +559,21 @@ const htmlFrame = async ( node: SceneNode & BaseFrameMixin, settings: HTMLSettings, ): Promise => { - const childrenStr = await htmlWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout(node, settings.optimizeLayout), - settings, - ); + const childrenStr = await htmlWidgetGenerator(node.children, settings); if (node.layoutMode !== "NONE") { - const rowColumn = htmlAutoLayoutProps(node, node, settings); - return htmlContainer(node, childrenStr, rowColumn, settings); - } else { - if (settings.optimizeLayout && node.inferredAutoLayout !== null) { - const rowColumn = htmlAutoLayoutProps( - node, - node.inferredAutoLayout, - settings, - ); - return htmlContainer(node, childrenStr, rowColumn, settings); - } - - // node.layoutMode === "NONE" && node.children.length > 1 - // children needs to be absolute - return htmlContainer(node, childrenStr, [], settings); - } -}; - -const htmlAsset = (node: SceneNode, settings: HTMLSettings): string => { - if (!("opacity" in node) || !("layoutAlign" in node) || !("fills" in node)) { - return ""; - } - - const builder = new HtmlDefaultBuilder(node, settings) - .commonPositionStyles() - .commonShapeStyles(); - - let tag = "div"; - let src = ""; - if (retrieveTopFill(node.fills)?.type === "IMAGE") { - addWarning("Image fills are replaced with placeholders"); - tag = "img"; - src = ` src="https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}"`; - } - - if (tag === "div") { - return `\n`; + const rowColumn = htmlAutoLayoutProps(node, settings); + return await htmlContainer(node, childrenStr, rowColumn, settings); } - return `\n<${tag}${builder.build()}${src} />`; + // node.layoutMode === "NONE" && node.children.length > 1 + // children needs to be absolute + return await htmlContainer(node, childrenStr, [], settings); }; // properties named propSomething always take care of "," // sometimes a property might not exist, so it doesn't add "," -const htmlContainer = ( +const htmlContainer = async ( node: SceneNode & SceneNodeMixin & BlendMixin & @@ -249,11 +583,9 @@ const htmlContainer = ( children: string, additionalStyles: string[] = [], settings: HTMLSettings, -): string => { +): Promise => { // ignore the view when size is zero or less - // while technically it shouldn't get less than 0, due to rounding errors, - // it can get to values like: -0.000004196293048153166 - if (node.width < 0 || node.height <= 0) { + if (node.width <= 0 || node.height <= 0) { return children; } @@ -264,31 +596,59 @@ const htmlContainer = ( if (builder.styles || additionalStyles) { let tag = "div"; let src = ""; - if (retrieveTopFill(node.fills)?.type === "IMAGE") { - addWarning("Image fills are replaced with placeholders"); - if (!("children" in node) || node.children.length === 0) { - tag = "img"; - src = ` src="https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}"`; + + if (nodeHasImageFill(node)) { + const altNode = node as AltNode; + const hasChildren = "children" in node && node.children.length > 0; + let imgUrl = ""; + + if ( + settings.embedImages && + (settings as PluginSettings).framework === "HTML" + ) { + imgUrl = (await exportNodeAsBase64PNG(altNode, hasChildren)) ?? ""; } else { + imgUrl = getPlaceholderImage(node.width, node.height); + } + + if (hasChildren) { builder.addStyles( formatWithJSX( "background-image", - settings.jsx, - `url(https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)})`, + settings.htmlGenerationMode === "jsx", + `url(${imgUrl})`, ), ); + } else { + tag = "img"; + src = ` src="${imgUrl}"`; } } const build = builder.build(additionalStyles); + const mode = settings.htmlGenerationMode || "html"; + + // For styled-components mode + if (mode === "styled-components" && builder.cssClassName) { + const componentName = cssCollection[builder.cssClassName].componentName; + + 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 if (children) { return `\n<${tag}${build}${src}>${indentString(children)}\n`; - } else if (selfClosingTags.includes(tag) || settings.jsx) { + } else if ( + selfClosingTags.includes(tag) || + settings.htmlGenerationMode === "jsx" + ) { return `\n<${tag}${build}${src} />`; } else { return `\n<${tag}${build}${src}>`; @@ -327,7 +687,7 @@ export const htmlCodeGenTextStyles = (settings: HTMLSettings) => { const result = previousExecutionCache .map( (style) => - `// ${style.text}\n${style.style.split(settings.jsx ? "," : ";").join(";\n")}`, + `// ${style.text}\n${style.style.split(settings.htmlGenerationMode === "jsx" ? "," : ";").join(";\n")}`, ) .join("\n---\n"); diff --git a/packages/backend/src/html/htmlTextBuilder.ts b/packages/backend/src/html/htmlTextBuilder.ts index f66d256c..ebb62794 100644 --- a/packages/backend/src/html/htmlTextBuilder.ts +++ b/packages/backend/src/html/htmlTextBuilder.ts @@ -1,32 +1,57 @@ -import { formatMultipleJSX, formatWithJSX } from "../common/parseJSX"; +import { formatMultipleJSX, formatWithJSX, escapeJSXText } from "../common/parseJSX"; import { HtmlDefaultBuilder } from "./htmlDefaultBuilder"; -import { globalTextStyleSegments } from "../altNodes/altConversion"; import { htmlColorFromFills } from "./builderImpl/htmlColor"; import { commonLetterSpacing, commonLineHeight, } from "../common/commonTextHeightSpacing"; -import { HTMLSettings } from "types"; +import { HTMLSettings, StyledTextSegmentSubset } from "types"; +import { + cssCollection, + generateUniqueClassName, + stylesToCSS, + getComponentName, +} from "./htmlMain"; export class HtmlTextBuilder extends HtmlDefaultBuilder { constructor(node: TextNode, settings: HTMLSettings) { super(node, settings); } - getTextSegments(id: string): { + // Override htmlElement to ensure text nodes use paragraph elements + get htmlElement(): string { + return "p"; + } + + getTextSegments(node: TextNode): { style: string; text: string; openTypeFeatures: { [key: string]: boolean }; + className?: string; + componentName?: string; }[] { - const segments = globalTextStyleSegments[id]; + const segments = (node as any) + .styledTextSegments as StyledTextSegmentSubset[]; if (!segments) { return []; } - return segments.map((segment) => { + return segments.map((segment, index) => { + // Prepare additional CSS properties from layer blur and drop shadow effects. + const additionalStyles: { [key: string]: string } = {}; + + const layerBlurStyle = this.getLayerBlurStyle(); + if (layerBlurStyle) { + additionalStyles.filter = layerBlurStyle; + } + const textShadowStyle = this.getTextShadowStyle(); + if (textShadowStyle) { + additionalStyles["text-shadow"] = textShadowStyle; + } + const styleAttributes = formatMultipleJSX( { - color: htmlColorFromFills(segment.fills), + color: htmlColorFromFills(segment.fills as any), "font-size": segment.fontSize, "font-family": segment.fontName.family, "font-style": this.getFontStyle(segment.fontName.style), @@ -40,16 +65,65 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { ), // "text-indent": segment.indentation, "word-wrap": "break-word", + ...additionalStyles, }, this.isJSX, ); - const charsWithLineBreak = segment.characters.split("\n").join("
"); - return { + let chars = segment.characters; + if (this.needsJSXTextEscaping) { + chars = escapeJSXText(chars); + } + const charsWithLineBreak = chars.split("\n").join("
"); + const result: any = { style: styleAttributes, text: charsWithLineBreak, openTypeFeatures: segment.openTypeFeatures, }; + + // Add class name and component name for Svelte or styled-components modes + const mode = this.settings.htmlGenerationMode; + if ( + (mode === "svelte" || mode === "styled-components") && + styleAttributes + ) { + // Use the pre-assigned uniqueId from the segment if available, + // or generate one if not (as a fallback) + const segmentName = + (segment as any).uniqueId || + `${((node as any).uniqueName || node.name || "text").replace(/[^a-zA-Z0-9_-]/g, "").toLowerCase()}_text_${(index + 1).toString().padStart(2, "0")}`; + + const className = generateUniqueClassName(segmentName); + result.className = className; + + // Convert styles to CSS format + const cssStyles = stylesToCSS( + styleAttributes + .split(this.isJSX ? "," : ";") + .map((style) => style.trim()) + .filter((style) => style), + this.isJSX, + ); + + // 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, + nodeType: "TEXT", + element: elementTag, + componentName: componentName, + }; + + if (mode === "styled-components") { + result.componentName = componentName; + } + } + + return result; }); } @@ -61,6 +135,16 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { return this; } + textTrim(): this { + if ("leadingTrim" in this.node && this.node.leadingTrim === "CAP_HEIGHT") { + this.addStyles(formatWithJSX("text-box-trim", this.isJSX, "trim-both")); + this.addStyles( + formatWithJSX("text-box-edge", this.isJSX, "cap alphabetic"), + ); + } + return this; + } + textDecoration(textDecoration: TextDecoration): string { switch (textDecoration) { case "STRIKETHROUGH": @@ -116,7 +200,7 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { return ""; } - textAlign(): this { + textAlignHorizontal(): this { const node = this.node as TextNode; // if alignHorizontal is LEFT, don't do anything because that is native @@ -139,4 +223,72 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { } return this; } + + textAlignVertical(): this { + const node = this.node as TextNode; + if (node.textAlignVertical && node.textAlignVertical !== "TOP") { + let alignItems = ""; + switch (node.textAlignVertical) { + case "CENTER": + alignItems = "center"; + break; + case "BOTTOM": + alignItems = "flex-end"; + break; + } + if (alignItems) { + this.addStyles( + formatWithJSX("justify-content", this.isJSX, alignItems), + ); + this.addStyles(formatWithJSX("display", this.isJSX, "flex")); + this.addStyles(formatWithJSX("flex-direction", this.isJSX, "column")); + } + } + return this; + } + + /** + * Returns a CSS filter value for layer blur. + */ + private getLayerBlurStyle(): string { + if (this.node && (this.node as TextNode).effects) { + const effects = (this.node as TextNode).effects; + const blurEffect = effects.find( + (effect) => + effect.type === "LAYER_BLUR" && + effect.visible !== false && + effect.radius > 0, + ); + if (blurEffect && blurEffect.radius) { + return `blur(${blurEffect.radius}px)`; + } + } + return ""; + } + + /** + * Returns a CSS text-shadow value if a drop shadow effect is applied. + */ + private getTextShadowStyle(): string { + if (this.node && (this.node as TextNode).effects) { + const effects = (this.node as TextNode).effects; + const dropShadow = effects.find( + (effect) => effect.type === "DROP_SHADOW" && effect.visible !== false, + ); + if (dropShadow) { + const ds = dropShadow as DropShadowEffect; // Type narrow the effect. + const offsetX = Math.round(ds.offset.x); + const offsetY = Math.round(ds.offset.y); + const blurRadius = Math.round(ds.radius); + const r = Math.round(ds.color.r * 255); + const g = Math.round(ds.color.g * 255); + const b = Math.round(ds.color.b * 255); + const a = ds.color.a; + return `${offsetX}px ${offsetY}px ${blurRadius}px rgba(${r}, ${g}, ${b}, ${a.toFixed( + 2, + )})`; + } + } + return ""; + } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 90f21d61..fcf4dc94 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -2,6 +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 { convertNodesToAltNodes as convertIntoNodes } from "./altNodes/altConversion"; export * from "./messaging"; diff --git a/packages/backend/src/messaging.ts b/packages/backend/src/messaging.ts index 64adcd45..3476ed3b 100644 --- a/packages/backend/src/messaging.ts +++ b/packages/backend/src/messaging.ts @@ -1,16 +1,29 @@ import { ConversionMessage, + ConversionStartMessage, EmptyMessage, ErrorMessage, PluginSettings, 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); +export const postConversionStart = () => + postBackendMessage({ type: "conversionStart" } as ConversionStartMessage); + export const postConversionComplete = ( conversionData: ConversionMessage | Omit, ) => postBackendMessage({ ...conversionData, type: "code" }); diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts b/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts index 2fb1b310..41650b54 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts @@ -1,5 +1,6 @@ import { SwiftUIModifier } from "types"; -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { AltNode } from "../../alt_api_types"; /** * https://developer.apple.com/documentation/swiftui/view/opacity(_:) @@ -8,7 +9,7 @@ export const swiftuiOpacity = ( node: MinimalBlendMixin, ): SwiftUIModifier | null => { if (node.opacity !== undefined && node.opacity !== 1) { - return ["opacity", sliceNum(node.opacity)]; + return ["opacity", numberToFixedString(node.opacity)]; } return null; }; @@ -29,9 +30,10 @@ export const swiftuiVisibility = ( /** * https://developer.apple.com/documentation/swiftui/modifiedcontent/rotationeffect(_:anchor:) */ -export const swiftuiRotation = (node: LayoutMixin): SwiftUIModifier | null => { - if (node.rotation !== undefined && Math.round(node.rotation) !== 0) { - return ["rotationEffect", `.degrees(${sliceNum(node.rotation)})`]; +export const swiftuiRotation = (node: AltNode): SwiftUIModifier | null => { + const rotation = (node.rotation || 0) + (node.cumulativeRotation || 0); + if (Math.round(rotation) !== 0) { + return ["rotationEffect", `.degrees(${numberToFixedString(rotation)})`]; } return null; }; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts b/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts index fc44deab..404f41e4 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts @@ -1,6 +1,6 @@ import { commonStroke } from "./../../common/commonStroke"; import { getCommonRadius } from "../../common/commonRadius"; -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { swiftUISolidColor } from "./swiftuiColor"; import { SwiftUIElement } from "./swiftuiParser"; import { SwiftUIModifier } from "types"; @@ -50,7 +50,7 @@ export const swiftuiBorder = (node: SceneNode): string[] | null => { const strokeModifier: SwiftUIModifier = [ "stroke", - `${strokeColor}, lineWidth: ${sliceNum(width)}`, + `${strokeColor}, lineWidth: ${numberToFixedString(width)}`, ]; if (strokeColor) { @@ -84,9 +84,9 @@ const strokeInset = ( ): [string, string | null] => { switch (node.strokeAlign) { case "INSIDE": - return ["inset", `by: ${sliceNum(width)}`]; + return ["inset", `by: ${numberToFixedString(width)}`]; case "OUTSIDE": - return ["inset", `by: -${sliceNum(width)}`]; + return ["inset", `by: -${numberToFixedString(width)}`]; case "CENTER": return ["inset", null]; } @@ -104,7 +104,7 @@ export const swiftuiCornerRadius = (node: SceneNode): string => { const radius = getCommonRadius(node); if ("all" in radius) { if (radius.all > 0) { - return sliceNum(radius.all); + return numberToFixedString(radius.all); } else { return ""; } @@ -119,7 +119,7 @@ export const swiftuiCornerRadius = (node: SceneNode): string => { ); if (maxBorder > 0) { - return sliceNum(maxBorder); + return numberToFixedString(maxBorder); } return ""; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts b/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts index 90e49090..c871a5af 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts @@ -1,9 +1,12 @@ import { retrieveTopFill } from "../../common/retrieveFill"; import { gradientAngle } from "../../common/color"; import { nearestValue } from "../../tailwind/conversionTables"; -import { sliceNum } from "../../common/numToAutoFixed"; -import { addWarning } from "../../common/commonConversionWarnings"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +/** + * Retrieve the SwiftUI color for a Paint object + * @param fill The paint object to extract color from + */ export const swiftUISolidColor = (fill: Paint): string => { if (fill && fill.type === "SOLID") { return swiftuiColor(fill.color, fill.opacity ?? 1.0); @@ -21,7 +24,30 @@ export const swiftUISolidColor = (fill: Paint): string => { return ""; }; +/** + * Retrieve the SwiftUI solid color when existent, otherwise "" + * @param node SceneNode containing the property to examine + * @param propertyPath Property path to extract fills from (e.g., 'fills', 'strokes') or direct fills array + */ export const swiftuiSolidColor = ( + node: SceneNode, + propertyPath: string | keyof SceneNode, +): string => { + let fills: ReadonlyArray | PluginAPI["mixed"]; + + // Property path string provided + fills = node[propertyPath as keyof SceneNode] as + | ReadonlyArray + | PluginAPI["mixed"]; + + return swiftuiSolidColorFromDirectFills(fills); +}; + +/** + * Retrieve the SwiftUI solid color directly from fills when existent, otherwise "" + * @param fills The fills array to process + */ +export const swiftuiSolidColorFromDirectFills = ( fills: ReadonlyArray | PluginAPI["mixed"], ): string => { const fill = retrieveTopFill(fills); @@ -46,35 +72,21 @@ export const swiftuiSolidColor = ( return ""; }; -export const swiftuiBackground = ( - node: SceneNode, - fills: ReadonlyArray | PluginAPI["mixed"], -): string => { - const fill = retrieveTopFill(fills); - - if (fill && fill.type === "SOLID") { - // opacity should only be null on set, not on get. But better be prevented. - const opacity = fill.opacity ?? 1.0; - return swiftuiColor(fill.color, opacity); - } else if (fill?.type === "GRADIENT_LINEAR") { - return swiftuiGradient(fill); - } else if (fill?.type === "IMAGE") { - addWarning("Image fills are replaced with placeholders"); - return `AsyncImage(url: URL(string: "https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}"))`; +/** + * Generate a SwiftUI gradient from a GradientPaint object + * @param fill The gradient fill object from Figma + * @returns SwiftUI gradient code as a string + */ +export const swiftuiGradient = (fill: GradientPaint): string => { + if (fill.type !== "GRADIENT_LINEAR") { + return ""; // Only handling linear gradients here for simplicity } - return ""; -}; - -export const swiftuiGradient = (fill: GradientPaint): string => { - const direction = gradientDirection(gradientAngle(fill)); + const angle = gradientAngle(fill); + const direction = gradientDirection(angle); const colors = fill.gradientStops - .map((d) => { - return swiftuiColor(d.color, d.color.a); - }) + .map((d) => swiftuiColor(d.color, d.color.a)) .join(", "); return `LinearGradient(gradient: Gradient(colors: [${colors}]), ${direction})`; @@ -116,11 +128,12 @@ export const swiftuiColor = (color: RGB, opacity: number): string => { return ".white"; } - const r = `red: ${sliceNum(color.r)}`; - const g = `green: ${sliceNum(color.g)}`; - const b = `blue: ${sliceNum(color.b)}`; + const r = `red: ${numberToFixedString(color.r)}`; + const g = `green: ${numberToFixedString(color.g)}`; + const b = `blue: ${numberToFixedString(color.b)}`; - const opacityAttr = opacity !== 1.0 ? `.opacity(${sliceNum(opacity)})` : ""; + const opacityAttr = + opacity !== 1.0 ? `.opacity(${numberToFixedString(opacity)})` : ""; return `Color(${r}, ${g}, ${b})${opacityAttr}`; }; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts b/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts index 536587be..4165e48f 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts @@ -1,4 +1,4 @@ -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { SwiftUIModifier } from "types"; export const swiftuiShadow = (node: SceneNode): SwiftUIModifier | null => { @@ -20,15 +20,17 @@ export const swiftuiShadow = (node: SceneNode): SwiftUIModifier | null => { const color = shadow.color; // set color when not black with 0.25 of opacity, which is the Figma default. Round the alpha now to avoid rounding issues. - const a = sliceNum(color.a); - const r = sliceNum(color.r); - const g = sliceNum(color.g); - const b = sliceNum(color.b); + const a = numberToFixedString(color.a); + const r = numberToFixedString(color.r); + const g = numberToFixedString(color.g); + const b = numberToFixedString(color.b); comp.push(`color: Color(red: ${r}, green: ${g}, blue: ${b}, opacity: ${a})`); - comp.push(`radius: ${sliceNum(shadow.radius)}`); + comp.push(`radius: ${numberToFixedString(shadow.radius)}`); - const x = shadow.offset.x > 0 ? `x: ${sliceNum(shadow.offset.x)}` : ""; - const y = shadow.offset.y > 0 ? `y: ${sliceNum(shadow.offset.y)}` : ""; + const x = + shadow.offset.x > 0 ? `x: ${numberToFixedString(shadow.offset.x)}` : ""; + const y = + shadow.offset.y > 0 ? `y: ${numberToFixedString(shadow.offset.y)}` : ""; // add initial comma since this is an optional paramater and radius must come first. if (x && y) { @@ -59,5 +61,5 @@ export const swiftuiBlur = (node: SceneNode): SwiftUIModifier | null => { // retrieve first blur. const blur = layerBlur[0].radius; - return ["blur", `radius: ${sliceNum(blur)})`]; + return ["blur", `radius: ${numberToFixedString(blur)})`]; }; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts b/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts index 83bb434f..35eedc74 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts @@ -1,4 +1,4 @@ -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { commonPadding } from "../../common/commonPadding"; import { SwiftUIModifier } from "types"; @@ -18,22 +18,22 @@ export const swiftuiPadding = ( if (padding.all === 0) { return null; } - return ["padding", sliceNum(padding.all)]; + return ["padding", numberToFixedString(padding.all)]; } if ("horizontal" in padding) { - const vertical = sliceNum(padding.vertical); - const horizontal = sliceNum(padding.horizontal); + const vertical = numberToFixedString(padding.vertical); + const horizontal = numberToFixedString(padding.horizontal); return [ "padding", `EdgeInsets(top: ${vertical}, leading: ${horizontal}, bottom: ${vertical}, trailing: ${horizontal})`, ]; } - const top = sliceNum(padding.top); - const left = sliceNum(padding.left); - const bottom = sliceNum(padding.bottom); - const right = sliceNum(padding.right); + const top = numberToFixedString(padding.top); + const left = numberToFixedString(padding.left); + const bottom = numberToFixedString(padding.bottom); + const right = numberToFixedString(padding.right); return [ "padding", `EdgeInsets(top: ${top}, leading: ${left}, bottom: ${bottom}, trailing: ${right})`, diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts b/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts index febe164e..d0fe784b 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts @@ -1,41 +1,40 @@ import { nodeSize } from "../../common/nodeWidthHeight"; -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; export const swiftuiSize = ( node: SceneNode, - optimizeLayout: boolean = false, -): { width: string; height: string } => { - const size = nodeSize(node, optimizeLayout); +): { width: string; height: string; constraints: string[] } => { + const size = nodeSize(node); - // if width is set as maxWidth, height must also be set as maxHeight (not height) - const shouldExtend = size.height === "fill" || size.width === "fill"; + const constraintProps: string[] = []; + let width = ""; + let height = ""; - // this cast will always be true, since nodeWidthHeight was called with false to relative. - let propWidth = ""; + // Handle width and height if (typeof size.width === "number") { - const w = sliceNum(size.width); - - if (shouldExtend) { - propWidth = `minWidth: ${w}, maxWidth: ${w}`; - } else { - propWidth = `width: ${w}`; - } - } else if (size.width === "fill") { - propWidth = `maxWidth: .infinity`; + width = `width: ${numberToFixedString(size.width)}`; } - - let propHeight = ""; if (typeof size.height === "number") { - const h = sliceNum(size.height); + height = `height: ${numberToFixedString(size.height)}`; + } - if (shouldExtend) { - propHeight = `minHeight: ${h}, maxHeight: ${h}`; - } else { - propHeight = `height: ${h}`; - } - } else if (size.height === "fill") { - propHeight = `maxHeight: .infinity`; + // Handle min/max constraints + if (node.minWidth !== undefined && node.minWidth !== null) { + constraintProps.push(`minWidth: ${numberToFixedString(node.minWidth)}`); + } + if (node.maxWidth !== undefined && node.maxWidth !== null) { + constraintProps.push(`maxWidth: ${numberToFixedString(node.maxWidth)}`); + } + if (node.minHeight !== undefined && node.minHeight !== null) { + constraintProps.push(`minHeight: ${numberToFixedString(node.minHeight)}`); + } + if (node.maxHeight !== undefined && node.maxHeight !== null) { + constraintProps.push(`maxHeight: ${numberToFixedString(node.maxHeight)}`); } - return { width: propWidth, height: propHeight }; + return { + width, + height, + constraints: constraintProps, + }; }; diff --git a/packages/backend/src/swiftui/swiftuiDefaultBuilder.ts b/packages/backend/src/swiftui/swiftuiDefaultBuilder.ts index eb5eb0ae..c082d38b 100644 --- a/packages/backend/src/swiftui/swiftuiDefaultBuilder.ts +++ b/packages/backend/src/swiftui/swiftuiDefaultBuilder.ts @@ -1,10 +1,9 @@ -import { sliceNum } from "./../common/numToAutoFixed"; +import { numberToFixedString } from "./../common/numToAutoFixed"; import { swiftuiBlur, swiftuiShadow } from "./builderImpl/swiftuiEffects"; import { swiftuiBorder, swiftuiCornerRadius, } from "./builderImpl/swiftuiBorder"; -import { swiftuiBackground } from "./builderImpl/swiftuiColor"; import { swiftuiPadding } from "./builderImpl/swiftuiPadding"; import { swiftuiSize } from "./builderImpl/swiftuiSize"; @@ -20,6 +19,7 @@ import { } from "../common/commonPosition"; import { SwiftUIElement } from "./builderImpl/swiftuiParser"; import { SwiftUIModifier } from "types"; +import { swiftuiSolidColor } from "./builderImpl/swiftuiColor"; export class SwiftuiDefaultBuilder { element: SwiftUIElement; @@ -36,8 +36,8 @@ export class SwiftuiDefaultBuilder { }); } - commonPositionStyles(node: SceneNode, optimizeLayout: boolean): this { - this.position(node, optimizeLayout); + commonPositionStyles(node: SceneNode): this { + this.position(node); if ("layoutAlign" in node && "opacity" in node) { this.blend(node); } @@ -75,8 +75,8 @@ export class SwiftuiDefaultBuilder { return { centerX: centerBasedX, centerY: centerBasedY }; } - position(node: SceneNode, optimizeLayout: boolean): this { - if (commonIsAbsolutePosition(node, optimizeLayout)) { + position(node: SceneNode): this { + if (commonIsAbsolutePosition(node)) { const { x, y } = getCommonPositionValue(node); const { centerX, centerY } = this.topLeftToCenterOffset( x, @@ -87,7 +87,7 @@ export class SwiftuiDefaultBuilder { this.pushModifier([ `offset`, - `x: ${sliceNum(centerX)}, y: ${sliceNum(centerY)}`, + `x: ${numberToFixedString(centerX)}, y: ${numberToFixedString(centerY)}`, ]); } return this; @@ -105,7 +105,7 @@ export class SwiftuiDefaultBuilder { shapeBackground(node: SceneNode): this { if ("fills" in node) { - const background = swiftuiBackground(node, node.fills); + const background = swiftuiSolidColor(node, "fills"); if (background) { this.pushModifier([`background`, background]); } @@ -138,23 +138,23 @@ export class SwiftuiDefaultBuilder { return this; } - size(node: SceneNode, optimize: boolean): this { - const { width, height } = swiftuiSize(node, optimize); - const sizes = [width, height].filter((d) => d); - if (sizes.length > 0) { - this.pushModifier([`frame`, sizes.join(", ")]); + size(node: SceneNode): this { + const { width, height, constraints } = swiftuiSize(node); + if (width || height) { + this.pushModifier([`frame`, [width, height].filter(Boolean).join(", ")]); + } + + // Add constraints if any exist + if (constraints.length > 0) { + this.pushModifier([`frame`, constraints.join(", ")]); } return this; } - autoLayoutPadding(node: SceneNode, optimizeLayout: boolean): this { + autoLayoutPadding(node: SceneNode): this { if ("paddingLeft" in node) { - this.pushModifier( - swiftuiPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node, - ), - ); + this.pushModifier(swiftuiPadding(node)); } return this; } diff --git a/packages/backend/src/swiftui/swiftuiMain.ts b/packages/backend/src/swiftui/swiftuiMain.ts index 91db1866..b864df34 100644 --- a/packages/backend/src/swiftui/swiftuiMain.ts +++ b/packages/backend/src/swiftui/swiftuiMain.ts @@ -1,10 +1,13 @@ import { indentString } from "../common/indentString"; -import { stringToClassName, sliceNum } from "../common/numToAutoFixed"; +import { + stringToClassName, + numberToFixedString, +} from "../common/numToAutoFixed"; import { SwiftuiTextBuilder } from "./swiftuiTextBuilder"; import { SwiftuiDefaultBuilder } from "./swiftuiDefaultBuilder"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; import { PluginSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; +import { getVisibleNodes } from "../common/nodeVisibility"; let localSettings: PluginSettings; let previousExecutionCache: string[]; @@ -63,11 +66,11 @@ const swiftuiWidgetGenerator = ( indentLevel: number, ): string => { // filter non visible nodes. This is necessary at this step because conversion already happened. - const visibleSceneNode = sceneNode.filter((d) => d.visible); + const visibleSceneNode = getVisibleNodes(sceneNode); let comp: string[] = []; - visibleSceneNode.forEach((node, index) => { - switch (node.type) { + visibleSceneNode.forEach((node) => { + switch ((node as any).type) { case "RECTANGLE": case "ELLIPSE": case "LINE": @@ -81,6 +84,7 @@ const swiftuiWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(swiftuiFrame(node, indentLevel)); break; case "TEXT": @@ -89,6 +93,7 @@ const swiftuiWidgetGenerator = ( case "VECTOR": addWarning("VectorNodes are not supported in SwiftUI"); break; + case "SLICE": default: break; } @@ -121,12 +126,12 @@ export const swiftuiContainer = ( const result = new SwiftuiDefaultBuilder(kind) .shapeForeground(node) - .autoLayoutPadding(node, localSettings.optimizeLayout) - .size(node, localSettings.optimizeLayout) + .autoLayoutPadding(node) + .size(node) .shapeBackground(node) .cornerRadius(node) .shapeBorder(node) - .commonPositionStyles(node, localSettings.optimizeLayout) + .commonPositionStyles(node) .effects(node) .build(kind === stack ? -2 : 0); @@ -148,9 +153,7 @@ const swiftuiText = (node: TextNode): string => { const result = new SwiftuiTextBuilder().createText(node); previousExecutionCache.push(result.build()); - return result - .commonPositionStyles(node, localSettings.optimizeLayout) - .build(); + return result.commonPositionStyles(node).build(); }; const swiftuiFrame = ( @@ -162,12 +165,7 @@ const swiftuiFrame = ( node.children.length > 1 ? indentLevel + 1 : indentLevel, ); - const anyStack = createDirectionalStack( - children, - localSettings.optimizeLayout && node.inferredAutoLayout !== null - ? node.inferredAutoLayout - : node, - ); + const anyStack = createDirectionalStack(children, node); return swiftuiContainer(node, anyStack); }; @@ -222,7 +220,7 @@ export const generateSwiftViewCode = ( .filter(([, value]) => value !== "") .map( ([key, value]) => - `${key}: ${typeof value === "number" ? sliceNum(value) : value}`, + `${key}: ${typeof value === "number" ? numberToFixedString(value) : value}`, ); const compactPropertiesArray = propertiesArray.join(", "); @@ -245,23 +243,14 @@ const widgetGeneratorWithLimits = ( ) => { if (node.children.length < 10) { // standard way - return swiftuiWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout( - node, - localSettings.optimizeLayout, - ), - indentLevel, - ); + return swiftuiWidgetGenerator(node.children, indentLevel); } const chunk = 10; let strBuilder = ""; - const slicedChildren = commonSortChildrenWhenInferredAutoLayout( - node, - localSettings.optimizeLayout, - ).slice(0, 100); + 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/swiftui/swiftuiTextBuilder.ts b/packages/backend/src/swiftui/swiftuiTextBuilder.ts index 38dcedb3..549e50d8 100644 --- a/packages/backend/src/swiftui/swiftuiTextBuilder.ts +++ b/packages/backend/src/swiftui/swiftuiTextBuilder.ts @@ -1,4 +1,4 @@ -import { sliceNum } from "../common/numToAutoFixed"; +import { numberToFixedString } from "../common/numToAutoFixed"; import { commonLetterSpacing, commonLineHeight, @@ -6,12 +6,13 @@ import { import { SwiftuiDefaultBuilder } from "./swiftuiDefaultBuilder"; import { swiftuiWeightMatcher } from "./builderImpl/swiftuiTextWeight"; import { swiftuiSize } from "./builderImpl/swiftuiSize"; -import { globalTextStyleSegments } from "../altNodes/altConversion"; import { SwiftUIElement } from "./builderImpl/swiftuiParser"; import { parseTextAsCode } from "../flutter/flutterTextBuilder"; -import { swiftuiSolidColor } from "./builderImpl/swiftuiColor"; +import { swiftuiSolidColorFromDirectFills } from "./builderImpl/swiftuiColor"; +import { StyledTextSegmentSubset } from "types"; export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { + node?: TextNode; modifiers: string[] = []; constructor(kind: string = "Text") { @@ -41,7 +42,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { } textColor(fills: Paint[]): string { - const fillColor = swiftuiSolidColor(fills); + const fillColor = swiftuiSolidColorFromDirectFills(fills); if (fillColor) { return fillColor; } @@ -80,6 +81,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { }; createText(node: TextNode): this { + this.node = node; let alignHorizontal = node.textAlignHorizontal?.toString()?.toLowerCase() ?? "left"; alignHorizontal = @@ -90,7 +92,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { // alignHorizontal !== "left" ? `TextAlign.${alignHorizontal}` : "", // }; - const segments = this.getTextSegments(node.id, node.characters); + const segments = this.getTextSegments(node, node.characters); if (segments) { this.element = segments; } else { @@ -100,8 +102,9 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { return this; } - getTextSegments(id: string, characters: string): SwiftUIElement | null { - const segments = globalTextStyleSegments[id]; + getTextSegments(node: TextNode, characters: string): SwiftUIElement | null { + const segments = (node as any) + .styledTextSegments as StyledTextSegmentSubset[]; if (!segments) { return null; } @@ -109,7 +112,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { const segment = segments[0]; // return segments.map((segment) => { - const fontSize = sliceNum(segment.fontSize); + const fontSize = numberToFixedString(segment.fontSize); const fontFamily = segment.fontName.family; const fontWeight = this.fontWeight(segment.fontWeight); const lineHeight = this.lineHeight(segment.lineHeight, segment.fontSize); @@ -140,6 +143,16 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { .addModifier([this.textStyle(segment.fontName.style), ""]) .addModifier(["foregroundColor", this.textColor(segment.fills)]); + const blurMod = this.textBlur(); + if (blurMod !== "") { + element.addModifier([blurMod, ""]); + } + + const shadowMod = this.textShadow(); + if (shadowMod !== "") { + element.addModifier([shadowMod, ""]); + } + return element; // }); } @@ -150,7 +163,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { ): string | null => { const value = commonLetterSpacing(letterSpacing, fontSize); if (value > 0) { - return sliceNum(value); + return numberToFixedString(value); } return null; }; @@ -160,13 +173,13 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { lineHeight = (lineHeight: LineHeight, fontSize: number): string | null => { const value = commonLineHeight(lineHeight, fontSize); if (value > 0) { - return sliceNum(value); + return numberToFixedString(value); } return null; }; wrapTextAutoResize = (node: TextNode): string => { - const { width, height } = swiftuiSize(node); + const { width, height, constraints } = swiftuiSize(node); let comp: string[] = []; switch (node.textAutoResize) { @@ -181,6 +194,8 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { break; } + comp.push(...constraints); + if (comp.length > 0) { const align = this.textAlignment(node); return `.frame(${comp.join(", ")}${align})`; @@ -221,4 +236,41 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { // when they are centered return ""; }; + + textBlur = (): string => { + if (this.node && this.node.effects) { + const blurEffect = this.node.effects.find( + (effect) => + effect.type === "LAYER_BLUR" && + effect.visible !== false && + effect.radius > 0, + ); + if (blurEffect) { + return `.blur(radius: ${blurEffect.radius})`; + } + } + return ""; + }; + + textShadow = (): string => { + if (this.node && this.node.effects) { + const dropShadow = this.node.effects.find( + (effect) => effect.type === "DROP_SHADOW" && effect.visible !== false, + ); + if (dropShadow) { + const ds = dropShadow as DropShadowEffect; + const offsetX = Math.round(ds.offset.x); + const offsetY = Math.round(ds.offset.y); + const blurRadius = Math.round(ds.radius); + return `.shadow(color: Color(red: ${ds.color.r.toFixed( + 2, + )}, green: ${ds.color.g.toFixed(2)}, blue: ${ds.color.b.toFixed( + 2, + )}, opacity: ${ds.color.a.toFixed( + 2, + )}), radius: ${blurRadius}, x: ${offsetX}, y: ${offsetY})`; + } + } + return ""; + }; } diff --git a/packages/backend/src/tailwind/builderImpl/tailwindAutoLayout.ts b/packages/backend/src/tailwind/builderImpl/tailwindAutoLayout.ts index 8078968f..73fda204 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindAutoLayout.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindAutoLayout.ts @@ -5,6 +5,7 @@ const getFlexDirection = (node: InferredAutoLayoutResult): string => const getJustifyContent = (node: InferredAutoLayoutResult): string => { switch (node.primaryAxisAlignItems) { + case undefined: case "MIN": return "justify-start"; case "CENTER": @@ -18,6 +19,7 @@ const getJustifyContent = (node: InferredAutoLayoutResult): string => { const getAlignItems = (node: InferredAutoLayoutResult): string => { switch (node.counterAxisAlignItems) { + case undefined: case "MIN": return "items-start"; case "CENTER": @@ -34,6 +36,27 @@ const getGap = (node: InferredAutoLayoutResult): string => ? `gap-${pxToLayoutSize(node.itemSpacing)}` : ""; +const getFlexWrap = (node: InferredAutoLayoutResult): string => + node.layoutWrap === "WRAP" ? "flex-wrap" : ""; + +const getAlignContent = (node: InferredAutoLayoutResult): string => { + if (node.layoutWrap !== "WRAP") return ""; + + switch (node.counterAxisAlignItems) { + case undefined: + case "MIN": + return "content-start"; + case "CENTER": + return "content-center"; + case "MAX": + return "content-end"; + case "BASELINE": + return "content-baseline"; + default: + return "content-normal"; + } +}; + const getFlex = ( node: SceneNode, autoLayout: InferredAutoLayoutResult, @@ -47,13 +70,16 @@ const getFlex = ( export const tailwindAutoLayoutProps = ( node: SceneNode, autoLayout: InferredAutoLayoutResult, -): string => - Object.values({ - flexDirection: getFlexDirection(autoLayout), - justifyContent: getJustifyContent(autoLayout), - alignItems: getAlignItems(autoLayout), - gap: getGap(autoLayout), - flex: getFlex(node, autoLayout), - }) - .filter((value) => value !== "") - .join(" "); +): string => { + const classes = [ + getFlex(node, autoLayout), + getFlexDirection(autoLayout), + getJustifyContent(autoLayout), + getAlignItems(autoLayout), + getGap(autoLayout), + getFlexWrap(autoLayout), + getAlignContent(autoLayout), + ].filter(Boolean); + + return classes.join(" "); +}; diff --git a/packages/backend/src/tailwind/builderImpl/tailwindBlend.ts b/packages/backend/src/tailwind/builderImpl/tailwindBlend.ts index 5687a702..c246852c 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindBlend.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindBlend.ts @@ -1,5 +1,6 @@ -import { sliceNum } from "../../common/numToAutoFixed"; -import { exactValue, nearestOpacity, nearestValue } from "../conversionTables"; +import { Paint } from "../../api_types"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { exactValue, nearestOpacity } from "../conversionTables"; /** * https://tailwindcss.com/docs/opacity/ @@ -55,6 +56,35 @@ export const tailwindBlendMode = (node: MinimalBlendMixin): string => { return ""; }; +/** + * Convert a Figma background blend mode to a Tailwind bg-blend-* class + * + * @param paintArray The array of paint fills that may have blend modes + * @returns Tailwind background blend mode class if applicable + */ +export const tailwindBackgroundBlendMode = ( + paintArray: ReadonlyArray, +): string => { + if ( + paintArray.length === 0 || + paintArray.every( + (d) => d.blendMode === "NORMAL" || d.blendMode === "PASS_THROUGH", + ) + ) { + return ""; + } + + // Get the top fill's blend mode (in Figma, the last item is the top one) + const topFill = paintArray[paintArray.length - 1]; + if (topFill.blendMode === "NORMAL" || topFill.blendMode === "PASS_THROUGH") { + return ""; + } + + const blendMode = + topFill.blendMode?.toLowerCase()?.replaceAll("_", "-") || "normal"; + return `bg-blend-${blendMode}`; +}; + /** * https://tailwindcss.com/docs/visibility/ * example: invisible @@ -93,7 +123,7 @@ export const tailwindRotation = (node: LayoutMixin): string => { return `origin-top-left ${minusIfNegative}rotate-${nearest}`; } else { - return `origin-top-left rotate-[${sliceNum(-node.rotation)}deg]`; + return `origin-top-left rotate-[${numberToFixedString(-node.rotation)}deg]`; } } return ""; diff --git a/packages/backend/src/tailwind/builderImpl/tailwindBorder.ts b/packages/backend/src/tailwind/builderImpl/tailwindBorder.ts index 2e402d75..d5ce0bed 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindBorder.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindBorder.ts @@ -1,39 +1,119 @@ import { getCommonRadius } from "../../common/commonRadius"; import { commonStroke } from "../../common/commonStroke"; -import { sliceNum } from "../../common/numToAutoFixed"; -import { nearestValue, pxToBorderRadius } from "../conversionTables"; +import { + pxToBorderRadius, + pxToBorderWidth, + pxToOutline, +} from "../conversionTables"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { addWarning } from "../../common/commonConversionWarnings"; + +const getBorder = ( + weight: number, + kind: string, + useOutline: boolean = false, + isBoxShadow: boolean = false, +): string => { + // For box-shadow (inside stroke on non-autolayout), return empty string as we'll handle separately + if (isBoxShadow) { + return ""; + } + + // Use outline utilities for outside/center strokes + if (useOutline) { + const outlineWidth = pxToOutline(weight); + if (outlineWidth === null) { + return `outline outline-[${numberToFixedString(weight)}px]`; + } else { + return `outline outline-${outlineWidth}`; + } + } + + // Special case: border (without width) is 1px in Tailwind + if (weight === 1) { + return `border${kind}`; + } + + // Use border utilities for default and inside strokes + const borderWidth = pxToBorderWidth(weight); + if (borderWidth === null) { + return `border${kind}-[${numberToFixedString(weight)}px]`; + } else if (borderWidth === "DEFAULT") { + // Border is 1px + return `border${kind}`; + } else { + return `border${kind}-${borderWidth}`; + } +}; /** * https://tailwindcss.com/docs/border-width/ * example: border-2 */ -export const tailwindBorderWidth = (node: SceneNode): string => { +export const tailwindBorderWidth = ( + node: SceneNode, +): { + isOutline: boolean; + property: string; + shadowProperty?: string; // This can be removed if not used elsewhere +} => { const commonBorder = commonStroke(node); if (!commonBorder) { - return ""; + return { + isOutline: false, + property: "", + }; } - const getBorder = (weight: number, kind: string) => { - const allowedValues = [1, 2, 4, 8]; - console.log("weight", weight); - const nearest = nearestValue(weight, allowedValues); - console.log("nearest", nearest); - - if (nearest === 1) { - // special case - return `border${kind}`; - } else { - return `border${kind}-${nearest}`; - } - }; + // Check stroke alignment and layout mode + const strokeAlign = "strokeAlign" in node ? node.strokeAlign : "INSIDE"; if ("all" in commonBorder) { if (commonBorder.all === 0) { - return ""; + return { + isOutline: false, + property: "", + }; } - return getBorder(commonBorder.all, ""); + + const weight = commonBorder.all; + + if ( + strokeAlign === "CENTER" || + strokeAlign === "OUTSIDE" || + node.type === "FRAME" || + node.type === "INSTANCE" || + node.type === "COMPONENT" + ) { + // For CENTER, OUTSIDE, or INSIDE+Frame, use outline + const property = getBorder(weight, "", true); + let offsetProperty = ""; + + if (strokeAlign === "CENTER") { + offsetProperty = `outline-offset-[-${numberToFixedString(weight / 2)}px]`; + } else if (strokeAlign === "INSIDE") { + offsetProperty = `outline-offset-[-${numberToFixedString(weight)}px]`; + } + + return { + isOutline: true, + property: offsetProperty ? `${property} ${offsetProperty}` : property, + }; + } else { + // Default case: use normal border (for INSIDE + AUTO_LAYOUT) + return { + isOutline: false, + property: getBorder(weight, "", false), + }; + } + } else { + // For non-uniform borders, we only support border (not outline) + addWarning( + 'Non-uniform borders are only supported with strokeAlign set to "inside". Will paint inside.', + ); } + // Handle non-uniform borders with individual border properties const comp = []; if (commonBorder.left !== 0) { comp.push(getBorder(commonBorder.left, "-l")); @@ -47,7 +127,11 @@ export const tailwindBorderWidth = (node: SceneNode): string => { if (commonBorder.bottom !== 0) { comp.push(getBorder(commonBorder.bottom, "-b")); } - return comp.join(" "); + + return { + isOutline: false, + property: comp.join(" "), + }; }; /** @@ -70,7 +154,6 @@ export const tailwindBorderRadius = (node: SceneNode): string => { return `-${r}`; } return ""; - // } }; const radius = getCommonRadius(node); @@ -85,7 +168,7 @@ export const tailwindBorderRadius = (node: SceneNode): string => { return `rounded${getRadius(radius.all)}`; } - // todo optimize for tr/tl/br/bl instead of t/r/l/b + // todo optimize for t/r/l/b instead of tr/tl/br/bl let comp: string[] = []; if (radius.topLeft !== 0) { comp.push(`rounded-tl${getRadius(radius.topLeft)}`); diff --git a/packages/backend/src/tailwind/builderImpl/tailwindColor.ts b/packages/backend/src/tailwind/builderImpl/tailwindColor.ts index a5d5cb70..158bd976 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindColor.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindColor.ts @@ -1,4 +1,3 @@ -import { retrieveTopFill } from "../../common/retrieveFill"; import { gradientAngle } from "../../common/color"; import { getColorInfo, @@ -6,15 +5,24 @@ import { nearestValue, } from "../conversionTables"; import { TailwindColorType } from "types"; -import { addWarning } from "../../common/commonConversionWarnings"; +import { retrieveTopFill } from "../../common/retrieveFill"; + +// Import the HTML gradient functions +import { + htmlAngularGradient, + htmlRadialGradient, +} from "../../html/builderImpl/htmlColor"; +import { GradientPaint, Paint } from "../../api_types"; +import { localTailwindSettings } from "../tailwindMain"; /** * Get a tailwind color value object * @param fill + * @param useVarSyntax Whether to use CSS variable syntax for variables */ -export function tailwindColor(fill: SolidPaint) { +export function tailwindColor(fill: SolidPaint, useVarSyntax: boolean = false) { const { hex, colorType, colorName, meta } = getColorInfo(fill); - const exportValue = tailwindSolidColor(fill, "solid"); + const exportValue = tailwindSolidColor(fill, "bg", useVarSyntax); return { exportValue, colorName, @@ -24,37 +32,101 @@ export function tailwindColor(fill: SolidPaint) { }; } +/** + * Calculate effective opacity from a fill or color stop + * @param fill The color fill or stop to process + * @param parentOpacity Optional parent opacity to factor in + * @returns The calculated effective opacity value + */ +function calculateEffectiveOpacity( + fill: SolidPaint | ColorStop, + parentOpacity?: number, +): number { + let effectiveOpacity = + typeof parentOpacity === "number" ? parentOpacity : 1.0; + + // Apply fill-specific opacity + if ("opacity" in fill && typeof fill.opacity === "number") { + effectiveOpacity *= fill.opacity; + } + + // For ColorStop, also consider the alpha channel in the color + if ("color" in fill && "a" in fill.color) { + effectiveOpacity *= fill.color.a; + } + + return effectiveOpacity; +} + /** * Get the tailwind token name for a given color * - * - variables: uses the tokenised variable name - * - colors: uses the nearest Tailwind color name + * @param fill The color fill to process + * @param kind Parameter specifying how the color will be used (e.g., 'text', 'bg') + * @param useVarSyntax Whether to use CSS variable syntax for variables + * @returns Tailwind color string with prefix (e.g., text-red-500) */ export const tailwindSolidColor = ( fill: SolidPaint | ColorStop, - kind?: TailwindColorType, + kind: TailwindColorType, + useVarSyntax: boolean = false, ): string => { - // example: stone-500 or custom-color-700 - const { colorName } = getColorInfo(fill); + // Check if we should use var syntax and the fill has a variable color name + if (useVarSyntax && (fill as any).variableColorName) { + const varName = (fill as any).variableColorName; + // Create the fallback color using existing color info + const { hex } = getColorInfo(fill); + // Return arbitrary value syntax with CSS variable + return `${kind}-[var(--${varName},${hex})]`; + } - // if no kind, it's a variable stop, so just return the name - if (!kind) { - return colorName; + // Original implementation for non-variable colors or when not using var syntax + 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}`; } - // grab opacity, or set it to full + const effectiveOpacity = calculateEffectiveOpacity(fill); const opacity = - "opacity" in fill && fill.opacity !== 1.0 - ? `/${nearestOpacity(fill.opacity ?? 1.0)}` - : ""; + effectiveOpacity !== 1.0 ? `/${nearestOpacity(effectiveOpacity)}` : ""; - // example: text-red-500, text-[#123abc], text-custom-color-700/25 return `${kind}-${colorName}${opacity}`; }; +/** + * Get the color name for a gradient stop, including opacity if needed + * + * @param stop The gradient color stop + * @param parentOpacity The opacity of the parent gradient + * @param useVarSyntax Whether to use CSS variable syntax for variables + * @returns Color name with optional opacity suffix + */ +export const tailwindGradientStop = ( + stop: ColorStop, + parentOpacity: number = 1.0, +): string => { + 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)}` : ""; + + return `${colorName}${opacity}`; +}; + // retrieve the SOLID color for tailwind export const tailwindColorFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"], + fills: ReadonlyArray, kind: TailwindColorType, ): string => { // [when testing] fills can be undefined @@ -76,73 +148,359 @@ export const tailwindColorFromFills = ( return ""; }; -/** - * https://tailwindcss.com/docs/box-shadow/ - * example: shadow - */ export const tailwindGradientFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"], + fills: ReadonlyArray, ): string => { - // [when testing] node.effects can be undefined - const fill = retrieveTopFill(fills); - if (fill?.type === "GRADIENT_LINEAR") { + // Return early if no fill exists + if (!fill) { + return ""; + } + + if (fill.type === "GRADIENT_LINEAR") { return tailwindGradient(fill); } - addWarning( - "Gradients are not fully supported in Tailwind except for Linear Gradients.", - ); + // Tailwind 4 has built-in support for radial and conic gradients + if (localTailwindSettings.useTailwind4) { + if (fill.type === "GRADIENT_RADIAL") { + return tailwindRadialGradient(fill); + } + if (fill.type === "GRADIENT_ANGULAR") { + return tailwindConicGradient(fill); + } + // Diamond is still too complex for direct Tailwind support + if (fill.type === "GRADIENT_DIAMOND") { + return ""; + } + } else { + // Use arbitrary values with HTML-based gradient syntax for other gradient types + if (fill.type === "GRADIENT_ANGULAR") { + return tailwindArbitraryGradient(htmlAngularGradient(fill)); + } + + if (fill.type === "GRADIENT_RADIAL") { + return tailwindArbitraryGradient(htmlRadialGradient(fill)); + } + + if (fill.type === "GRADIENT_DIAMOND") { + // Diamond is too complex, it is going to create 3 linear gradients, which gets too weird in Tailwind. + return ""; + } + } return ""; }; +/** + * Converts CSS gradient syntax to Tailwind arbitrary value syntax + * @param cssGradient The CSS gradient string (e.g., "radial-gradient(...)") + * @returns Tailwind class with arbitrary value (e.g., "bg-[radial-gradient(...)]") + */ +const tailwindArbitraryGradient = (cssGradient: string): string => { + // Replace spaces with underscores for Tailwind compatibility + const tailwindValue = cssGradient.replace(/\s+/g, "_"); + return `bg-[${tailwindValue}]`; +}; + +/** + * Maps an angle to a gradient direction class + * @param angle The angle in degrees + * @returns The appropriate gradient direction class + */ +const directionMap: Record = { + 0: "bg-gradient-to-r", + 45: "bg-gradient-to-br", + 90: "bg-gradient-to-b", + 135: "bg-gradient-to-bl", + "-45": "bg-gradient-to-tr", + "-90": "bg-gradient-to-t", + "-135": "bg-gradient-to-tl", + 180: "bg-gradient-to-l", +}; + +function getGradientDirectionClass( + angle: number, + useTailwind4: boolean, +): string { + const angleValues = [0, 45, 90, 135, 180, -45, -90, -135, -180]; + + // For non-standard angles in Tailwind 4, use exact angle + if (useTailwind4) { + const roundedAngle = Math.round(angle); + if (angleValues.includes(roundedAngle)) { + return directionMap[roundedAngle]; + } + + const exactAngle = Math.round(((angle % 360) + 360) % 360); + return `bg-linear-${exactAngle}`; + } + + let snappedAngle = nearestValue(angle, angleValues); + if (snappedAngle === -180) snappedAngle = 180; + + // Check if angle is in the map + const entry = directionMap[snappedAngle]; + if (entry) { + return entry; + } + + return "bg-gradient-to-r"; +} + +/** + * Check if a stop position needs a position override + * @param actual The actual position (0-1) + * @param expected The expected default position (0-1) + * @returns True if position needs to be specified + */ +const needsPositionOverride = (actual: number, expected: number): boolean => { + // Only include position if it deviates by more than 5% from expected + return Math.abs(actual - expected) > 0.05; +}; + +/** + * Gets position modifier string for a gradient stop if needed + * @param stopPosition The stop position (0-1) + * @param expectedPosition The expected default position (0-1) + * @param unit The unit to use (%, deg) + * @param multiplier Multiplier for the position value + * @returns Position string or empty string + */ +const getStopPositionModifier = ( + stopPosition: number, + expectedPosition: number, + unit: string = "%", + multiplier: number = 100, +): string => { + if (needsPositionOverride(stopPosition, expectedPosition)) { + const position = Math.round(stopPosition * multiplier); + return ` ${position}${unit}`; + } + return ""; +}; + +/** + * Generates a complete gradient stop with position if needed + * @param prefix The stop prefix (from-, via-, to-) + * @param stop The gradient stop + * @param globalOpacity The global opacity + * @param expectedPosition The expected default position (0-1) + * @param unit The unit to use (%, deg) + * @param multiplier Multiplier for the position value + * @returns Complete gradient stop string + */ +function generateGradientStop( + prefix: string, + stop: ColorStop, + globalOpacity: number = 1.0, + expectedPosition: number, + unit: string = "%", + multiplier: number = 100, +): string { + const colorValue = tailwindGradientStop(stop, globalOpacity); + const colorPart = `${prefix}-${colorValue}`; + + if (!localTailwindSettings.useTailwind4) { + return colorPart; + } + + // Only add position if it significantly differs from the default + const positionModifier = getStopPositionModifier( + stop.position, + expectedPosition, + unit, + multiplier, + ); + return positionModifier + ? `${colorPart} ${prefix}${positionModifier}` + : colorPart; +} + export const tailwindGradient = (fill: GradientPaint): string => { - const direction = gradientDirection(gradientAngle(fill)); + const globalOpacity = fill.opacity ?? 1.0; + const direction = getGradientDirectionClass( + gradientAngle(fill), + localTailwindSettings.useTailwind4, + ); if (fill.gradientStops.length === 1) { - const fromColor = tailwindSolidColor(fill.gradientStops[0]); - - return `${direction} from-${fromColor}`; + const fromStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + return [direction, fromStop].filter(Boolean).join(" "); } else if (fill.gradientStops.length === 2) { - const fromColor = tailwindSolidColor(fill.gradientStops[0]); - const toColor = tailwindSolidColor(fill.gradientStops[1]); - - return `${direction} from-${fromColor} to-${toColor}`; + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + const lastStop = generateGradientStop( + "to", + fill.gradientStops[1], + globalOpacity, + 1, + ); + return [direction, firstStop, lastStop].filter(Boolean).join(" "); } else { - const fromColor = tailwindSolidColor(fill.gradientStops[0]); + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + const viaStop = generateGradientStop( + "via", + fill.gradientStops[1], + globalOpacity, + 0.5, + ); + const lastStop = generateGradientStop( + "to", + fill.gradientStops[fill.gradientStops.length - 1], + globalOpacity, + 1, + ); + return [direction, firstStop, viaStop, lastStop].filter(Boolean).join(" "); + } +}; - // middle (second color) - const viaColor = tailwindSolidColor(fill.gradientStops[1]); +/** + * Generate Tailwind 4 radial gradient + */ +const tailwindRadialGradient = (fill: GradientPaint): string => { + const globalOpacity = fill.opacity ?? 1.0; + const [center] = fill.gradientHandlePositions; + const cx = Math.round(center.x * 100); + const cy = Math.round(center.y * 100); + const isCustomPosition = Math.abs(cx - 50) > 5 || Math.abs(cy - 50) > 5; + const baseClass = isCustomPosition + ? `bg-radial-[at_${cx}%_${cy}%]` + : "bg-radial"; - // last - const toColor = tailwindSolidColor( + if (fill.gradientStops.length === 1) { + const fromStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + return [baseClass, fromStop].filter(Boolean).join(" "); + } else if (fill.gradientStops.length === 2) { + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + const lastStop = generateGradientStop( + "to", + fill.gradientStops[1], + globalOpacity, + 1, + ); + return [baseClass, firstStop, lastStop].filter(Boolean).join(" "); + } else { + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + const viaStop = generateGradientStop( + "via", + fill.gradientStops[1], + globalOpacity, + 0.5, + ); + const lastStop = generateGradientStop( + "to", fill.gradientStops[fill.gradientStops.length - 1], + globalOpacity, + 1, ); - - return `${direction} from-${fromColor} via-${viaColor} to-${toColor}`; + return [baseClass, firstStop, viaStop, lastStop].filter(Boolean).join(" "); } }; -const gradientDirection = (angle: number): string => { - switch (nearestValue(angle, [-180, -135, -90, -45, 0, 45, 90, 135, 180])) { - case 0: - return "bg-gradient-to-r"; - case 45: - return "bg-gradient-to-br"; - case 90: - return "bg-gradient-to-b"; - case 135: - return "bg-gradient-to-bl"; - case -45: - return "bg-gradient-to-tr"; - case -90: - return "bg-gradient-to-t"; - case -135: - return "bg-gradient-to-tl"; - default: - // 180 and -180 - return "bg-gradient-to-l"; +/** + * Generate Tailwind 4 conic gradient + */ +const tailwindConicGradient = (fill: GradientPaint): string => { + const [center, , startDirection] = fill.gradientHandlePositions; + const globalOpacity = fill.opacity ?? 1.0; + const dx = startDirection.x - center.x; + const dy = startDirection.y - center.y; + let angle = Math.atan2(dy, dx) * (180 / Math.PI); + angle = (angle + 360) % 360; + const normalizedAngle = Math.round(angle); + const cx = Math.round(center.x * 100); + const cy = Math.round(center.y * 100); + const isCustomPosition = Math.abs(cx - 50) > 5 || Math.abs(cy - 50) > 5; + let baseClass = `bg-conic-${normalizedAngle}`; + + if (isCustomPosition) { + baseClass = `bg-conic-[from_${normalizedAngle}deg_at_${cx}%_${cy}%]`; + } + + if (fill.gradientStops.length === 1) { + const fromStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + "deg", + 360, + ); + return [baseClass, fromStop].filter(Boolean).join(" "); + } else if (fill.gradientStops.length === 2) { + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + "deg", + 360, + ); + const lastStop = generateGradientStop( + "to", + fill.gradientStops[1], + globalOpacity, + 1, + "deg", + 360, + ); + return [baseClass, firstStop, lastStop].filter(Boolean).join(" "); + } else { + const firstStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + "deg", + 360, + ); + const viaStop = generateGradientStop( + "via", + fill.gradientStops[1], + globalOpacity, + 0.5, + "deg", + 360, + ); + const lastStop = generateGradientStop( + "to", + fill.gradientStops[fill.gradientStops.length - 1], + globalOpacity, + 1, + "deg", + 360, + ); + return [baseClass, firstStop, viaStop, lastStop].filter(Boolean).join(" "); } }; diff --git a/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts b/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts index 2862dde4..bfe64b5d 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts @@ -1,3 +1,5 @@ +import { localTailwindSettings } from "../tailwindMain"; + /** * https://tailwindcss.com/docs/box-shadow/ * example: shadow @@ -19,7 +21,8 @@ export const tailwindShadow = (node: BlendMixin): string[] => { d.color?.b === 0 && Math.abs(d.color?.a - 0.05) < EPSILON ) { - return "shadow-sm"; + // shadow-sm → shadow-xs in v4 + return localTailwindSettings.useTailwind4 ? "shadow-xs" : "shadow-sm"; } else if ( d.offset?.x === 0 && d.offset?.y === 1 && @@ -30,7 +33,8 @@ export const tailwindShadow = (node: BlendMixin): string[] => { d.color?.b === 0 && Math.abs(d.color?.a - 0.1) < EPSILON ) { - return "shadow"; + // shadow → shadow-sm in v4 + return localTailwindSettings.useTailwind4 ? "shadow-sm" : "shadow"; } else if ( d.offset?.x === 0 && d.offset?.y === 4 && @@ -41,6 +45,7 @@ export const tailwindShadow = (node: BlendMixin): string[] => { d.color?.b === 0 && Math.abs(d.color?.a - 0.1) < EPSILON ) { + // shadow-md stays the same return "shadow-md"; } else if ( d.offset?.x === 0 && diff --git a/packages/backend/src/tailwind/builderImpl/tailwindSize.ts b/packages/backend/src/tailwind/builderImpl/tailwindSize.ts index af7a183a..1273e3e2 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindSize.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindSize.ts @@ -1,62 +1,115 @@ import { pxToLayoutSize } from "../conversionTables"; import { nodeSize } from "../../common/nodeWidthHeight"; -import { formatWithJSX } from "../../common/parseJSX"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { TailwindSettings } from "types"; +import { localTailwindSettings } from "../tailwindMain"; + +/** + * Formats a size value into a Tailwind class + * Uses Tailwind's standard classes if there's a good match, otherwise uses arbitrary values + */ +const formatTailwindSizeValue = ( + size: number, + prefix: string, + settings?: TailwindSettings, +): string => { + const tailwindSize = pxToLayoutSize(size); + + // If we found a matching Tailwind class, use it + if (!tailwindSize.startsWith("[")) { + return `${prefix}-${tailwindSize}`; + } + + // No matching class or rounding disabled, use arbitrary value + const sizeFixed = numberToFixedString(size); + if (sizeFixed === "0") { + return `${prefix}-0`; + } else { + return `${prefix}-[${sizeFixed}px]`; + } +}; export const tailwindSizePartial = ( node: SceneNode, - optimizeLayout: boolean, -): { width: string; height: string } => { - const size = nodeSize(node, optimizeLayout); - - const nodeParent = - (node.parent && optimizeLayout && "inferredAutoLayout" in node.parent - ? node.parent.inferredAutoLayout - : null) ?? node.parent; + settings?: TailwindSettings, +): { width: string; height: string; constraints: string } => { + const size = nodeSize(node); + const nodeParent = node.parent; let w = ""; - if ( - typeof size.width === "number" && - "layoutSizingHorizontal" in node && - node.layoutSizingHorizontal === "FIXED" - ) { - w = `w-${pxToLayoutSize(size.width)}`; + if (typeof size.width === "number") { + w = formatTailwindSizeValue(size.width, "w", settings); } else if (size.width === "fill") { if ( nodeParent && "layoutMode" in nodeParent && nodeParent.layoutMode === "HORIZONTAL" ) { - w = `grow shrink basis-0`; + w = "flex-1"; } else { - w = `self-stretch`; + if (node.maxWidth) { + w = "w-full"; + } else { + w = "self-stretch"; + } } } let h = ""; if (typeof size.height === "number") { - h = `h-${pxToLayoutSize(size.height)}`; + h = formatTailwindSizeValue(size.height, "h", settings); } else if (size.height === "fill") { if ( - size.height === "fill" && nodeParent && "layoutMode" in nodeParent && nodeParent.layoutMode === "VERTICAL" ) { - h = `grow shrink basis-0`; + h = "flex-1"; } else { - h = `self-stretch`; + if (node.maxHeight) { + h = "h-full"; + } else { + h = "self-stretch"; + } } } - return { width: w, height: h }; -}; + // Handle min/max constraints in tailwind + const constraints = []; -export const htmlSizePartialForTailwind = ( - node: SceneNode, - isJSX: boolean, -): [string, string] => { - return [ - formatWithJSX("width", isJSX, node.width), - formatWithJSX("height", isJSX, node.height), - ]; + if (node.maxWidth !== undefined && node.maxWidth !== null) { + constraints.push(formatTailwindSizeValue(node.maxWidth, "max-w", settings)); + } + + if (node.minWidth !== undefined && node.minWidth !== null) { + constraints.push(formatTailwindSizeValue(node.minWidth, "min-w", settings)); + } + + if (node.maxHeight !== undefined && node.maxHeight !== null) { + constraints.push( + formatTailwindSizeValue(node.maxHeight, "max-h", settings), + ); + } + + if (node.minHeight !== undefined && node.minHeight !== null) { + constraints.push( + formatTailwindSizeValue(node.minHeight, "min-h", settings), + ); + } + + // Technically size exists since Tailwind 3.4 (December 2023), but to avoid confusion, restrict to 4, + if (localTailwindSettings.useTailwind4) { + const wValue = w.substring(2); + const hValue = h.substring(2); + if (wValue === hValue) { + w = `size-${wValue}`; + h = ""; + } + } + + return { + width: w, + height: h, + constraints: constraints.join(" "), + }; }; diff --git a/packages/backend/src/tailwind/conversionTables.ts b/packages/backend/src/tailwind/conversionTables.ts index ba814e8d..4532d608 100644 --- a/packages/backend/src/tailwind/conversionTables.ts +++ b/packages/backend/src/tailwind/conversionTables.ts @@ -1,5 +1,5 @@ import { nearestColorFrom } from "../nearest-color/nearestColor"; -import { sliceNum } from "../common/numToAutoFixed"; +import { numberToFixedString } from "../common/numToAutoFixed"; import { localTailwindSettings } from "./tailwindMain"; import { config } from "./tailwindConfig"; import { rgbTo6hex } from "../common/color"; @@ -10,6 +10,22 @@ export const nearestValue = (goal: number, array: Array): number => { }); }; +// New function to get nearest value only if it's within acceptable threshold +export const nearestValueWithThreshold = ( + goal: number, + array: Array, + thresholdPercent: number = localTailwindSettings.thresholdPercent, +): number | null => { + const nearest = nearestValue(goal, array); + const diff = Math.abs(nearest - goal); + const percentDiff = (diff / goal) * 100; + + if (percentDiff <= thresholdPercent) { + return nearest; + } + return null; +}; + export const exactValue = ( goal: number, array: Array, @@ -28,7 +44,7 @@ export const exactValue = ( /** * convert pixel values to Tailwind attributes. * by default, Tailwind uses rem, while Figma uses px. - * Therefore, a conversion is necessary. Rem = Pixel / 16.abs + * Therefore, a conversion is necessary. Rem = Pixel / baseFontSize * Then, find in the corresponding table the closest value. */ const pxToRemToTailwind = ( @@ -36,15 +52,23 @@ const pxToRemToTailwind = ( conversionMap: Record, ): string => { const keys = Object.keys(conversionMap).map((d) => +d); - const convertedValue = exactValue(value / 16, keys); + // Use the configured base font size or fall back to default 16px + const baseFontSize = localTailwindSettings.baseFontSize || 16; + const remValue = value / baseFontSize; + const convertedValue = exactValue(remValue, keys); if (convertedValue) { return conversionMap[convertedValue]; } else if (localTailwindSettings.roundTailwindValues) { - return conversionMap[nearestValue(value / 16, keys)]; + // Only round if the nearest value is within acceptable threshold + const thresholdValue = nearestValueWithThreshold(remValue, keys); + + if (thresholdValue !== null) { + return conversionMap[thresholdValue]; + } } - return `[${sliceNum(value)}px]`; + return `[${numberToFixedString(value)}px]`; }; const pxToTailwind = ( @@ -57,10 +81,15 @@ const pxToTailwind = ( if (convertedValue) { return conversionMap[convertedValue]; } else if (localTailwindSettings.roundTailwindValues) { - return conversionMap[nearestValue(value, keys)]; + // Only round if the nearest value is within acceptable threshold + const thresholdValue = nearestValueWithThreshold(value, keys); + + if (thresholdValue !== null) { + return conversionMap[thresholdValue]; + } } - return `[${sliceNum(value)}px]`; + return `[${numberToFixedString(value)}px]`; }; export const pxToLetterSpacing = (value: number): string => { @@ -76,20 +105,38 @@ export const pxToFontSize = (value: number): string => { }; export const pxToBorderRadius = (value: number): string => { - return pxToRemToTailwind(value, config.borderRadius); + const conversionMap = localTailwindSettings.useTailwind4 + ? config.borderRadiusV4 + : config.borderRadius; + return pxToRemToTailwind(value, conversionMap); +}; + +export const pxToBorderWidth = (value: number): string | null => { + return pxToTailwind(value, config.border); +}; + +export const pxToOutline = (value: number): string | null => { + return pxToTailwind(value, config.outline); }; export const pxToBlur = (value: number): string | null => { - return pxToTailwind(value, config.blur); + const conversionMap = localTailwindSettings.useTailwind4 + ? config.blurV4 + : config.blur; + return pxToTailwind(value, conversionMap); }; export const pxToLayoutSize = (value: number): string => { - const tailwindValue = pxToTailwind(value, config.layoutSize); - if (tailwindValue) { - return tailwindValue; - } - - return `[${sliceNum(value)}px]`; + // Scale the input value according to the base font size ratio + const baseFontSize = localTailwindSettings.baseFontSize || 16; + // If baseFontSize is different than 16, we need to adjust the pixel value + // For example, with baseFontSize=14, 7px should match with the key for 8px (w-2) + const scaledValue = (value * 16) / baseFontSize; + + // Use pxToTailwind directly with the scaled value, since the keys in config.layoutSize + // are likely in pixels based on a 16px base font size + const result = pxToTailwind(scaledValue, config.layoutSize); + return result !== null ? result : `[${numberToFixedString(value)}px]`; }; export const nearestOpacity = (nodeOpacity: number): number => { @@ -113,10 +160,11 @@ export const nearestColorFromRgb = (color: RGB) => { return { name, value }; }; -export const variableToColorName = (alias: VariableAlias) => { +export const variableToColorName = async (id: string) => { return ( - figma.variables.getVariableById(alias.id)?.name.replaceAll("/", "-").replaceAll(" ", "-") || - alias.id.toLowerCase().replaceAll(":", "-") + (await figma.variables.getVariableByIdAsync(id))?.name + .replaceAll("/", "-") + .replaceAll(" ", "-") || id.toLowerCase().replaceAll(":", "-") ); }; @@ -132,37 +180,37 @@ export function getColorInfo(fill: SolidPaint | ColorStop) { let hex: string = "#" + rgbTo6hex(fill.color); let meta: string = ""; + // variable + if ((fill as any).variableColorName) { + // Use pre-computed variable name if available + colorName = (fill as any).variableColorName; // || variableToColorName(fill.boundVariables.color); + colorType = "variable"; + meta = "custom"; + + return { + colorType, + colorName, + hex, + meta, + }; + } + // Check for pure black/white first if (fill.color.r === 0 && fill.color.g === 0 && fill.color.b === 0) { return { colorType: "tailwind", colorName: "black", hex: "#000000", - meta: '' + meta: "", }; - } - - if (fill.color.r === 1 && fill.color.g === 1 && fill.color.b === 1) { + } else if (fill.color.r === 1 && fill.color.g === 1 && fill.color.b === 1) { return { - colorType: "tailwind", + colorType: "tailwind", colorName: "white", hex: "#ffffff", - meta: '' + meta: "", }; - } - - // variable - if ( - localTailwindSettings.customTailwindColors && - fill.boundVariables?.color - ) { - colorName = variableToColorName(fill.boundVariables.color); - colorType = "variable"; - meta = "custom"; - } - - // solid color - else { + } else { // get tailwind color as comparison const { name, value } = nearestColorFromRgb(fill.color); diff --git a/packages/backend/src/tailwind/tailwindConfig.ts b/packages/backend/src/tailwind/tailwindConfig.ts index 5660ae41..cb08676d 100644 --- a/packages/backend/src/tailwind/tailwindConfig.ts +++ b/packages/backend/src/tailwind/tailwindConfig.ts @@ -1,5 +1,5 @@ const layoutSize = { - // '0: 0', + "0": "0", 1: "px", 2: "0.5", 4: "1", @@ -37,7 +37,7 @@ const layoutSize = { }; const borderRadius = { - // 0: "none", + 0: "none", 0.125: "sm", 0.25: "", 0.375: "md", @@ -48,6 +48,19 @@ const borderRadius = { 10: "full", }; +// Add a set of v4 mappings for the renamed utilities +const borderRadiusV4 = { + 0: "none", + 0.125: "xs", // sm -> xs + 0.25: "sm", // (default) -> sm + 0.375: "md", // unchanged + 0.5: "lg", // unchanged + 0.75: "xl", // unchanged + 1.0: "2xl", // unchanged + 1.5: "3xl", // unchanged + 10: "full", // unchanged +}; + const fontSize = { 0.75: "xs", 0.875: "sm", @@ -65,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 = { @@ -98,6 +109,18 @@ const blur = { 64: "3xl", }; +// Tailwind v4 blur mapping +const blurV4 = { + 0: "none", + 4: "xs", // sm -> xs + 8: "sm", // blur -> sm + 12: "md", // unchanged + 16: "lg", // unchanged + 24: "xl", // unchanged + 40: "2xl", // unchanged + 64: "3xl", // unchanged +}; + const opacity = [0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95]; // AutoGenerated for Tailwind 2 via [convert_tailwind_colors.js] @@ -356,48 +379,79 @@ const fontWeight: Record = { 600: "semibold", 700: "bold", 800: "extrabold", - 900: "black" + 900: "black", }; const fontFamily = { sans: [ - 'ui-sans-serif', - 'system-ui', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - 'Noto Color Emoji' + "ui-sans-serif", + "system-ui", + "sans-serif", + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji", ], serif: [ - 'ui-serif', - 'Georgia', - 'Cambria', - 'Times New Roman', - 'Times', - 'serif' + "ui-serif", + "Georgia", + "Cambria", + "Times New Roman", + "Times", + "serif", ], mono: [ - 'ui-monospace', - 'SFMono-Regular', - 'Menlo', - 'Monaco', - 'Consolas', - 'Liberation Mono', - 'Courier New', - 'monospace' - ] + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], +}; + +const border = { + 0: "0", + 1: "1", + 2: "2", + 4: "4", + 8: "8", +}; + +const outline = { + 0: "0", + 1: "1", + 2: "2", + 4: "4", + 8: "8", +}; + +// Tailwind v4 shadow mapping +const shadowV4 = { + sm: "xs", // sm -> xs + DEFAULT: "sm", // (default) -> sm + md: "md", // unchanged + lg: "lg", // unchanged + xl: "xl", // unchanged + "2xl": "2xl", // unchanged }; export const config = { layoutSize, borderRadius, + borderRadiusV4, fontSize, lineHeight, letterSpacing, blur, + blurV4, + shadowV4, opacity, color, fontWeight, fontFamily, + border, + outline, }; diff --git a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts index 9160828a..eae314c6 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -1,10 +1,14 @@ -import { stringToClassName, sliceNum } from "./../common/numToAutoFixed"; +import { + stringToClassName, + numberToFixedString, +} from "./../common/numToAutoFixed"; import { tailwindShadow } from "./builderImpl/tailwindShadow"; import { tailwindVisibility, tailwindRotation, tailwindOpacity, tailwindBlendMode, + tailwindBackgroundBlendMode, } from "./builderImpl/tailwindBlend"; import { tailwindBorderWidth, @@ -23,11 +27,13 @@ import { import { pxToBlur } from "./conversionTables"; import { formatDataAttribute, + formatTwigAttribute, getClassLabel, } from "../common/commonFormatAttributes"; import { TailwindColorType, TailwindSettings } from "types"; +import { MinimalFillsTrait, MinimalStrokesTrait, Paint } from "../api_types"; -const isNotEmpty = (s: string) => s !== ""; +const isNotEmpty = (s: string) => s !== "" && s !== null && s !== undefined; const dropEmptyStrings = (strings: string[]) => strings.filter(isNotEmpty); export class TailwindDefaultBuilder { @@ -42,13 +48,18 @@ export class TailwindDefaultBuilder { return this.settings.showLayerNames ? this.node.name : ""; } get visible() { - return this.node.visible; + return this.node.visible ?? true; } get isJSX() { - return this.settings.jsx; + return this.settings.tailwindGenerationMode === "jsx"; } - get optimizeLayout() { - return this.settings.optimizeLayout; + + get needsJSXTextEscaping() { + return this.isJSX; + } + + get isTwigComponent() { + return this.settings.tailwindGenerationMode === "twig" && this.node.type === "INSTANCE" } constructor(node: SceneNode, settings: TailwindSettings) { @@ -60,10 +71,15 @@ export class TailwindDefaultBuilder { } addAttributes = (...newStyles: string[]) => { - this.attributes.push(...dropEmptyStrings(newStyles)); + // Filter out empty strings and trim any extra spaces + const cleanedStyles = dropEmptyStrings(newStyles).map((s) => s.trim()); + this.attributes.push(...cleanedStyles); }; + prependAttributes = (...newStyles: string[]) => { - this.attributes.unshift(...dropEmptyStrings(newStyles)); + // Filter out empty strings and trim any extra spaces + const cleanedStyles = dropEmptyStrings(newStyles).map((s) => s.trim()); + this.attributes.unshift(...cleanedStyles); }; blend(): this { @@ -86,7 +102,7 @@ export class TailwindDefaultBuilder { } commonShapeStyles(): this { - this.customColor((this.node as MinimalFillsMixin).fills, "bg"); + this.customColor((this.node as MinimalFillsTrait).fills, "bg"); this.radius(); this.shadow(); this.border(); @@ -105,21 +121,24 @@ export class TailwindDefaultBuilder { border(): this { if ("strokes" in this.node) { - this.addAttributes(tailwindBorderWidth(this.node)); - this.customColor(this.node.strokes, "border"); + const { isOutline, property } = tailwindBorderWidth(this.node); + this.addAttributes(property); + this.customColor( + this.node.strokes as MinimalStrokesTrait, + isOutline ? "outline" : "border", + ); } return this; } position(): this { - const { node, optimizeLayout } = this; - - if (commonIsAbsolutePosition(node, optimizeLayout)) { - const { x, y } = getCommonPositionValue(node); + const { node } = this; + if (commonIsAbsolutePosition(node)) { + const { x, y } = getCommonPositionValue(node, this.settings); - const parsedX = sliceNum(x); - const parsedY = sliceNum(y); + const parsedX = numberToFixedString(x); + const parsedY = numberToFixedString(y); if (parsedX === "0") { this.addAttributes(`left-0`); } else { @@ -132,12 +151,7 @@ export class TailwindDefaultBuilder { } this.addAttributes(`absolute`); - } else if ( - node.type === "GROUP" || - ("layoutMode" in node && - ((optimizeLayout ? node.inferredAutoLayout : null) ?? node) - ?.layoutMode === "NONE") - ) { + } else if (node.type === "GROUP" || (node as any).isRelative) { this.addAttributes("relative"); } return this; @@ -149,15 +163,17 @@ export class TailwindDefaultBuilder { * example: text-opacity-25 * example: bg-blue-500 */ - customColor( - paint: ReadonlyArray | PluginAPI["mixed"], - kind: TailwindColorType, - ): this { - // visible is true or undefinied (tests) + customColor(paint: ReadonlyArray, kind: TailwindColorType): this { if (this.visible) { let gradient = ""; if (kind === "bg") { gradient = tailwindGradientFromFills(paint); + + // Add background blend mode class if applicable + const blendModeClass = tailwindBackgroundBlendMode(paint); + if (blendModeClass) { + this.addAttributes(blendModeClass); + } } if (gradient) { this.addAttributes(gradient); @@ -179,8 +195,8 @@ export class TailwindDefaultBuilder { // must be called before Position, because of the hasFixedSize attribute. size(): this { - const { node, optimizeLayout } = this; - const { width, height } = tailwindSizePartial(node, optimizeLayout); + const { node, settings } = this; + const { width, height, constraints } = tailwindSizePartial(node, settings); if (node.type === "TEXT") { switch (node.textAutoResize) { @@ -198,17 +214,17 @@ export class TailwindDefaultBuilder { this.addAttributes(width, height); } + // Add any min/max constraints + if (constraints) { + this.addAttributes(constraints); + } + return this; } autoLayoutPadding(): this { if ("paddingLeft" in this.node) { - this.addAttributes( - ...tailwindPadding( - (this.optimizeLayout ? this.node.inferredAutoLayout : null) ?? - this.node, - ), - ); + this.addAttributes(...tailwindPadding(this.node)); } return this; } @@ -216,9 +232,11 @@ export class TailwindDefaultBuilder { blur() { const { node } = this; if ("effects" in node && node.effects.length > 0) { - const blur = node.effects.find((e) => e.type === "LAYER_BLUR"); + const blur = node.effects.find( + (e) => e.type === "LAYER_BLUR" && e.visible, + ); if (blur) { - const blurValue = pxToBlur(blur.radius); + const blurValue = pxToBlur(blur.radius / 2); if (blurValue) { this.addAttributes( blurValue === "blur" ? "blur" : `blur-${blurValue}`, @@ -227,10 +245,10 @@ export class TailwindDefaultBuilder { } const backgroundBlur = node.effects.find( - (e) => e.type === "BACKGROUND_BLUR", + (e) => e.type === "BACKGROUND_BLUR" && e.visible, ); if (backgroundBlur) { - const backgroundBlurValue = pxToBlur(backgroundBlur.radius); + const backgroundBlurValue = pxToBlur(backgroundBlur.radius / 2); if (backgroundBlurValue) { this.addAttributes( `backdrop-blur${ @@ -249,19 +267,41 @@ export class TailwindDefaultBuilder { } build(additionalAttr = ""): string { - this.addAttributes(additionalAttr); + if (additionalAttr) { + this.addAttributes(additionalAttr); + } if (this.name !== "") { this.prependAttributes(stringToClassName(this.name)); } if (this.name) { - this.addData("layer", this.name); + this.addData("layer", this.name.trim()); + } + + if ("componentProperties" in this.node && this.node.componentProperties) { + Object.entries(this.node.componentProperties) + ?.map((prop) => { + 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 this.isTwigComponent + ? formatTwigAttribute(cleanName, String(prop[1].value)) + : formatDataAttribute(cleanName, String(prop[1].value)); + } + return ""; + }) + .filter(Boolean) + .sort() + .forEach((d) => this.data.push(d)); } const classLabel = getClassLabel(this.isJSX); const classNames = this.attributes.length > 0 - ? ` ${classLabel}="${this.attributes.join(" ")}"` + ? ` ${classLabel}="${this.attributes.filter(Boolean).join(" ")}"` : ""; const styles = this.style.length > 0 ? ` style="${this.style}"` : ""; const dataAttributes = this.data.join(""); diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index ff3d3441..ea4243c3 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -1,102 +1,115 @@ import { retrieveTopFill } from "../common/retrieveFill"; import { indentString } from "../common/indentString"; +import { addWarning } from "../common/commonConversionWarnings"; +import { getVisibleNodes } from "../common/nodeVisibility"; +import { getPlaceholderImage } from "../common/images"; import { TailwindTextBuilder } from "./tailwindTextBuilder"; import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; import { tailwindAutoLayoutProps } from "./builderImpl/tailwindAutoLayout"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; -import { AltNode, PluginSettings, TailwindSettings } from "types"; -import { addWarning } from "../common/commonConversionWarnings"; import { renderAndAttachSVG } from "../altNodes/altNodeUtils"; -import { getVisibleNodes } from "../common/nodeVisibility"; +import { AltNode, PluginSettings, TailwindSettings } from "types"; export let localTailwindSettings: PluginSettings; - -let previousExecutionCache: { style: string; text: string }[]; - -const selfClosingTags = ["img"]; +let previousExecutionCache: { + style: string; + text: string; + openTypeFeatures: Record; +}[] = []; +const SELF_CLOSING_TAGS = ["img"]; export const tailwindMain = async ( sceneNode: Array, settings: PluginSettings, -) => { +): Promise => { localTailwindSettings = settings; previousExecutionCache = []; let result = await tailwindWidgetGenerator(sceneNode, settings); - // remove the initial \n that is made in Container. - if (result.length > 0 && result.startsWith("\n")) { - result = result.slice(1, result.length); + // Remove the initial newline that is made in Container + if (result.startsWith("\n")) { + result = result.slice(1); } return result; }; -// TODO: lint idea: replace BorderRadius.only(topleft: 8, topRight: 8) with BorderRadius.horizontal(8) const tailwindWidgetGenerator = async ( sceneNode: ReadonlyArray, settings: TailwindSettings, ): Promise => { - // filter non visible nodes. This is necessary at this step because conversion already happened. - const promiseOfConvertedCode = getVisibleNodes(sceneNode).map( - convertNode(settings), - ); + const visibleNodes = getVisibleNodes(sceneNode); + const promiseOfConvertedCode = visibleNodes.map(convertNode(settings)); const code = (await Promise.all(promiseOfConvertedCode)).join(""); return code; }; -const convertNode = (settings: TailwindSettings) => async (node: SceneNode) => { - const altNode = await renderAndAttachSVG(node); - if (altNode.svg) return tailwindWrapSVG(altNode, settings); - - switch (node.type) { - case "RECTANGLE": - case "ELLIPSE": - return tailwindContainer(node, "", "", settings); - case "GROUP": - return tailwindGroup(node, settings); - case "FRAME": - case "COMPONENT": - case "INSTANCE": - case "COMPONENT_SET": - return tailwindFrame(node, settings); - case "TEXT": - return tailwindText(node, settings); - case "LINE": - return tailwindLine(node, settings); - case "SECTION": - return tailwindSection(node, settings); - case "VECTOR": - addWarning("VectorNodes are not supported in Tailwind"); - break; - default: - addWarning(`${node.type} nodes are not supported in Tailwind`); - } - return ""; -}; +const convertNode = + (settings: TailwindSettings) => + async (node: SceneNode): Promise => { + if (settings.embedVectors && (node as any).canBeFlattened) { + const altNode = await renderAndAttachSVG(node); + if (altNode.svg) { + return tailwindWrapSVG(altNode, settings); + } + } + + switch ((node as any).type) { + case "RECTANGLE": + case "ELLIPSE": + return tailwindContainer(node, "", "", settings); + case "GROUP": + return tailwindGroup(node, settings); + case "FRAME": + case "COMPONENT": + case "INSTANCE": + case "COMPONENT_SET": + case "SLOT": + return tailwindFrame(node, settings); + case "TEXT": + return tailwindText(node, settings); + case "LINE": + return tailwindLine(node, settings); + case "SECTION": + return tailwindSection(node, settings); + case "VECTOR": + if (!settings.embedVectors) { + addWarning("Vector is not supported"); + } + return tailwindContainer( + { ...node, type: "RECTANGLE" } as any, + "", + "", + settings, + ); + default: + addWarning(`${node.type} node is not supported`); + } + return ""; + }; const tailwindWrapSVG = ( node: AltNode, settings: TailwindSettings, ): string => { - if (node.svg === "") return ""; + if (!node.svg) return ""; + const builder = new TailwindDefaultBuilder(node, settings) .addData("svg-wrapper") .position(); - return `\n\n${node.svg ?? ""}`; + return `\n\n${indentString(node.svg ?? "")}`; }; -const tailwindGroup = async (node: GroupNode, settings: TailwindSettings) => { - // ignore the view when size is zero or less - // while technically it shouldn't get less than 0, due to rounding errors, - // it can get to values like: -0.000004196293048153166 - // also ignore if there are no children inside, which makes no sense +const tailwindGroup = async ( + node: GroupNode, + settings: TailwindSettings, +): Promise => { + // Ignore the view when size is zero or less or if there are no children if (node.width < 0 || node.height <= 0 || node.children.length === 0) { return ""; } - // this needs to be called after CustomNode because widthHeight depends on it const builder = new TailwindDefaultBuilder(node, settings) .blend() .size() @@ -104,9 +117,7 @@ const tailwindGroup = async (node: GroupNode, settings: TailwindSettings) => { if (builder.attributes || builder.style) { const attr = builder.build(""); - const generator = await tailwindWidgetGenerator(node.children, settings); - return `\n${indentString(generator)}\n`; } @@ -117,28 +128,29 @@ export const tailwindText = ( node: TextNode, settings: TailwindSettings, ): string => { - let layoutBuilder = new TailwindTextBuilder(node, settings) + const layoutBuilder = new TailwindTextBuilder(node, settings) .commonPositionStyles() - .textAlign(); + .textAlignHorizontal() + .textAlignVertical(); - const styledHtml = layoutBuilder.getTextSegments(node.id); + const styledHtml = layoutBuilder.getTextSegments(node); previousExecutionCache.push(...styledHtml); let content = ""; if (styledHtml.length === 1) { - layoutBuilder.addAttributes(styledHtml[0].style); - content = styledHtml[0].text; - - const additionalTag = - styledHtml[0].openTypeFeatures.SUBS === true - ? "sub" - : styledHtml[0].openTypeFeatures.SUPS === true - ? "sup" - : ""; - - if (additionalTag) { - content = `<${additionalTag}>${content}`; - } + const segment = styledHtml[0]; + layoutBuilder.addAttributes(segment.style); + + const getFeatureTag = (features: Record): string => { + if (features.SUBS === true) return "sub"; + if (features.SUPS === true) return "sup"; + return ""; + }; + + const additionalTag = getFeatureTag(segment.openTypeFeatures); + content = additionalTag + ? `<${additionalTag}>${segment.text}` + : segment.text; } else { content = styledHtml .map((style) => { @@ -161,47 +173,82 @@ const tailwindFrame = async ( node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, settings: TailwindSettings, ): Promise => { - const childrenStr = await tailwindWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout( - node, - localTailwindSettings.optimizeLayout, - ), - settings, - ); + // 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); - // Add overflow-hidden class if clipsContent is true - const clipsContentClass = node.clipsContent ? " overflow-hidden" : ""; + const clipsContentClass = + node.clipsContent && "children" in node && node.children.length > 0 + ? "overflow-hidden" + : ""; + let layoutProps = ""; if (node.layoutMode !== "NONE") { - const rowColumn = tailwindAutoLayoutProps(node, node); - return tailwindContainer( - node, - childrenStr, - rowColumn + clipsContentClass, - settings, - ); + layoutProps = tailwindAutoLayoutProps(node, node); + } + + // Combine classes properly, ensuring no extra spaces + const combinedProps = [layoutProps, clipsContentClass] + .filter(Boolean) + .join(" "); + + 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 { - if ( - localTailwindSettings.optimizeLayout && - node.inferredAutoLayout !== null - ) { - const rowColumn = tailwindAutoLayoutProps(node, node.inferredAutoLayout); - return tailwindContainer( - node, - childrenStr, - rowColumn + clipsContentClass, - settings, - ); - } + // 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"); +} - // node.layoutMode === "NONE" && node.children.length > 1 - // children needs to be absolute - return tailwindContainer(node, childrenStr, clipsContentClass, settings); +// 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; }; -// properties named propSomething always take care of "," -// sometimes a property might not exist, so it doesn't add "," export const tailwindContainer = ( node: SceneNode & SceneNodeMixin & @@ -213,49 +260,49 @@ export const tailwindContainer = ( additionalAttr: string, settings: TailwindSettings, ): string => { - // ignore the view when size is zero or less - // while technically it shouldn't get less than 0, due to rounding errors, - // it can get to values like: -0.000004196293048153166 + // Ignore the view when size is zero or less if (node.width < 0 || node.height < 0) { return children; } - let builder = new TailwindDefaultBuilder(node, settings) + const builder = new TailwindDefaultBuilder(node, settings) .commonPositionStyles() .commonShapeStyles(); - if (builder.attributes || additionalAttr) { - const build = builder.build(additionalAttr); - - // image fill and no children -- let's emit an - let tag = "div"; - let src = ""; - if (retrieveTopFill(node.fills)?.type === "IMAGE") { - addWarning("Image fills are replaced with placeholders"); - if (!("children" in node) || node.children.length === 0) { - tag = "img"; - src = ` src="https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)}"`; - } else { - builder.addAttributes( - `bg-[url(https://via.placeholder.com/${node.width.toFixed( - 0, - )}x${node.height.toFixed(0)})]`, - ); - } - } + if (!builder.attributes && !additionalAttr) { + return children; + } + + const build = builder.build(additionalAttr); - if (children) { - return `\n<${tag}${build}${src}>${indentString(children)}\n`; - } else if (selfClosingTags.includes(tag) || settings.jsx) { - return `\n<${tag}${build}${src} />`; + // Determine if we should use img tag + let tag = "div"; + let src = ""; + const topFill = retrieveTopFill(node.fills); + + if (topFill?.type === "IMAGE") { + addWarning("Image fills are replaced with placeholders"); + const imageURL = getPlaceholderImage(node.width, node.height); + + if (!("children" in node) || node.children.length === 0) { + tag = "img"; + src = ` src="${imageURL}"`; } else { - return `\n<${tag}${build}${src}>`; + builder.addAttributes(`bg-[url(${imageURL})]`); } } - return children; + // Generate appropriate HTML + if (children) { + return `\n<${tag}${build}${src}>${indentString(children)}\n`; + } else if ( + SELF_CLOSING_TAGS.includes(tag) || + settings.tailwindGenerationMode === "jsx" + ) { + return `\n<${tag}${build}${src} />`; + } else { + return `\n<${tag}${build}${src}>`; + } }; export const tailwindLine = ( @@ -279,21 +326,18 @@ export const tailwindSection = async ( .position() .customColor(node.fills, "bg"); - if (childrenStr) { - return `\n${indentString(childrenStr)}\n`; - } else { - return `\n`; - } + const build = builder.build(); + return childrenStr + ? `\n${indentString(childrenStr)}\n` + : `\n`; }; -export const tailwindCodeGenTextStyles = () => { - const result = previousExecutionCache - .map((style) => `// ${style.text}\n${style.style.split(" ").join("\n")}`) - .join("\n---\n"); - - if (!result) { +export const tailwindCodeGenTextStyles = (): string => { + if (previousExecutionCache.length === 0) { return "// No text styles in this selection"; } - return result; + return previousExecutionCache + .map((style) => `// ${style.text}\n${style.style.split(" ").join("\n")}`) + .join("\n---\n"); }; diff --git a/packages/backend/src/tailwind/tailwindTextBuilder.ts b/packages/backend/src/tailwind/tailwindTextBuilder.ts index 9c23e03d..750ba8f8 100644 --- a/packages/backend/src/tailwind/tailwindTextBuilder.ts +++ b/packages/backend/src/tailwind/tailwindTextBuilder.ts @@ -1,24 +1,29 @@ -import { globalTextStyleSegments } from "../altNodes/altConversion"; import { commonLetterSpacing, commonLineHeight, } from "../common/commonTextHeightSpacing"; +import { escapeJSXText } from "../common/parseJSX"; import { tailwindColorFromFills } from "./builderImpl/tailwindColor"; import { pxToFontSize, pxToLetterSpacing, pxToLineHeight, + pxToBlur, } from "./conversionTables"; import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; import { config } from "./tailwindConfig"; +import { StyledTextSegmentSubset } from "types"; +import { localTailwindSettings } from "./tailwindMain"; export class TailwindTextBuilder extends TailwindDefaultBuilder { - getTextSegments(id: string): { + getTextSegments(node: TextNode): { style: string; text: string; openTypeFeatures: { [key: string]: boolean }; }[] { - const segments = globalTextStyleSegments[id]; + const segments = (node as any) + .styledTextSegments as StyledTextSegmentSubset[]; + if (!segments) { return []; } @@ -36,6 +41,8 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { segment.fontSize, ); // const textIndentStyle = this.indentStyle(segment.indentation); + const blurStyle = this.layerBlur(); + const shadowStyle = this.textShadow(); const styleClasses = [ color, @@ -47,11 +54,18 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { lineHeightStyle, letterSpacingStyle, // textIndentStyle, + blurStyle, + shadowStyle, + this.truncateText(node), ] - .filter((d) => d !== "") + .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, @@ -60,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"], ) => { @@ -86,17 +113,39 @@ 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"; + } } - return "font-['" + fontName.family + "']"; + const underscoreFontName = fontName.family.replace(/\s/g, "_"); + + return "font-['" + underscoreFontName + "']"; }; /** @@ -129,8 +178,8 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { } const value = node.fontName.style - .replace("italic", "") - .replace(" ", "") + .replaceAll("italic", "") + .replaceAll(" ", "") .toLowerCase(); this.addAttributes(`font-${value}`); @@ -170,7 +219,7 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { * https://tailwindcss.com/docs/text-align/ * example: text-justify */ - textAlign(): this { + textAlignHorizontal(): this { // if alignHorizontal is LEFT, don't do anything because that is native const node = this.node as TextNode; // only undefined in testing @@ -194,6 +243,29 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { return this; } + /** + * https://tailwindcss.com/docs/vertical-align/ + * example: align-top, align-middle, align-bottom + */ + textAlignVertical(): this { + const node = this.node as TextNode; + switch (node.textAlignVertical) { + case "TOP": + this.addAttributes("justify-start"); + break; + case "CENTER": + this.addAttributes("justify-center"); + break; + case "BOTTOM": + this.addAttributes("justify-end"); + break; + default: + break; + } + + return this; + } + /** * https://tailwindcss.com/docs/text-transform/ * example: uppercase @@ -229,6 +301,54 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { } } + /** + * https://v3.tailwindcss.com/docs/blur + */ + layerBlur = (): string => { + if (this.node && (this.node as TextNode).effects) { + const effects = (this.node as TextNode).effects; + const blurEffect = effects.find( + (effect) => effect.type === "LAYER_BLUR" && effect.visible !== false, + ); + if (blurEffect && blurEffect.radius && blurEffect.radius > 0) { + const blurSuffix = pxToBlur(blurEffect.radius); + if (blurSuffix) { + return `blur-${blurSuffix}`; + } + } + } + return ""; + }; + + /** + * New method to handle text shadow. + * When a drop shadow is applied to a text element, + * this method returns an arbitrary Tailwind utility class + * in the following format: + * + * [text-shadow:_0px_4px_4px_rgb(0_0_0_/_0.50)] + */ + textShadow = (): string => { + if (this.node && (this.node as TextNode).effects) { + const effects = (this.node as TextNode).effects; + const dropShadow = effects.find( + (effect) => effect.type === "DROP_SHADOW" && effect.visible !== false, + ); + if (dropShadow) { + const ds = dropShadow as DropShadowEffect; + const offsetX = Math.round(ds.offset.x); + const offsetY = Math.round(ds.offset.y); + const blurRadius = Math.round(ds.radius); + const r = Math.round(ds.color.r * 255); + const g = Math.round(ds.color.g * 255); + const b = Math.round(ds.color.b * 255); + const aFixed = ds.color.a.toFixed(2); + return `[text-shadow:_${offsetX}px_${offsetY}px_${blurRadius}px_rgb(${r}_${g}_${b}_/_${aFixed})]`; + } + } + return ""; + }; + reset(): void { this.attributes = []; } diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 91fbbced..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": "^14.2.20", - "eslint-config-prettier": "^9.1.0", - "eslint-config-turbo": "^2.3.3", - "eslint-plugin-react": "7.35.0" + "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 c3ebc43b..a2de4ef2 100644 --- a/packages/plugin-ui/package.json +++ b/packages/plugin-ui/package.json @@ -10,19 +10,24 @@ "lint": "eslint \"src/**/*.ts*\"" }, "dependencies": { - "@types/react": "^18.3.17", - "@types/react-dom": "^18.3.5", + "@base-ui/react": "^1.4.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "15.5.13", - "copy-to-clipboard": "^3.3.3", - "react": "^18.3.1", - "react-syntax-highlighter": "^15.6.1", - "tailwindcss": "3.4.6" + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "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.17.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", "types": "workspace:*", - "typescript": "^5.7.2" + "typescript": "^6.0.3" } } diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index bd8fefe5..abbfa24f 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; import copy from "copy-to-clipboard"; import Preview from "./components/Preview"; import GradientsPanel from "./components/GradientsPanel"; import ColorsPanel from "./components/ColorsPanel"; import CodePanel from "./components/CodePanel"; -import WarningIcon from "./components/WarningIcon"; +import EmptyState from "./components/EmptyState"; +import About from "./components/About"; +import WarningsPanel from "./components/WarningsPanel"; import { Framework, HTMLPreview, @@ -17,6 +18,13 @@ import { preferenceOptions, selectPreferenceOptions, } from "./codegenPreferenceOptions"; +import Loading from "./components/Loading"; +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; @@ -25,94 +33,192 @@ type PluginUIProps = { selectedFramework: Framework; setSelectedFramework: (framework: Framework) => void; settings: PluginSettings | null; - onPreferenceChanged: (key: string, value: boolean | string) => void; + onPreferenceChanged: ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => void; colors: SolidColorConversion[]; gradients: LinearGradientConversion[]; + isLoading: boolean; }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; +const LOADING_INDICATOR_DELAY_MS = 250; + +type FrameworkTabsProps = { + frameworks: Framework[]; + selectedFramework: Framework; + setSelectedFramework: (framework: Framework) => void; + showAbout: boolean; + setShowAbout: (show: boolean) => void; +}; + +const FrameworkTabs = ({ + frameworks, + selectedFramework, + setSelectedFramework, + showAbout, + setShowAbout, +}: FrameworkTabsProps) => { + return ( +
+ {frameworks.map((tab) => ( + + ))} +
+ ); +}; export const PluginUI = (props: PluginUIProps) => { - const isEmpty = props.code === ""; + const [showAbout, setShowAbout] = useState(false); + const [showLoading, setShowLoading] = useState(false); + const [hasHandledInitialLoad, setHasHandledInitialLoad] = useState(false); + const [previewExpanded, setPreviewExpanded] = useState(false); + const [previewViewMode, setPreviewViewMode] = useState< + "desktop" | "mobile" | "precision" + >("precision"); + const [previewBgColor, setPreviewBgColor] = useState<"white" | "black">( + "white", + ); + + 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 ( -
-
- {frameworks.map((tab) => ( - - ))} -
-
-
-
- {isEmpty === false && props.htmlPreview && ( - - )} - {warnings.length > 0 && ( -
-
-
- -
-

Warnings:

-
-
    - {warnings.map((message: string) => ( -
  • - {message} -
  • - ))} -
-
- )} - - - {props.colors.length > 0 && ( - { - copy(value); - }} + +
+
+
+ - )} - - {props.gradients.length > 0 && ( - { - copy(value); + +
+
+
+ + {showAbout ? ( + + ) : isEmpty ? ( +
+ +
+ ) : ( +
+ {props.htmlPreview && ( + + )} + + {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 9caa850a..fed6d2d9 100644 --- a/packages/plugin-ui/src/codegenPreferenceOptions.ts +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -3,25 +3,17 @@ import { LocalCodegenPreferenceOptions, SelectPreferenceOptions } from "types"; export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ { itemType: "individual_select", - propertyName: "jsx", - label: "React (JSX)", - description: 'Render "class" attributes as "className"', - isDefault: false, - includedLanguages: ["HTML", "Tailwind"], - }, - { - itemType: "individual_select", - propertyName: "optimizeLayout", - label: "Optimize layout", - description: "Attempt to auto-layout suitable element groups", + propertyName: "useTailwind4", + label: "Tailwind 4", + description: "Enable Tailwind CSS version 4 features and syntax.", isDefault: true, - includedLanguages: ["HTML", "Tailwind", "Flutter", "SwiftUI"], + includedLanguages: ["Tailwind"], }, { itemType: "individual_select", propertyName: "showLayerNames", label: "Layer names", - description: "Include layer names in classes", + description: "Include Figma layer names in classes.", isDefault: false, includedLanguages: ["HTML", "Tailwind"], }, @@ -29,7 +21,8 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ itemType: "individual_select", propertyName: "roundTailwindValues", label: "Round values", - description: "Round pixel values to nearest Tailwind sizes", + description: + "Round pixel values to nearest Tailwind sizes (within a 15% range).", isDefault: false, includedLanguages: ["Tailwind"], }, @@ -37,22 +30,63 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ itemType: "individual_select", propertyName: "roundTailwindColors", label: "Round colors", - description: "Round color values to nearest Tailwind colors", + description: "Round Figma color values to nearest Tailwind colors.", isDefault: false, includedLanguages: ["Tailwind"], }, { itemType: "individual_select", - propertyName: "customTailwindColors", - label: "Custom colors", - description: "Use color variable names as custom color names", + propertyName: "useColorVariables", + label: "Color Variables", + description: + "Export code using Figma variables as colors. Example: 'bg-background' instead of 'bg-white'.", + isDefault: true, + includedLanguages: ["HTML", "Tailwind", "Flutter", "Compose"], + }, + { + itemType: "individual_select", + propertyName: "embedImages", + label: "Embed Images", + description: + "Convert Figma images to Base64 and embed them in the code. This may be slow. If there are too many images, it could freeze Figma.", isDefault: false, - includedLanguages: ["Tailwind"], + includedLanguages: ["HTML"], + }, + { + itemType: "individual_select", + propertyName: "embedVectors", + label: "Embed Vectors", + description: + "Enable this to convert vector shapes to SVGs and embed them in the design. This can be a slow operation. If unchecked, shapes will be converted into rectangles.", + isDefault: false, + includedLanguages: ["HTML", "Tailwind"], }, - // Add your preferences data here ]; export const selectPreferenceOptions: SelectPreferenceOptions[] = [ + { + itemType: "select", + propertyName: "htmlGenerationMode", + label: "Mode", + options: [ + { label: "HTML", value: "html" }, + { label: "React (JSX)", value: "jsx" }, + { label: "Svelte", value: "svelte" }, + { label: "styled-components", value: "styled-components" }, + ], + includedLanguages: ["HTML"], + }, + { + itemType: "select", + propertyName: "tailwindGenerationMode", + label: "Mode", + options: [ + { label: "HTML", value: "html" }, + { label: "React (JSX)", value: "jsx" }, + { label: "Twig", value: "twig" }, + ], + includedLanguages: ["Tailwind"], + }, { itemType: "select", propertyName: "flutterGenerationMode", @@ -75,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 new file mode 100644 index 00000000..25f28872 --- /dev/null +++ b/packages/plugin-ui/src/components/About.tsx @@ -0,0 +1,299 @@ +import { useState } from "react"; +import { + ArrowRightIcon, + Code, + Heart, + Lock, + MessageCircle, + Star, + Zap, + Copy, + CheckCircle, + ToggleLeft, + 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: PluginSettings[keyof PluginSettings], + ) => void; +}; + +const About = ({ + useOldPluginVersion = false, + onPreferenceChanged, +}: AboutProps) => { + const [copied, setCopied] = useState(false); + + const copySelectionJson = async () => { + try { + // Send message to the plugin to get selection JSON + parent.postMessage( + { pluginMessage: { type: "get-selection-json" } }, + "*", + ); + + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy selection JSON:", error); + } + }; + + const togglePluginVersion = () => { + onPreferenceChanged("useOldPluginVersion2025", !useOldPluginVersion); + }; + + return ( +
+ {/* Header Section with Logo and Title */} +
+
+ +
+

Figma to Code

+
+ Created with + + by Bernardo Ferrari +
+ +
+ + {/* Cards Section */} +
+ {/* Privacy Policy Card */} + + +
+
+ +
+

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. +

+
+
+ + {/* 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 */} + + +
+
+ +
+

Get in Touch

+
+

+ Have feedback, questions, or need help? Open a GitHub issue: +

+ +
+
+ + {/* Debug Helper Card */} + + +
+
+ +
+

Debug Helper

+
+

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

+ + + {/* 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 */} +
+

+ © {new Date().getFullYear()} Bernardo Ferrari. All rights reserved. +

+
+
+ ); +}; + +function GithubLogo({ + width = 18, + height = 18, + className, +}: { + width?: number; + height?: number; + className?: string; +}) { + return ( + + ); +} + +function XLogo() { + return ( + + + + ); +} + +export default About; diff --git a/packages/plugin-ui/src/components/CodePanel.tsx b/packages/plugin-ui/src/components/CodePanel.tsx index 32c84b74..9ed0a577 100644 --- a/packages/plugin-ui/src/components/CodePanel.tsx +++ b/packages/plugin-ui/src/components/CodePanel.tsx @@ -4,11 +4,14 @@ import { PluginSettings, SelectPreferenceOptions, } from "types"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coldarkDark as theme } from "react-syntax-highlighter/dist/esm/styles/prism"; -import copy from "copy-to-clipboard"; -import SelectableToggle from "./SelectableToggle"; +import { CopyButton } from "./CopyButton"; +import EmptyState from "./EmptyState"; +import SettingsGroup from "./SettingsGroup"; +import FrameworkTabs from "./FrameworkTabs"; +import { TailwindSettings } from "./TailwindSettings"; interface CodePanelProps { code: string; @@ -16,12 +19,16 @@ interface CodePanelProps { settings: PluginSettings | null; preferenceOptions: LocalCodegenPreferenceOptions[]; selectPreferenceOptions: SelectPreferenceOptions[]; - onPreferenceChanged: (key: string, value: boolean | string) => void; + onPreferenceChanged: ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => void; } const CodePanel = (props: CodePanelProps) => { - const [isPressed, setIsPressed] = useState(false); const [syntaxHovered, setSyntaxHovered] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const initialLinesToShow = 25; const { code, preferenceOptions, @@ -30,129 +37,243 @@ const CodePanel = (props: CodePanelProps) => { settings, onPreferenceChanged, } = props; - const isEmpty = code === ""; + const isCodeEmpty = code === ""; - // Add your clipboard function here or any other actions - const handleButtonClick = () => { - setIsPressed(true); - setTimeout(() => setIsPressed(false), 250); - copy(code); + // Helper function to add the prefix before every class (or className) in the code. + // It finds every occurrence of class="..." or className="..." and, for each class, + // prepends the custom prefix. + const applyPrefixToClasses = ( + codeString: string, + prefix: string | undefined, + ) => { + if (!prefix) { + return codeString; + } + + return codeString.replace( + /(class(?:Name)?)="([^"]*)"/g, + (match, attr, classes) => { + const prefixedClasses = classes + .split(/\s+/) + .filter(Boolean) + .map((cls: string) => prefix + cls) + .join(" "); + return `${attr}="${prefixedClasses}"`; + }, + ); + }; + + // Function to truncate code to a specific number of lines + const truncateCode = (codeString: string, lines: number) => { + const codeLines = codeString.split("\n"); + if (codeLines.length <= lines) { + return codeString; + } + return codeLines.slice(0, lines).join("\n") + "\n..."; }; + // If the selected framework is Tailwind and a prefix is provided then transform the code. + const prefixedCode = + selectedFramework === "Tailwind" && + settings?.customTailwindPrefix?.trim() !== "" + ? applyPrefixToClasses(code, settings?.customTailwindPrefix) + : code; + + // Memoize the line count calculation to improve performance for large code blocks + const lineCount = useMemo( + () => prefixedCode.split("\n").length, + [prefixedCode], + ); + + // Determine if code should be truncated + const shouldTruncate = !isExpanded && lineCount > initialLinesToShow; + const displayedCode = shouldTruncate + ? truncateCode(prefixedCode, initialLinesToShow) + : prefixedCode; + const showMoreButton = lineCount > initialLinesToShow; + const showCodeCopyButton = lineCount > 5; + const handleButtonHover = () => setSyntaxHovered(true); const handleButtonLeave = () => setSyntaxHovered(false); - const selectableSettingsFiltered = selectPreferenceOptions.filter( - (preference) => - preference.includedLanguages?.includes(props.selectedFramework), - ); + // Memoized preference groups for better performance + const { + essentialPreferences, + stylingPreferences, + selectableSettingsFiltered, + } = useMemo(() => { + // Get preferences for the current framework + const frameworkPreferences = preferenceOptions.filter((preference) => + preference.includedLanguages?.includes(selectedFramework), + ); + + // Define preference grouping based on property names + const essentialPropertyNames = ["jsx"]; + const stylingPropertyNames = [ + "useTailwind4", + "roundTailwindValues", + "roundTailwindColors", + "useColorVariables", + "showLayerNames", + "embedImages", + "embedVectors", + ]; + + // Group preferences by category + return { + essentialPreferences: frameworkPreferences.filter((p) => + essentialPropertyNames.includes(p.propertyName), + ), + stylingPreferences: frameworkPreferences.filter((p) => + stylingPropertyNames.includes(p.propertyName), + ), + selectableSettingsFiltered: selectPreferenceOptions.filter((p) => + p.includedLanguages?.includes(selectedFramework), + ), + }; + }, [preferenceOptions, selectPreferenceOptions, selectedFramework]); + + const hasSettingsBeforeStyling = + essentialPreferences.length > 0 || selectableSettingsFiltered.length > 0; return (
-

+

Code

- {isEmpty === false && ( - + /> )}
- {isEmpty === false && ( -
-
- {preferenceOptions - .filter((preference) => - preference.includedLanguages?.includes(selectedFramework), - ) - .map((preference) => ( - { - onPreferenceChanged(preference.propertyName, value); - }} - buttonClass="bg-green-100 dark:bg-black dark:ring-green-800 ring-green-500" - checkClass="bg-green-400 dark:bg-black dark:bg-green-500 dark:border-green-500 ring-green-300 border-green-400" - /> - ))} -
+ {!isCodeEmpty && ( +
+ {/* Essential settings always shown */} + + + {/* Framework-specific options */} {selectableSettingsFiltered.length > 0 && ( - <> -
- -
- {selectableSettingsFiltered.map((preference) => ( - <> - {preference.options.map((option) => ( - { - onPreferenceChanged( - preference.propertyName, - option.value, - ); - }} - buttonClass="bg-blue-100 dark:bg-black dark:ring-blue-800" - checkClass="bg-blue-400 dark:bg-black dark:bg-blue-500 dark:border-blue-500 ring-blue-300 border-blue-400" - /> - ))} - - ))} -
- +
+

+ {selectedFramework} Options +

+ {selectableSettingsFiltered.map((preference) => { + // Regular toggle buttons for other options + return ( + option.isDefault) + ?.value ?? + "") as string + } + onChange={(value) => { + onPreferenceChanged(preference.propertyName, value); + }} + /> + ); + })} +
+ )} + + {/* Styling preferences with custom prefix for Tailwind */} + {(stylingPreferences.length > 0 || + selectedFramework === "Tailwind") && ( +
+ + {selectedFramework === "Tailwind" && ( + + )} + +
)}
)}
- {isEmpty ? ( -

No layer is selected. Please select a layer.

+ {isCodeEmpty ? ( + ) : ( - - {code} - + <> + {showCodeCopyButton && ( +
+ +
+ )} + + {displayedCode} + + {showMoreButton && ( +
+ +
+ )} + )}
); }; + export default CodePanel; diff --git a/packages/plugin-ui/src/components/ColorsPanel.tsx b/packages/plugin-ui/src/components/ColorsPanel.tsx index ed6463b3..eec35ffd 100644 --- a/packages/plugin-ui/src/components/ColorsPanel.tsx +++ b/packages/plugin-ui/src/components/ColorsPanel.tsx @@ -13,24 +13,43 @@ const ColorsPanel = (props: { props.onColorClick(value); }; + // Helper function to format complex color values + const formatColorValue = (value: string) => { + // Extract CSS variable name if present + if (value.includes("var(--")) { + const varMatch = value.match(/var\(--([\w-]+)/); + return varMatch ? `--${varMatch[1]}` : value; + } + return value; + }; + return ( -
-

- Colors -

+
+
+
+

+ Color Palette +

+ + {props.colors.length} color{props.colors.length > 1 ? "s" : ""} + +
+
+
{props.colors.map((color, idx) => ( ))} diff --git a/packages/plugin-ui/src/components/CopyButton.tsx b/packages/plugin-ui/src/components/CopyButton.tsx new file mode 100644 index 00000000..13b44a1f --- /dev/null +++ b/packages/plugin-ui/src/components/CopyButton.tsx @@ -0,0 +1,83 @@ +"use client"; + +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; + className?: string; + showLabel?: boolean; + successDuration?: number; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + +export function CopyButton({ + value, + className, + showLabel = true, + successDuration = 1500, + onMouseEnter, + onMouseLeave, +}: CopyButtonProps) { + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (!isCopied) return; + const timer = setTimeout(() => setIsCopied(false), successDuration); + return () => clearTimeout(timer); + }, [isCopied, successDuration]); + + const handleCopy = useCallback(() => { + try { + copy(value); + setIsCopied(true); + } catch (error) { + console.error("Failed to copy text: ", error); + } + }, [value]); + + return ( + + ); +} diff --git a/packages/plugin-ui/src/components/CustomPrefixInput.tsx b/packages/plugin-ui/src/components/CustomPrefixInput.tsx new file mode 100644 index 00000000..43549176 --- /dev/null +++ b/packages/plugin-ui/src/components/CustomPrefixInput.tsx @@ -0,0 +1,366 @@ +import React, { useState, useRef, useEffect } from "react"; +import { HelpCircle, Check } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +interface FormFieldProps { + // Common props + label: string; + initialValue: string | number; + onValueChange: (value: string | number) => void; + placeholder?: string; + helpText?: string; + + // Validation props + type?: "text" | "number" | "json"; + min?: number; + max?: number; + suffix?: string; + + // For text input validation + disallowedPattern?: RegExp; + disallowedMessage?: string; + + // Optional preview (for text inputs) + showPreview?: boolean; + previewExamples?: string[]; + previewTransform?: (value: string, example: string) => React.ReactNode; +} + +const FormField = React.memo( + ({ + label, + initialValue, + onValueChange, + placeholder, + helpText, + type = "text", + min, + max, + suffix, + disallowedPattern = /\s/, + disallowedMessage = "Input cannot contain spaces", + showPreview = false, + previewExamples = ["flex"], + previewTransform, + }: FormFieldProps) => { + // Use internal state to manage the input value + const [inputValue, setInputValue] = useState(String(initialValue)); + const [isFocused, setIsFocused] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + 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(() => { + setInputValue(String(initialValue)); + setHasChanges(false); + setHasError(false); + setErrorMessage(""); + }, [initialValue]); + + const validateInput = (value: string): boolean => { + // Text validation + if (type === "text") { + if (disallowedPattern && disallowedPattern.test(value)) { + setHasError(true); + setErrorMessage(disallowedMessage); + return false; + } + setHasError(false); + setErrorMessage(""); + return true; + } + + // Number validation + if (type === "number") { + // Check for non-numeric characters + if (/[^0-9]/.test(value)) { + setHasError(true); + setErrorMessage("Only numbers are allowed"); + return false; + } + + const numValue = parseInt(value, 10); + + if (isNaN(numValue)) { + setHasError(true); + setErrorMessage("Please enter a valid number"); + return false; + } + + if (min !== undefined && numValue < min) { + setHasError(true); + setErrorMessage(`Minimum value is ${min}`); + return false; + } + + if (max !== undefined && numValue > max) { + setHasError(true); + setErrorMessage(`Maximum value is ${max}`); + return false; + } + + setHasError(false); + setErrorMessage(""); + 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; + }; + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + validateInput(newValue); + 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; + + if (type === "number") { + const numValue = parseInt(inputValue, 10); + if (!isNaN(numValue)) { + onValueChange(numValue); + } + } else { + onValueChange(inputValue); + } + + setHasChanges(false); + + // Show success indicator briefly + setShowSuccess(true); + setTimeout(() => setShowSuccess(false), 1500); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + applyChanges(); + inputRef.current?.blur(); + } + }; + + 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) => ( +
+
+ {value} + {example} +
+ +
+ {example} +
+
+ ); + + const renderPreview = previewTransform || defaultPreviewTransform; + + return ( +
+
+ + + {helpText && ( + + } + > + + + {helpText} + + )} + + {showSuccess && ( + + Applied + + )} +
+ +
+
+
+ {type === "json" ? ( +