diff --git a/.eslintrc.js b/.eslintrc.js index cc83fed4..312e15c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ const config = { - extends: ["kentcdodds", "kentcdodds/jest"], + extends: ["kentcdodds"], rules: { "valid-jsdoc": "off", "max-len": "off", 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/.gitignore b/.gitignore index 3fbb8b82..360d5067 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ test/**/next-env.d.ts **/.idea **/.vscode -build \ No newline at end of file +build +.turbo \ No newline at end of file diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..dc0bb0f4 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v22.12.0 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/README.md b/README.md index 7bd98db6..34653f46 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,35 @@

-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: +Converting visual designs to code inevitably encounters complex edge cases. Here are some challenges the plugin handles: + +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. + +2. **Color Variables**: The plugin detects and processes color variables, allowing for theme-consistent output. + +3. **Gradients and Effects**: Different frameworks handle gradients and effects in unique ways, requiring specialized conversion logic. ![Conversion Workflow](assets/examples.png) @@ -35,24 +49,80 @@ When finding the unknown (a `Group` or `Frame` with more than one child and no v ### Todo -- 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 +- Vectors (possible to enable in HTML and Tailwind) +- Images (possible to enable to inline them in HTML and Tailwind) +- Line/Star/Polygon -### Tailwind limitations +## How to build the project -- **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. +### Package Manager -### Flutter limits and ideas +The project is configured for [pnpm](https://pnpm.io/). To install, see the [installation notes for pnpm](https://pnpm.io/installation). -- **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`. +### Monorepo -## How to build the project +The plugin is organized as a monorepo. There are several packages: + +- `packages/backend` - Contains the business logic that reads the Figma API and converts nodes +- `packages/plugin-ui` - Contains the common UI for the plugin +- `packages/eslint-config-custom` - Config file for ESLint +- `packages/tsconfig` - Collection of TSConfig files used throughout the project + +- `apps/plugin` - This is the actual plugin assembled from the parts in `backend` & `plugin-ui`. Within this folder it's divided between: + - `plugin-src` - loads from `backend` and compiles to `code.js` + - `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. + +### 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` - 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 + +When running the `dev` task, you can open `http://localhost:3000` to see the debug version of the UI. -The project is configured to be built with Webpack or Rollup. The author couldn't find a way to correctly configure Svelte in Webpack, so Rollup was added. But Rollup is a lot less stable than Webpack and crashes regularly in watch mode when editing Typescript files. So, if you are going to work with Typescript only, I recommend sticking with Webpack. If you are going to make changes in the UI, you **need** to use Rollup for now. +Screenshot 2024-12-13 at 16 26 43 ## Issues diff --git a/__tests__/README.md b/__tests__/README.md deleted file mode 100644 index 5520768c..00000000 --- a/__tests__/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Tests - -This project uses [Jest](https://jestjs.io/) for tests, and has some help from [Figma API Stub](https://github.com/react-figma/figma-api-stub), since the official Figma Plugins API is untestable. - -## Coverage - -Coverage is currently at an impressive 99%. You can inspect the coverage by clicking at the Codecov badge: -[![codecov](https://codecov.io/gh/bernaferrari/FigmaToCode/branch/master/graph/badge.svg)](https://codecov.io/gh/bernaferrari/FigmaToCode) - -![Coverage](../assets/coverage.png) - -## Test commands - -- To run the tests: `yarn test` or `yarn run test` -- To calculate the coverage: `yarn run coverage` -- To run ES Lint: `yarn run lint` diff --git a/__tests__/altNodes/altConversions.test.ts b/__tests__/altNodes/altConversions.test.ts deleted file mode 100644 index 3b2306f8..00000000 --- a/__tests__/altNodes/altConversions.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { createFigma } from "figma-api-stub"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { convertIntoAltNodes } from "../../src/altNodes/altConversion"; -import { htmlMain } from "./../../src/html/htmlMain"; -import { AltFrameNode } from "./../../src/altNodes/altMixins"; - -describe("AltConversions", () => { - const figma = createFigma({ - simulateErrors: true, - isWithoutTimeout: false, - }); - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = figma; - it("Rectangle", () => { - const rectangle = figma.createRectangle(); - rectangle.resize(20, 20); - - expect(tailwindMain(convertIntoAltNodes([rectangle]))).toEqual( - '
' - ); - }); - - it("Frame", () => { - const frame = figma.createFrame(); - frame.resize(20, 20); - frame.x = 0; - frame.y = 0; - frame.layoutMode = "HORIZONTAL"; - frame.counterAxisAlignItems = "CENTER"; - frame.primaryAxisAlignItems = "SPACE_BETWEEN"; - frame.counterAxisSizingMode = "FIXED"; - frame.primaryAxisSizingMode = "FIXED"; - - const rectangle = figma.createRectangle(); - rectangle.resize(20, 20); - rectangle.x = 0; - rectangle.y = 0; - rectangle.layoutGrow = 0; - rectangle.layoutAlign = "INHERIT"; - frame.appendChild(rectangle); - - expect(htmlMain(convertIntoAltNodes([frame]))).toEqual( - `
-
-
` - ); - }); - - // todo understand why it is failing - // it("Group wrapping single item", () => { - // // single Group should disappear - // const node = figma.createFrame(); - // node.resize(20, 20); - - // const rectangle = figma.createRectangle(); - // rectangle.resize(20, 20); - - // figma.group([rectangle], node); - - // const convert = convertIntoAltNodes([node]); - - // expect(tailwindMain(convert)).toEqual(`
`); - // }); - - // todo understand why it is failing - // it("Group wrapping two items", () => { - // // single Group should disappear - // const node = figma.createFrame(); - // node.resize(20, 20); - // node.primaryAxisAlignItems = "CENTER"; - // node.counterAxisAlignItems = "CENTER"; - - // const rect1 = figma.createRectangle(); - // rect1.resize(20, 20); - - // const rect2 = figma.createRectangle(); - // rect2.resize(20, 20); - - // figma.group([rect1, rect2], node); - - // const convert = convertIntoAltNodes([node]); - - // expect(tailwindMain(convert)).toEqual( - // `
- //
- //
- //
` - // ); - // }); - - it("Text", () => { - const node = figma.createText(); - - figma.loadFontAsync({ - family: "Roboto", - style: "Regular", - }); - - node.fontName = { family: "Roboto", style: "Regular" }; - node.characters = ""; - - expect( - tailwindMain(convertIntoAltNodes([node], new AltFrameNode())) - ).toEqual(`

`); - }); - - it("Ellipse", () => { - // this test requires mocking the full EllipseNode. Figma-api-stub doesn't support VectorNode. - class EllipseNode { - readonly type = "ELLIPSE"; - } - - interface EllipseNode - extends DefaultShapeMixin, - ConstraintMixin, - CornerMixin { - readonly type: "ELLIPSE"; - clone(): EllipseNode; - arcData: ArcData; - } - - const node = new EllipseNode(); - // set read-only variables - Object.defineProperty(node, "width", { value: 20 }); - Object.defineProperty(node, "height", { value: 20 }); - - expect( - tailwindMain(convertIntoAltNodes([node], new AltFrameNode())) - ).toEqual(`
`); - }); - - it("Line", () => { - // this test requires mocking the full EllipseNode. Figma-api-stub doesn't support VectorNode. - class LineNode { - readonly type = "LINE"; - } - - interface LineNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { - readonly type: "LINE"; - clone(): LineNode; - } - - const node = new LineNode(); - // set read-only variables - Object.defineProperty(node, "width", { value: 20 }); - - expect( - tailwindMain(convertIntoAltNodes([node], new AltFrameNode())) - ).toEqual(`
`); - }); - - it("Vector", () => { - // this test requires mocking the full VectorNode. Figma-api-stub doesn't support VectorNode. - class VectorNode { - readonly type = "VECTOR"; - } - - interface VectorNode - extends DefaultShapeMixin, - ConstraintMixin, - CornerMixin { - readonly type: "VECTOR"; - clone(): VectorNode; - vectorNetwork: VectorNetwork; - vectorPaths: VectorPaths; - handleMirroring: HandleMirroring | PluginAPI["mixed"]; - } - - const node = new VectorNode(); - // set read-only variables - Object.defineProperty(node, "width", { value: 20 }); - Object.defineProperty(node, "height", { value: 20 }); - - expect( - tailwindMain(convertIntoAltNodes([node], new AltFrameNode())) - ).toEqual( - `
` - ); - }); -}); diff --git a/__tests__/altNodes/convertGroupToFrame.test.ts b/__tests__/altNodes/convertGroupToFrame.test.ts deleted file mode 100644 index 6e706311..00000000 --- a/__tests__/altNodes/convertGroupToFrame.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { AltGroupNode, AltRectangleNode } from "../../src/altNodes/altMixins"; -import { convertGroupToFrame } from "../../src/altNodes/convertGroupToFrame"; -import { convertNodesOnRectangle } from "../../src/altNodes/convertNodesOnRectangle"; - -describe("Convert Group to Frame", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Simple conversion", () => { - const rectangle = new AltRectangleNode(); - rectangle.x = 20; - rectangle.y = 20; - rectangle.width = 20; - rectangle.height = 20; - - const group = new AltGroupNode(); - group.x = 20; - group.y = 20; - group.width = 20; - group.height = 20; - group.children = [rectangle]; - - const converted = convertGroupToFrame(group); - expect(tailwindMain([convertNodesOnRectangle(converted)])) - .toEqual(`
-
-
`); - }); - - it("Correctly position the children", () => { - const rect0 = new AltRectangleNode(); - rect0.x = 200; - rect0.y = 200; - rect0.width = 20; - rect0.height = 20; - - const rect1 = new AltRectangleNode(); - rect1.x = 220; - rect1.y = 220; - rect1.width = 20; - rect1.height = 20; - - const rect2 = new AltRectangleNode(); - rect2.x = 240; - rect2.y = 240; - rect2.width = 20; - rect2.height = 20; - - const group = new AltGroupNode(); - group.x = 200; - group.y = 200; - group.width = 260; - group.height = 260; - group.children = [rect0, rect1, rect2]; - - const newFrame = convertGroupToFrame(group); - - expect(newFrame.children[0].x).toEqual(0); - expect(newFrame.children[0].y).toEqual(0); - - expect(newFrame.children[1].x).toEqual(20); - expect(newFrame.children[1].x).toEqual(20); - - expect(newFrame.children[2].x).toEqual(40); - expect(newFrame.children[2].x).toEqual(40); - }); -}); diff --git a/__tests__/altNodes/convertNodesOnRectangle.test.ts b/__tests__/altNodes/convertNodesOnRectangle.test.ts deleted file mode 100644 index 02b6c24e..00000000 --- a/__tests__/altNodes/convertNodesOnRectangle.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { tailwindSize } from "../../src/tailwind/builderImpl/tailwindSize"; -import { - AltFrameNode, - AltGroupNode, - AltRectangleNode, - AltSceneNode, - AltTextNode, -} from "../../src/altNodes/altMixins"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { convertNodesOnRectangle } from "../../src/altNodes/convertNodesOnRectangle"; - -describe("convert node if child is big rect", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("frame with one child (no conversion)", () => { - const frame = new AltFrameNode(); - frame.width = 100; - frame.height = 100; - - const rectangle = new AltRectangleNode(); - rectangle.width = 100; - rectangle.height = 100; - rectangle.x = 0; - rectangle.y = 0; - rectangle.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - rectangle.parent = frame; - frame.children = [rectangle]; - - // it will only work with two or more items. - const converted = convertNodesOnRectangle(frame); - - expect(tailwindSize(converted)).toEqual("w-24 h-24 "); - - expect(tailwindMain([converted])).toEqual( - `
-
-
` - ); - }); - - it("child is invisible", () => { - const frame = new AltFrameNode(); - frame.width = 100; - frame.height = 100; - frame.id = "frame"; - - const rect1 = new AltRectangleNode(); - rect1.id = "rect 1"; - rect1.width = 100; - rect1.height = 100; - rect1.x = 0; - rect1.y = 0; - rect1.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - const rect2 = new AltRectangleNode(); - rect2.id = "rect 2"; - rect2.width = 50; - rect2.height = 50; - rect2.x = 0; - rect2.y = 0; - rect2.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - rect1.visible = false; - - rect2.parent = frame; - rect1.parent = frame; - - frame.children = [rect1, rect2]; - - const invisibleConverted = convertNodesOnRectangle(frame); - - expect(tailwindMain([invisibleConverted])).toEqual( - `
-
-
-
-
` - ); - }); - - it("frame with two children", () => { - const frame = new AltFrameNode(); - frame.id = "frame"; - frame.width = 20; - frame.height = 20; - - const rectangle = new AltRectangleNode(); - rectangle.id = "rectangle"; - rectangle.width = 20; - rectangle.height = 20; - rectangle.x = 0; - rectangle.y = 0; - rectangle.visible = true; - rectangle.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - const miniRect = new AltRectangleNode(); - miniRect.id = "miniRect"; - miniRect.width = 10; - miniRect.height = 10; - miniRect.x = 5; - miniRect.y = 5; - miniRect.visible = true; - miniRect.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - miniRect.parent = frame; - rectangle.parent = frame; - frame.children = [rectangle, miniRect]; - - // it will only work with two or more items. - // todo should the conversion happen also when a group has a single rect? - const converted = convertNodesOnRectangle(frame); - - expect(tailwindMain([converted])).toEqual( - `
-
-
-
-
` - ); - }); - - it("Fail", () => { - const rect1 = new AltRectangleNode(); - rect1.id = "rect 1"; - rect1.x = 0; - rect1.y = 0; - rect1.width = 100; - rect1.height = 100; - - const rect2 = new AltRectangleNode(); - rect2.id = "rect 2"; - rect2.x = 0; - rect2.y = 0; - rect2.width = 20; - rect2.height = 120; - - const group = new AltGroupNode(); - group.id = "group"; - group.x = 0; - group.y = 0; - group.width = 120; - group.height = 20; - group.children = [rect1, rect2]; - rect1.parent = group; - rect2.parent = group; - - expect(tailwindMain([convertNodesOnRectangle(group)])) - .toEqual(`
-
-
-
`); - }); - it("group with 2 children", () => { - const group = new AltGroupNode(); - group.id = "group"; - group.width = 20; - group.height = 20; - group.x = 0; - group.y = 0; - - const rectangle = new AltRectangleNode(); - rectangle.id = "rect 1"; - rectangle.width = 20; - rectangle.height = 20; - rectangle.x = 0; - rectangle.y = 0; - rectangle.visible = true; - rectangle.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - const miniRect = new AltRectangleNode(); - miniRect.id = "rect 2"; - miniRect.width = 8; - miniRect.height = 8; - miniRect.x = 0; - miniRect.y = 0; - miniRect.visible = true; - miniRect.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - miniRect.parent = group; - rectangle.parent = group; - group.children = [rectangle, miniRect]; - - const pre_conv = convertNodesOnRectangle(group); - - // force Group removal. This is done automatically in AltConversion when executed in Figma. - const conv = pre_conv.children[0]; - conv.parent = null; - - // counterAxisSizingMode is AUTO, therefore bg-black doesn't contain the size - - expect(tailwindMain([conv])).toEqual( - `
-
-
` - ); - }); - - it("simple example", () => { - const node = new AltFrameNode(); - node.id = "FRAME"; - node.width = 400; - node.height = 400; - - const child0 = new AltRectangleNode(); - child0.id = "MAIN"; - child0.width = 100; - child0.height = 100; - child0.x = 0; - child0.y = 0; - - const child1 = new AltRectangleNode(); - child1.id = "RECT 1"; - child1.width = 20; - child1.height = 20; - child1.x = 0; - child1.y = 0; - - const child2 = new AltRectangleNode(); - child2.id = "RECT 2"; - child2.width = 30; - child2.height = 30; - child2.x = 10; - child2.y = 10; - - const child3 = new AltRectangleNode(); - child3.id = "RECT 3"; - child3.width = 60; - child3.height = 60; - child3.x = 10; - child3.y = 10; - - // from most background to most foreground - node.children = [child0, child1, child2, child3]; - - const convert = convertNodesOnRectangle(node); - - expect(convert.children).toHaveLength(1); - }); - - it("multiple rectangles on top of each other", () => { - const node = new AltFrameNode(); - node.id = "FRAME"; - node.width = 400; - node.height = 400; - - const child0 = new AltRectangleNode(); - child0.id = "MAIN 1"; - child0.width = 30; - child0.height = 30; - child0.x = 0; - child0.y = 0; - - const child1 = new AltRectangleNode(); - child1.id = "RECT 1 M1"; - child1.width = 20; - child1.height = 20; - child1.x = 10; - child1.y = 10; - - const child2 = new AltRectangleNode(); - child2.id = "RECT 2 M1"; - child2.width = 10; - child2.height = 10; - child2.x = 20; - child2.y = 20; - - const child3 = new AltRectangleNode(); - child3.id = "MAIN 2"; - child3.width = 40; - child3.height = 40; - child3.x = 40; - child3.y = 40; - - const child4 = new AltRectangleNode(); - child4.id = "RECT 1 M2"; - child4.width = 10; - child4.height = 20; - child4.x = 50; - child4.y = 50; - - const child5 = new AltRectangleNode(); - child5.id = "RECT 2 M2"; - child5.width = 45; - child5.height = 20; - child5.x = 40; - child5.y = 30; - - const childIgnored = new AltTextNode(); - childIgnored.id = "RECT 2 M2"; - childIgnored.width = 100; - childIgnored.height = 100; - childIgnored.x = 0; - childIgnored.y = 0; - - // from most background to most foreground - node.children = [ - child0, - child3, - childIgnored, - child1, - child2, - child4, - child5, - ]; - - const convert = convertNodesOnRectangle(node); - - // 4, because it should include even those that are not converted. - expect(convert.children).toHaveLength(4); - }); - - it("invalid when testing without id", () => { - const node = new AltFrameNode(); - node.width = 400; - node.height = 400; - - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(() => convertNodesOnRectangle(node)).toThrow(); - }); -}); diff --git a/__tests__/altNodes/convertToAutoLayout.test.ts b/__tests__/altNodes/convertToAutoLayout.test.ts deleted file mode 100644 index 3c65137c..00000000 --- a/__tests__/altNodes/convertToAutoLayout.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { AltFrameNode, AltRectangleNode } from "../../src/altNodes/altMixins"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { convertToAutoLayout } from "../../src/altNodes/convertToAutoLayout"; - -describe("Convert to AutoLayout", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Simple conversion", () => { - const frame = new AltFrameNode(); - frame.x = 0; - frame.y = 0; - frame.width = 50; - frame.height = 50; - frame.layoutMode = "NONE"; - - const node1 = new AltRectangleNode(); - node1.x = 0; - node1.y = 0; - node1.width = 20; - node1.height = 20; - node1.parent = frame; - node1.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 1.0, - b: 1.0, - }, - }, - ]; - - const node2 = new AltRectangleNode(); - node2.x = 20; - node2.y = 0; - node2.width = 20; - node2.height = 20; - node2.parent = frame; - node2.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - }, - ]; - - // initially they are not ordered. ConvertToAutoLayout will also order them. - frame.children = [node2, node1]; - - // convertToAutoLayout is going to add padding to the parent, which justifies the h-full. - - // output should be HORIZONTAL - expect(tailwindMain([convertToAutoLayout(frame)])).toEqual( - `
-
-
-
` - ); - - // output should be VERTICAL - node2.x = 0; - node2.y = 20; - frame.layoutMode = "NONE"; - frame.children = [node2, node1]; - - expect(tailwindMain([convertToAutoLayout(frame)])).toEqual( - `
-
-
-
` - ); - - // horizontally align while vertical - node1.width = 50; - - node2.x = 25; - node2.y = 25; - frame.layoutMode = "NONE"; - frame.children = [node2, node1]; - - expect(tailwindMain([convertToAutoLayout(frame)])).toEqual( - `
-
-
-
` - ); - - // vertically align while horizontal - node1.width = 20; - node1.height = 50; - - node2.x = 20; - node2.y = 20; - frame.layoutMode = "NONE"; - frame.children = [node2, node1]; - - expect(tailwindMain([convertToAutoLayout(frame)])).toEqual( - `
-
-
-
` - ); - - node1.height = 20; - - // output should be NOTHING - node2.x = 10; - node2.y = 10; - frame.layoutMode = "NONE"; - frame.children = [node2, node1]; - - expect(tailwindMain([convertToAutoLayout(frame)])).toEqual( - `
-
-
-
` - ); - }); - - it("Trigger avgX", () => { - const frame = new AltFrameNode(); - frame.x = 0; - frame.y = 0; - frame.width = 50; - frame.height = 50; - frame.layoutMode = "NONE"; - - const node1 = new AltRectangleNode(); - node1.x = 0; - node1.y = 0; - node1.width = 20; - node1.height = 20; - node1.parent = frame; - - const node2 = new AltRectangleNode(); - node2.x = 16; - node2.y = 0; - node2.width = 20; - node2.height = 20; - node2.parent = frame; - - const node3 = new AltRectangleNode(); - node3.x = 40; - node3.y = 0; - node3.width = 20; - node3.height = 20; - node3.parent = frame; - - // initially they are not ordered. ConvertToAutoLayout will also order them. - frame.children = [node3, node2, node1]; - - // output should be HORIZONTAL - expect(tailwindMain([convertToAutoLayout(frame)])) - .toEqual(`
-
-
-
-
`); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterBlend.test.ts b/__tests__/flutter/builderImpl/flutterBlend.test.ts deleted file mode 100644 index d384ad6f..00000000 --- a/__tests__/flutter/builderImpl/flutterBlend.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - flutterOpacity, - flutterRotation, - flutterVisibility, -} from "../../../src/flutter/builderImpl/flutterBlend"; -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; - -describe("Flutter Blend", () => { - const node = new AltRectangleNode(); - - it("opacity", () => { - // undefined (unitialized, only happen on tests) - expect(flutterOpacity(node, "")).toEqual(""); - - node.opacity = 0.5; - expect(flutterOpacity(node, "test")).toEqual( - `Opacity( - opacity: 0.50, - child: test -),` - ); - - node.opacity = 1.0; - expect(flutterOpacity(node, "")).toEqual(""); - }); - - it("visibility", () => { - // undefined (unitialized, only happen on tests) - expect(flutterVisibility(node, "")).toEqual(""); - - node.visible = false; - expect(flutterVisibility(node, "test")).toEqual( - `Visibility( - visible: false, - child: test -),` - ); - - node.visible = true; - expect(flutterVisibility(node, "test")).toEqual("test"); - }); - - it("rotation", () => { - // undefined (unitialized, only happen on tests) - expect(flutterRotation(node, "")).toEqual(""); - - // test small negative value to check if output will be nothing - node.rotation = -7.0167096047110005e-15; - expect(flutterRotation(node, "")).toEqual(""); - - node.rotation = 45; - expect(flutterRotation(node, "test")).toEqual( - `Transform.rotate( - angle: -0.79, - child: test -),` - ); - - node.rotation = -45; - expect(flutterRotation(node, "test")).toEqual( - `Transform.rotate( - angle: 0.79, - child: test -),` - ); - - node.rotation = 90; - expect(flutterRotation(node, "test")).toEqual( - `Transform.rotate( - angle: -1.57, - child: test -),` - ); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterBorder.test.ts b/__tests__/flutter/builderImpl/flutterBorder.test.ts deleted file mode 100644 index 898ecaa3..00000000 --- a/__tests__/flutter/builderImpl/flutterBorder.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - AltRectangleNode, - AltEllipseNode, - AltGroupNode, -} from "../../../src/altNodes/altMixins"; -import { - flutterBorderRadius, - flutterBorder, - flutterShape, -} from "../../../src/flutter/builderImpl/flutterBorder"; - -describe("Flutter Border", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("flutterBorderRadius", () => { - const node = new AltRectangleNode(); - expect(flutterBorderRadius(node)).toEqual(""); - - node.cornerRadius = 2; - expect(flutterBorderRadius(node)).toEqual( - "\nborderRadius: BorderRadius.circular(2)," - ); - - node.cornerRadius = figma.mixed; - node.topLeftRadius = 2; - node.topRightRadius = 0; - node.bottomLeftRadius = 0; - node.bottomRightRadius = 0; - expect(flutterBorderRadius(node)).toEqual( - "\nborderRadius: BorderRadius.only(topLeft: Radius.circular(2), topRight: Radius.circular(0), bottomLeft: Radius.circular(0), bottomRight: Radius.circular(0), )," - ); - - const ellipseNode = new AltEllipseNode(); - expect(flutterBorderRadius(ellipseNode)).toEqual(""); - }); - - it("flutterBorder", () => { - const node = new AltRectangleNode(); - node.strokeWeight = 2; - node.strokes = [ - { - type: "SOLID", - color: { r: 0, g: 0, b: 0 }, - }, - ]; - expect(flutterBorder(node)).toEqual( - "\nborder: Border.all(color: Colors.black, width: 2, )," - ); - - node.strokeWeight = 0; - expect(flutterBorder(node)).toEqual(""); - - expect(flutterBorder(new AltGroupNode())).toEqual(""); - }); - - it("flutterShape", () => { - const node = new AltRectangleNode(); - - node.cornerRadius = figma.mixed; - node.topLeftRadius = 4; - node.topRightRadius = 0; - node.bottomLeftRadius = 0; - node.bottomRightRadius = 0; - expect(flutterShape(node)).toEqual( - `\nshape: RoundedRectangleBorder( - borderRadius: BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(0), bottomLeft: Radius.circular(0), bottomRight: Radius.circular(0), ), -),` - ); - - const ellipseNode = new AltEllipseNode(); - ellipseNode.strokeWeight = 4; - ellipseNode.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - expect(flutterShape(ellipseNode)).toEqual( - `\nshape: CircleBorder( - side: BorderSide(width: 4, color: Color(0xff3f3f3f), ), -),` - ); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterColor.test.ts b/__tests__/flutter/builderImpl/flutterColor.test.ts deleted file mode 100644 index 6a0f04fb..00000000 --- a/__tests__/flutter/builderImpl/flutterColor.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { - flutterColorFromFills, - flutterBoxDecorationColor, -} from "../../../src/flutter/builderImpl/flutterColor"; -import { AltRectangleNode, AltTextNode } from "../../../src/altNodes/altMixins"; -import { flutterMain } from "./../../../src/flutter/flutterMain"; -describe("Flutter Color", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("standard color", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 0.941, - g: 0.318, - b: 0.22, - }, - opacity: 1.0, - }, - ]; - - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ncolor: Color(0xffef5138)," - ); - }); - - it("check for black and white on Text", () => { - const node = new AltTextNode(); - node.characters = ""; - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - }, - ]; - - expect(flutterColorFromFills(node.fills)).toEqual("color: Colors.black,"); - - node.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 1.0, - b: 1.0, - }, - opacity: 1.0, - }, - ]; - - expect(flutterColorFromFills(node.fills)).toEqual("color: Colors.white,"); - }); - - it("opacity and visibility changes", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - visible: false, - }, - ]; - - expect(flutterColorFromFills(node.fills)).toEqual(""); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: undefined, - visible: true, - }, - ]; - - // this scenario should never happen in real life; figma allows undefined to be set, but not to be get. - expect(flutterColorFromFills(node.fills)).toEqual("color: Colors.black,"); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 0.0, - visible: true, - }, - ]; - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ncolor: Color(0x00000000)," - ); - }); - - it("Gradient Linear", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFill]; - - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Colors.black], )," - ); - - // topLeft to bottomRight (135) - Object.assign(gradientFill.gradientTransform, [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.black], )," - ); - - // bottom to top (-90) - Object.assign(gradientFill.gradientTransform, [ - [7.734507789791678e-8, -1.2339448928833008, 1.1376146078109741], - [-2.3507132530212402, -1.0997783306265774e-7, 1.6796307563781738], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black], )," - ); - - // top to bottom (90) - Object.assign(gradientFill.gradientTransform, [ - [6.851496436866e-8, 2.085271120071411, -0.6976743936538696], - [3.9725232124328613, -1.4210854715202004e-14, -0.8289895057678223], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black], )," - ); - - // left to right (0) - Object.assign(gradientFill.gradientTransform, [ - [1.845637559890747, 1.9779233184635814e-7, -0.45637592673301697], - [6.030897026221282e-8, -3.364259719848633, 2.188383102416992], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Colors.black], )," - ); - - // right to left (180) - Object.assign(gradientFill.gradientTransform, [ - [-2.3905811309814453, 0.04066795855760574, 1.707460880279541], - [0.07747448235750198, 4.357592582702637, -1.0299113988876343], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.centerRight, end: Alignment.centerLeft, colors: [Colors.black], )," - ); - - // bottom left to top right (-135) - Object.assign(gradientFill.gradientTransform, [ - [-1.2678464651107788, -1.9602917432785034, 1.6415824890136719], - [-3.7344324588775635, 2.3110527992248535, 0.4661891460418701], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.bottomRight, end: Alignment.topLeft, colors: [Colors.black], )," - ); - - // bottom left to top right (-45) - Object.assign(gradientFill.gradientTransform, [ - [0.7420053482055664, -0.6850813031196594, 0.4412658214569092], - [-1.3051068782806396, -1.3525396585464478, 1.8345310688018799], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.bottomLeft, end: Alignment.topRight, colors: [Colors.black], )," - ); - - // top right to bottom left (-45) - Object.assign(gradientFill.gradientTransform, [ - [-0.7061997652053833, 0.7888921499252319, 0.5180976986885071], - [1.5028705596923828, 1.2872726917266846, -1.0877336263656616], - ]); - expect(flutterBoxDecorationColor(node.fills)).toEqual( - "\ngradient: LinearGradient(begin: Alignment.topRight, end: Alignment.bottomLeft, colors: [Colors.black], )," - ); - }); - - it("Execute Main with Linear Gradient, corners and stroke", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - // width is going be 18 because 10 + 4 + 4 of stroke. - node.height = 10; - node.width = 10; - node.fills = [gradientFill]; - node.strokeWeight = 4; - node.strokeAlign = "OUTSIDE"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - node.cornerRadius = 16; - - expect(flutterMain([node])).toEqual( - `Container( - width: 18, - height: 18, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Color(0xff3f3f3f), width: 4, ), - gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.black, Color(0xffff0000)], ), - ), -)` - ); - }); - - it("fail with other types", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "IMAGE", - scaleMode: "FILL", - imageHash: null, - }, - ]; - - expect(flutterColorFromFills(node.fills)).toEqual(""); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterPadding.test.ts b/__tests__/flutter/builderImpl/flutterPadding.test.ts deleted file mode 100644 index e4f62cd0..00000000 --- a/__tests__/flutter/builderImpl/flutterPadding.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; -import { flutterPadding } from "../../../src/flutter/builderImpl/flutterPadding"; - -describe("Flutter Padding", () => { - it("test padding", () => { - const frameNode = new AltFrameNode(); - expect(flutterPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "NONE"; - expect(flutterPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "VERTICAL"; - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 2; - frameNode.paddingBottom = 2; - expect(flutterPadding(frameNode)).toEqual( - "\npadding: const EdgeInsets.all(2)," - ); - - frameNode.paddingLeft = 1; - frameNode.paddingRight = 2; - frameNode.paddingTop = 3; - frameNode.paddingBottom = 4; - expect(flutterPadding(frameNode)).toEqual( - "\npadding: const EdgeInsets.only(left: 1, right: 2, top: 3, bottom: 4, )," - ); - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 4; - expect(flutterPadding(frameNode)).toEqual( - "\npadding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4, )," - ); - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(flutterPadding(frameNode)).toEqual( - "\npadding: const EdgeInsets.symmetric(horizontal: 2, )," - ); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 2; - frameNode.paddingBottom = 2; - expect(flutterPadding(frameNode)).toEqual( - "\npadding: const EdgeInsets.symmetric(vertical: 2, )," - ); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(flutterPadding(frameNode)).toEqual(""); - - const notFrame = new AltRectangleNode(); - expect(flutterPadding(notFrame)).toEqual(""); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterPosition.test.ts b/__tests__/flutter/builderImpl/flutterPosition.test.ts deleted file mode 100644 index fc5f7723..00000000 --- a/__tests__/flutter/builderImpl/flutterPosition.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { AltFrameNode } from "../../../src/altNodes/altMixins"; -import { flutterPosition } from "../../../src/flutter/builderImpl/flutterPosition"; - -describe("Flutter Position", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Frame AutoLayout Position", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.x = 0; - parent.y = 0; - parent.layoutMode = "NONE"; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.parent = parent; - - parent.children = [node]; - - // node.parent.id === parent.id, so return "" - expect(flutterPosition(node, "", parent.id)).toEqual(""); - - // todo improve this? - - node.layoutAlign = "MIN"; - expect(flutterPosition(node, "")).toEqual(""); - - node.layoutAlign = "MAX"; - expect(flutterPosition(node, "")).toEqual(""); - - node.layoutAlign = "CENTER"; - expect(flutterPosition(node, "")).toEqual(""); - }); - - it("Frame Absolute Position", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.x = 0; - parent.y = 0; - parent.id = "root"; - parent.layoutMode = "NONE"; - parent.isRelative = true; - - const node = new AltFrameNode(); - parent.id = "node"; - node.parent = parent; - - // child equals parent - node.width = 100; - node.height = 100; - expect(flutterPosition(node, "child")).toEqual("child"); - - node.width = 25; - node.height = 25; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 25; - nodeF2.height = 25; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - // center - node.x = 37; - node.y = 37; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.center, - child: child - ), -),` - ); - - // top-left - node.x = 0; - node.y = 0; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.topLeft, - child: child - ), -),` - ); - - // top-right - node.x = 75; - node.y = 0; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.topRight, - child: child - ), -),` - ); - - // bottom-left - node.x = 0; - node.y = 75; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.bottomLeft, - child: child - ), -),` - ); - - // bottom-right - node.x = 75; - node.y = 75; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.bottomRight, - child: child - ), -),` - ); - - // top-center - node.x = 37; - node.y = 0; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.topCenter, - child: child - ), -),` - ); - - // left-center - node.x = 0; - node.y = 37; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.centerLeft, - child: child - ), -),` - ); - - // bottom-center - node.x = 37; - node.y = 75; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.bottomCenter, - child: child - ), -),` - ); - - // right-center - node.x = 75; - node.y = 37; - expect(flutterPosition(node, "child")).toEqual( - `Positioned.fill( - child: Align( - alignment: Alignment.centerRight, - child: child - ), -),` - ); - - // center Y, random X - node.x = 22; - node.y = 37; - expect(flutterPosition(node, "child")).toEqual( - `Positioned( - left: 22, - top: 37, - child: child -),` - ); - - // center X, random Y - node.x = 37; - node.y = 22; - expect(flutterPosition(node, "child")).toEqual( - `Positioned( - left: 37, - top: 22, - child: child -),` - ); - - // without position - node.x = 45; - node.y = 88; - expect(flutterPosition(node, "child")).toEqual( - `Positioned( - left: 45, - top: 88, - child: child -),` - ); - }); - - it("Position: node has same size as parent", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.layoutMode = "NONE"; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.parent = parent; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 100; - nodeF2.height = 100; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - expect(flutterPosition(node, "")).toEqual(""); - }); - - it("No position when parent is root", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - - const parent = new AltFrameNode(); - parent.id = "root"; - parent.layoutMode = "NONE"; - - node.parent = parent; - - expect(flutterPosition(node, "", parent.id)).toEqual(""); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterShadow.test.ts b/__tests__/flutter/builderImpl/flutterShadow.test.ts deleted file mode 100644 index 64ae5602..00000000 --- a/__tests__/flutter/builderImpl/flutterShadow.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - flutterBoxShadow, - flutterElevationAndShadowColor, -} from "../../../src/flutter/builderImpl/flutterShadow"; -import { AltFrameNode } from "../../../src/altNodes/altMixins"; - -describe("Flutter Shadow", () => { - it("drop shadow", () => { - const node = new AltFrameNode(); - - node.effects = []; - expect(flutterBoxShadow(node)).toEqual(""); - - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - visible: true, - }, - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 1, g: 1, b: 0, a: 0.25 }, - offset: { x: 4, y: 4 }, - radius: 8, - visible: true, - }, - ]; - - expect(flutterBoxShadow(node)).toEqual( - `\nboxShadow: [ - BoxShadow( - color: Color(0x3f000000), - blurRadius: 4, - offset: Offset(0, 4), - ), - BoxShadow( - color: Color(0x3fffff00), - blurRadius: 8, - offset: Offset(4, 4), - ), -],` - ); - - const [elev, color] = flutterElevationAndShadowColor(node); - expect(elev).toEqual("\nelevation: 4, "); - expect(color).toEqual("\ncolor: Color(0x3f000000), "); - }); - - it("inner shadow", () => { - const node = new AltFrameNode(); - - node.effects = []; - const [elev1, color1] = flutterElevationAndShadowColor(node); - expect(elev1).toEqual(""); - expect(color1).toEqual(""); - - node.effects = [ - { - type: "INNER_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(flutterBoxShadow(node)).toEqual(""); - - const [elev2, color2] = flutterElevationAndShadowColor(node); - expect(elev2).toEqual(""); - expect(color2).toEqual(""); - }); -}); diff --git a/__tests__/flutter/builderImpl/flutterSize.test.ts b/__tests__/flutter/builderImpl/flutterSize.test.ts deleted file mode 100644 index b13379d7..00000000 --- a/__tests__/flutter/builderImpl/flutterSize.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - flutterSize, - flutterSizeWH, -} from "../../../src/flutter/builderImpl/flutterSize"; -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; - -describe("Flutter Size", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("size for a rectangle", () => { - const node = new AltRectangleNode(); - - node.width = 16; - node.height = 16; - expect(flutterSizeWH(node)).toEqual("\nwidth: 16,\nheight: 16,"); - - node.width = 100; - node.height = 200; - expect(flutterSizeWH(node)).toEqual("\nwidth: 100,\nheight: 200,"); - - node.width = 300; - node.height = 300; - expect(flutterSizeWH(node)).toEqual("\nwidth: 300,\nheight: 300,"); - }); - - it("STRETCH inside AutoLayout", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.layoutAlign = "INHERIT"; - node.primaryAxisSizingMode = "FIXED"; - node.counterAxisSizingMode = "FIXED"; - node.width = 10; - node.height = 10; - - const child = new AltRectangleNode(); - child.layoutAlign = "STRETCH"; - child.layoutGrow = 1; - child.width = 10; - child.height = 10; - - child.parent = node; - node.children = [child]; - - const fSize1 = flutterSize(child); - expect(fSize1.width).toEqual(""); - expect(fSize1.height).toEqual("\nheight: double.infinity,"); - expect(fSize1.isExpanded).toEqual(true); - - node.layoutMode = "VERTICAL"; - - const fSize2 = flutterSize(child); - expect(fSize2.width).toEqual("\nwidth: double.infinity,"); - expect(fSize2.height).toEqual(""); - expect(fSize2.isExpanded).toEqual(true); - }); - - it("Fixed size when children are absolute", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(flutterSizeWH(node)).toEqual("\nwidth: 48,\nheight: 48,"); - }); - - it("counterAxisSizingMode is FIXED", () => { - const node = new AltFrameNode(); - node.counterAxisSizingMode = "FIXED"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - node.layoutMode = "HORIZONTAL"; - expect(flutterSizeWH(node)).toEqual("\nheight: 48,"); - - node.layoutMode = "VERTICAL"; - expect(flutterSizeWH(node)).toEqual("\nwidth: 48,"); - - node.layoutMode = "NONE"; - expect(flutterSizeWH(node)).toEqual("\nwidth: 48,\nheight: 48,"); - }); - - it("counterAxisSizingMode is AUTO", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "AUTO"; - node.primaryAxisSizingMode = "AUTO"; - node.x = 0; - node.y = 0; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(flutterSizeWH(node)).toEqual(""); - - // responsive - const parentNode = new AltFrameNode(); - parentNode.counterAxisSizingMode = "FIXED"; - parentNode.primaryAxisSizingMode = "FIXED"; - parentNode.x = 0; - parentNode.y = 0; - parentNode.width = 48; - parentNode.height = 48; - parentNode.children = [node]; - node.parent = parentNode; - expect(flutterSizeWH(node)).toEqual(""); - expect(flutterSizeWH(parentNode)).toEqual("\nwidth: 48,\nheight: 48,"); - }); - - it("width changes when there are strokes", () => { - const node = new AltRectangleNode(); - node.x = 0; - node.y = 0; - node.width = 8; - node.height = 8; - - expect(flutterSizeWH(node)).toEqual("\nwidth: 8,\nheight: 8,"); - - node.strokeWeight = 4; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - node.strokeAlign = "CENTER"; - expect(flutterSizeWH(node)).toEqual("\nwidth: 12,\nheight: 12,"); - - node.strokeAlign = "OUTSIDE"; - expect(flutterSizeWH(node)).toEqual("\nwidth: 16,\nheight: 16,"); - }); - - it("adjust parent if children's size + stroke > parent size", () => { - const node = new AltRectangleNode(); - node.width = 8; - node.height = 8; - - node.strokeWeight = 4; - node.strokeAlign = "OUTSIDE"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - parentNode.children = [node]; - node.parent = parentNode; - - const fSize1 = flutterSize(parentNode); - - expect(fSize1.width).toEqual("\nwidth: 16,"); - expect(fSize1.height).toEqual("\nheight: 16,"); - expect(fSize1.isExpanded).toEqual(false); - - node.strokeAlign = "CENTER"; - const fSize2 = flutterSize(parentNode); - expect(fSize2.width).toEqual("\nwidth: 12,"); - expect(fSize2.height).toEqual("\nheight: 12,"); - expect(fSize2.isExpanded).toEqual(false); - }); - - it("full width when width is same to the parent", () => { - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.width = 12; - parentNode.height = 12; - parentNode.counterAxisSizingMode = "AUTO"; - parentNode.primaryAxisSizingMode = "AUTO"; - - const node = new AltFrameNode(); - node.width = 12; - node.height = 12; - node.parent = parentNode; - - parentNode.children = [node]; - - expect(flutterSizeWH(parentNode)).toEqual("\nwidth: 12,\nheight: 12,"); - expect(flutterSizeWH(node)).toEqual("\nwidth: 12,\nheight: 12,"); - }); -}); diff --git a/__tests__/flutter/flutterContainer.test.ts b/__tests__/flutter/flutterContainer.test.ts deleted file mode 100644 index 3be16552..00000000 --- a/__tests__/flutter/flutterContainer.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, - AltEllipseNode, -} from "../../src/altNodes/altMixins"; -import { flutterContainer } from "../../src/flutter/flutterContainer"; - -describe("Flutter Container", () => { - it("no size", () => { - const node = new AltRectangleNode(); - - // undefined (unitialized, only happen on tests) - expect(flutterContainer(node, "")).toEqual(""); - - node.width = 0; - node.height = 10; - expect(flutterContainer(node, "")).toEqual(""); - - node.width = 10; - node.height = 0; - expect(flutterContainer(node, "")).toEqual(""); - }); - - it("padding only", () => { - const node = new AltRectangleNode(); - node.width = 10; - node.height = 10; - - const parent = new AltFrameNode(); - parent.layoutMode = "HORIZONTAL"; - parent.width = 30; - parent.height = 30; - parent.x = 0; - parent.y = 0; - parent.paddingLeft = 10; - parent.paddingRight = 10; - parent.paddingTop = 10; - parent.paddingBottom = 10; - - parent.children = [node]; - node.parent = parent; - - expect(flutterContainer(parent, "")).toEqual(`Padding( - padding: const EdgeInsets.all(10), -),`); - - node.layoutGrow = 1; - node.layoutAlign = "STRETCH"; - - parent.primaryAxisSizingMode = "FIXED"; - parent.counterAxisSizingMode = "FIXED"; - - expect(flutterContainer(node, "")).toEqual(`Expanded( - child: Container( - height: double.infinity, - ), -),`); - }); - - it("standard scenario", () => { - const node = new AltRectangleNode(); - node.width = 10; - node.height = 10; - - expect(flutterContainer(node, "")).toEqual(`Container( - width: 10, - height: 10, -),`); - - expect(flutterContainer(node, "child")).toEqual(`Container( - width: 10, - height: 10, - child: child -),`); - }); - - it("ellipse", () => { - const node = new AltEllipseNode(); - node.width = 10; - node.height = 10; - - expect(flutterContainer(node, "")).toEqual(`Container( - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - ), -),`); - }); -}); diff --git a/__tests__/flutter/flutterMain.test.ts b/__tests__/flutter/flutterMain.test.ts deleted file mode 100644 index af72d6e0..00000000 --- a/__tests__/flutter/flutterMain.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { flutterMain } from "../../src/flutter/flutterMain"; -import { convertToAutoLayout } from "../../src/altNodes/convertToAutoLayout"; -import { - AltRectangleNode, - AltFrameNode, - AltGroupNode, -} from "../../src/altNodes/altMixins"; - -describe("Flutter Main", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - it("Standard flow", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 4; - child1.height = 4; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 4; - child2.height = 4; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(flutterMain([convertToAutoLayout(node)], "", false)) - .toEqual(`Container( - width: 32, - height: 32, - child: Stack( - children:[ - Positioned( - left: 9, - top: 9, - child: Container( - width: 4, - height: 4, - color: Colors.white, - ), - ), - Positioned( - left: 9, - top: 9, - child: Container( - width: 4, - height: 4, - ), - ), - ], - ), -)`); - }); - - it("children is larger than 384", () => { - const node = new AltFrameNode(); - node.width = 420; - node.height = 420; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 385; - child1.height = 8; - child1.x = 9; - child1.y = 9; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 385; - child2.x = 9; - child2.y = 9; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(flutterMain([convertToAutoLayout(node)])).toEqual(`Container( - width: 420, - height: 420, - child: Stack( - children:[ - Positioned( - left: 9, - top: 9, - child: Container( - width: 385, - height: 8, - color: Colors.white, - ), - ), - Positioned( - left: 9, - top: 9, - child: Container( - width: 8, - height: 385, - ), - ), - ], - ), -)`); - }); - - it("Group with relative position", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltGroupNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "GROUP"; - node.isRelative = true; - - const child = new AltRectangleNode(); - child.width = 4; - child.height = 4; - child.x = 9; - child.y = 9; - child.name = "RECT"; - child.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - node.children = [child]; - child.parent = node; - expect(flutterMain([node])).toEqual(`Container( - width: 32, - height: 32, - child: Stack( - children:[Positioned( - left: 9, - top: 9, - child: Container( - width: 4, - height: 4, - color: Colors.white, - ), - ),], - ), -)`); - }); - - it("Row and Column with 2 children", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltFrameNode(); - node.width = 32; - node.height = 8; - node.x = 0; - node.y = 0; - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "AUTO"; - node.primaryAxisSizingMode = "AUTO"; - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - node.itemSpacing = 8; - - const child1 = new AltRectangleNode(); - child1.width = 8; - child1.height = 8; - child1.x = 0; - child1.y = 0; - child1.layoutAlign = "INHERIT"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 8; - child2.x = 16; - child2.y = 0; - child2.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(flutterMain([node])).toEqual(`Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children:[ - Container( - width: 8, - height: 8, - color: Colors.white, - ), - SizedBox(width: 8), - Container( - width: 8, - height: 8, - color: Colors.black, - ), - ], -)`); - - // variations for test coverage - node.layoutMode = "VERTICAL"; - node.layoutGrow = 1; - node.primaryAxisAlignItems = "CENTER"; - node.counterAxisAlignItems = "CENTER"; - - expect(flutterMain([node])).toEqual(`Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children:[ - Container( - width: 8, - height: 8, - color: Colors.white, - ), - SizedBox(height: 8), - Container( - width: 8, - height: 8, - color: Colors.black, - ), - ], -)`); - - node.primaryAxisAlignItems = "MAX"; - node.counterAxisAlignItems = "MAX"; - - expect(flutterMain([node])).toEqual(`Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children:[ - Container( - width: 8, - height: 8, - color: Colors.white, - ), - SizedBox(height: 8), - Container( - width: 8, - height: 8, - color: Colors.black, - ), - ], -)`); - - node.primaryAxisAlignItems = "SPACE_BETWEEN"; - node.counterAxisAlignItems = "CENTER"; - - expect(flutterMain([node])).toEqual(`Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children:[ - Container( - width: 8, - height: 8, - color: Colors.white, - ), - SizedBox(height: 8), - Container( - width: 8, - height: 8, - color: Colors.black, - ), - ], -)`); - }); - - it("Row with 1 children", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltFrameNode(); - node.width = 32; - node.height = 8; - node.x = 0; - node.y = 0; - node.layoutMode = "HORIZONTAL"; - node.primaryAxisSizingMode = "FIXED"; - node.primaryAxisAlignItems = "CENTER"; - node.counterAxisSizingMode = "AUTO"; - node.counterAxisAlignItems = "CENTER"; - node.itemSpacing = 8; - node.paddingBottom = 0; - node.paddingTop = 0; - node.paddingLeft = 0; - node.paddingRight = 0; - node.visible = true; - node.layoutAlign = "INHERIT"; - node.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child1 = new AltRectangleNode(); - child1.width = 8; - child1.height = 8; - child1.x = 0; - child1.y = 0; - child1.layoutGrow = 1; - child1.visible = true; - child1.layoutAlign = "STRETCH"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - child1.parent = node; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 8; - child2.x = 12; - child2.y = 12; - child2.layoutGrow = 0; - child2.visible = true; - child2.layoutAlign = "INHERIT"; - child2.fills = []; - child2.parent = node; - - node.children = [child1, child2]; - - expect(flutterMain([node], "", true)).toEqual( - `SizedBox( - width: 32, - child: Material( - color: Colors.white, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children:[ - Expanded( - child: SizedBox( - height: double.infinity, - child: Material( - color: Colors.white, - ), - ), - ), - SizedBox(width: 8), - Container( - width: 8, - height: 8, - ), - ], - ), - ), -)` - ); - }); -}); diff --git a/__tests__/flutter/flutterText.test.ts b/__tests__/flutter/flutterText.test.ts deleted file mode 100644 index f7fd5191..00000000 --- a/__tests__/flutter/flutterText.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { AltFrameNode, AltTextNode } from "../../src/altNodes/altMixins"; -import { FlutterTextBuilder } from "./../../src/flutter/flutterTextBuilder"; -import { flutterMain } from "./../../src/flutter/flutterMain"; - -describe("Flutter Text", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("inside AutoLayout", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 8; - node.x = 0; - node.y = 0; - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - node.itemSpacing = 8; - - const textNode = new AltTextNode(); - textNode.characters = ""; - textNode.width = 16; - textNode.height = 16; - textNode.layoutAlign = "STRETCH"; - textNode.layoutGrow = 1; - - node.children = [textNode]; - textNode.parent = node; - - textNode.textAutoResize = "NONE"; - expect(flutterMain([node])).toEqual( - `Container( - width: 32, - height: 8, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children:[ - Expanded( - child: SizedBox( - height: double.infinity, - child: Text( - "", - ), - ), - ), - ], - ), -)` - ); - }); - it("textAutoResize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "NONE"; - expect(flutterMain([node])).toEqual( - `SizedBox( - width: 16, - height: 16, - child: Text( - "", - ), -)` - ); - - node.textAutoResize = "HEIGHT"; - expect(flutterMain([node])).toEqual( - `SizedBox( - width: 16, - child: Text( - "", - ), -)` - ); - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - }); - - it("textAlignHorizontal", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.textAlignHorizontal = "LEFT"; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - - node.textAutoResize = "NONE"; - node.textAlignHorizontal = "CENTER"; - expect(flutterMain([node])).toEqual( - `SizedBox( - width: 16, - height: 16, - child: Text( - "", - textAlign: TextAlign.center, - ), -)` - ); - - node.textAlignHorizontal = "JUSTIFIED"; - expect(flutterMain([node])).toEqual( - `SizedBox( - width: 16, - height: 16, - child: Text( - "", - textAlign: TextAlign.justify, - ), -)` - ); - }); - it("fontSize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.fontSize = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - expect(flutterMain([node])).toEqual( - `Text( - "", - style: TextStyle( - fontSize: 16, - ), -)` - ); - }); - - it("fontName", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.fontName = { - family: "inter", - style: "bold", - }; - expect(flutterMain([node])).toEqual(`Text( - "", - style: TextStyle( - fontFamily: "inter", - fontWeight: FontWeight.w700, - ), -)`); - - node.fontName = { - family: "inter", - style: "medium italic", - }; - expect(flutterMain([node])).toEqual(`Text( - "", - style: TextStyle( - fontStyle: FontStyle.italic, - fontFamily: "inter", - fontWeight: FontWeight.w500, - ), -)`); - - node.fontName = { - family: "inter", - style: "regular", - }; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - - node.fontName = { - family: "inter", - style: "doesn't exist", - }; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - }); - - it("letterSpacing", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.fontSize = 24; - - node.letterSpacing = { - value: 110, - unit: "PERCENT", - }; - expect(flutterMain([node])).toEqual( - `Text( - "", - style: TextStyle( - fontSize: 24, - letterSpacing: 26.40, - ), -)` - ); - - node.letterSpacing = { - value: 10, - unit: "PIXELS", - }; - expect(flutterMain([node])).toEqual( - `Text( - "", - style: TextStyle( - fontSize: 24, - letterSpacing: 10, - ), -)` - ); - }); - - it("lineHeight", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.lineHeight = { - value: 110, - unit: "PERCENT", - }; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - - node.lineHeight = { - value: 10, - unit: "PIXELS", - }; - - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - }); - - it("textCase", () => { - const node = new AltTextNode(); - node.characters = "aA"; - - node.textCase = "LOWER"; - expect(flutterMain([node])).toEqual(`Text( - "aa", -)`); - - // todo implement it - // node.textCase = "TITLE"; - // expect(flutterMain([node])).toEqual('Text("Aa", ),'); - - node.textCase = "UPPER"; - expect(flutterMain([node])).toEqual(`Text( - "AA", -)`); - - node.textCase = "ORIGINAL"; - expect(flutterMain([node])).toEqual(`Text( - "aA", -)`); - - node.textAlignHorizontal = "CENTER"; - node.layoutAlign = "INHERIT"; - expect(flutterMain([node])).toEqual(`Text( - "aA", - textAlign: TextAlign.center, -)`); - - node.textAlignHorizontal = "JUSTIFIED"; - node.layoutAlign = "INHERIT"; - expect(flutterMain([node])).toEqual(`Text( - "aA", - textAlign: TextAlign.justify, -)`); - }); - - it("textDecoration", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textDecoration = "NONE"; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - - node.textDecoration = "STRIKETHROUGH"; - expect(flutterMain([node])).toEqual(`Text( - "", -)`); - - node.textDecoration = "UNDERLINE"; - expect(flutterMain([node])).toEqual( - `Text( - "", - style: TextStyle( - decoration: TextDecoration.underline, - ), -)` - ); - }); - - it("reset", () => { - const node = new AltTextNode(); - node.characters = ""; - - const builder = new FlutterTextBuilder(""); - builder.reset(); - expect(builder.child).toEqual(""); - }); -}); diff --git a/__tests__/html/builderImpl/htmlBlend.test.ts b/__tests__/html/builderImpl/htmlBlend.test.ts deleted file mode 100644 index 48fd7223..00000000 --- a/__tests__/html/builderImpl/htmlBlend.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; -import { - htmlOpacity, - htmlRotation, - htmlVisibility, -} from "./../../../src/html/builderImpl/htmlBlend"; - -describe("HTML Blend", () => { - const node = new AltRectangleNode(); - - it("opacity", () => { - node.opacity = 0.1; - expect(htmlOpacity(node, false)).toEqual("opacity: 0.10; "); - - node.opacity = 0.3; - expect(htmlOpacity(node, true)).toEqual("opacity: 0.30, "); - - node.opacity = 1; - expect(htmlOpacity(node, false)).toEqual(""); - }); - - it("visibility", () => { - // undefined (unitialized, only happen on tests) - expect(htmlVisibility(node, false)).toEqual(""); - - node.visible = false; - expect(htmlVisibility(node, false)).toEqual("visibility: hidden; "); - - node.visible = false; - expect(htmlVisibility(node, true)).toEqual("visibility: 'hidden', "); - }); - - it("rotation", () => { - // avoid rounding errors - node.rotation = -7.0167096047110005e-15; - expect(htmlRotation(node, false)).toEqual(""); - - node.rotation = 45; - expect(htmlRotation(node, false)).toEqual("transform: rotate(45deg); "); - - node.rotation = -90; - expect(htmlRotation(node, true)).toEqual("transform: 'rotate(-90deg)', "); - }); -}); diff --git a/__tests__/html/builderImpl/htmlBorder.test.ts b/__tests__/html/builderImpl/htmlBorder.test.ts deleted file mode 100644 index ec36afa7..00000000 --- a/__tests__/html/builderImpl/htmlBorder.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - AltRectangleNode, - AltTextNode, - AltEllipseNode, -} from "../../../src/altNodes/altMixins"; -import { htmlBorderRadius } from "../../../src/html/builderImpl/htmlBorderRadius"; -describe("HTML Border", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - const node = new AltRectangleNode(); - node.topRightRadius = 0; - node.bottomLeftRadius = 0; - node.bottomRightRadius = 0; - - node.strokes = [ - { - type: "SOLID", - color: { r: 0, g: 0, b: 0 }, - }, - ]; - - it("standard corner radius", () => { - node.cornerRadius = 0; - expect(htmlBorderRadius(node, false)).toEqual(""); - - node.height = 90; - node.cornerRadius = 45; - expect(htmlBorderRadius(node, false)).toEqual("border-radius: 45px; "); - - node.topLeftRadius = 0; - node.cornerRadius = 0; - expect(htmlBorderRadius(node, false)).toEqual(""); - - node.cornerRadius = 10; - expect(htmlBorderRadius(node, false)).toEqual("border-radius: 10px; "); - }); - - it("custom corner radius", () => { - node.cornerRadius = figma.mixed; - node.topLeftRadius = 4; - expect(htmlBorderRadius(node, false)).toEqual( - "border-top-left-radius: 4px; " - ); - - node.topLeftRadius = 0; - node.topRightRadius = 4; - expect(htmlBorderRadius(node, false)).toEqual( - "border-top-right-radius: 4px; " - ); - - node.topRightRadius = 0; - node.bottomLeftRadius = 4; - expect(htmlBorderRadius(node, false)).toEqual( - "border-bottom-left-radius: 4px; " - ); - - node.bottomLeftRadius = 0; - node.bottomRightRadius = 4; - expect(htmlBorderRadius(node, false)).toEqual( - "border-bottom-right-radius: 4px; " - ); - }); - - it("other nodes", () => { - // Ellipses are always round - expect(htmlBorderRadius(new AltEllipseNode(), false)).toEqual( - "border-radius: 9999px; " - ); - - // Text is unsupported - expect(htmlBorderRadius(new AltTextNode(), false)).toEqual(""); - }); -}); diff --git a/__tests__/html/builderImpl/htmlColor.test.ts b/__tests__/html/builderImpl/htmlColor.test.ts deleted file mode 100644 index 23152682..00000000 --- a/__tests__/html/builderImpl/htmlColor.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { AltRectangleNode, AltTextNode } from "../../../src/altNodes/altMixins"; -import { htmlMain } from "./../../../src/html/htmlMain"; -import { - htmlColorFromFills, - htmlGradientFromFills, -} from "./../../../src/html/builderImpl/htmlColor"; -describe("HTML Color", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("white and black", () => { - const node = new AltTextNode(); - node.characters = ""; - node.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 1.0, - b: 1.0, - }, - opacity: 1.0, - }, - ]; - - expect(htmlColorFromFills(node.fills)).toEqual("white"); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - }, - ]; - expect(htmlColorFromFills(node.fills)).toEqual("black"); - }); - - it("opacity and visibility changes", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - visible: false, - }, - ]; - - expect(htmlColorFromFills(node.fills)).toEqual(""); - - node.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 0.0, - b: 0.0, - }, - opacity: 0.0, - visible: true, - }, - ]; - expect(htmlColorFromFills(node.fills)).toEqual("rgba(255, 0, 0, 0)"); - }); - - it("Gradient Linear", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFill]; - - expect(htmlGradientFromFills(node.fills)).toEqual( - "linear-gradient(90deg, black)" - ); - - // topLeft to bottomRight (135) - Object.assign(gradientFill.gradientTransform, [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ]); - expect(htmlGradientFromFills(node.fills)).toEqual( - "linear-gradient(131deg, black)" - ); - }); - - it("Execute Main with Linear Gradient, corners and stroke", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - // width is going be 18 because 10 + 4 + 4 of stroke. - node.height = 10; - node.width = 10; - node.fills = [gradientFill]; - node.strokeWeight = 4; - node.strokeAlign = "OUTSIDE"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - node.cornerRadius = 16; - node.dashPattern = []; - - expect(htmlMain([node])).toEqual( - `
` - ); - }); - - it("fail with other fill types", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [], - }, - ]; - - expect(htmlColorFromFills(node.fills)).toEqual(""); - }); -}); diff --git a/__tests__/html/builderImpl/htmlPadding.test.ts b/__tests__/html/builderImpl/htmlPadding.test.ts deleted file mode 100644 index 1a5cb96b..00000000 --- a/__tests__/html/builderImpl/htmlPadding.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; -import { htmlPadding } from "./../../../src/html/builderImpl/htmlPadding"; - -describe("HTML padding", () => { - it("test html padding", () => { - const frameNode = new AltFrameNode(); - expect(htmlPadding(frameNode, false)).toEqual(""); - - frameNode.layoutMode = "NONE"; - expect(htmlPadding(frameNode, false)).toEqual(""); - - frameNode.layoutMode = "VERTICAL"; - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 4; - expect(htmlPadding(frameNode, false)).toEqual("padding: 4px; "); - - frameNode.paddingLeft = 1; - frameNode.paddingRight = 2; - frameNode.paddingTop = 3; - frameNode.paddingBottom = 4; - expect(htmlPadding(frameNode, false)).toEqual( - "padding-top: 3px; padding-bottom: 4px; padding-left: 1px; padding-right: 2px; " - ); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 8; - frameNode.paddingBottom = 8; - expect(htmlPadding(frameNode, false)).toEqual( - "padding-left: 4px; padding-right: 4px; padding-top: 8px; padding-bottom: 8px; " - ); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(htmlPadding(frameNode, false)).toEqual(""); - - const notFrame = new AltRectangleNode(); - expect(htmlPadding(notFrame, false)).toEqual(""); - }); -}); diff --git a/__tests__/html/builderImpl/htmlPosition.test.ts b/__tests__/html/builderImpl/htmlPosition.test.ts deleted file mode 100644 index 0c294bf3..00000000 --- a/__tests__/html/builderImpl/htmlPosition.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { AltFrameNode } from "../../../src/altNodes/altMixins"; -import { htmlPosition } from "./../../../src/html/builderImpl/htmlPosition"; - -describe("HTML Position", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Frame Absolute Position", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.x = 0; - parent.y = 0; - parent.id = "root"; - parent.layoutMode = "NONE"; - parent.isRelative = true; - - const node = new AltFrameNode(); - parent.id = "node"; - node.parent = parent; - - // child equals parent - node.width = 100; - node.height = 100; - expect(htmlPosition(node)).toEqual("absoluteManualLayout"); - }); - - it("Position: node has same size as parent", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.layoutMode = "NONE"; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.parent = parent; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 100; - nodeF2.height = 100; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - expect(htmlPosition(node)).toEqual(""); - }); - - it("No position when parent is root", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - - const parent = new AltFrameNode(); - parent.id = "root"; - parent.layoutMode = "NONE"; - - node.parent = parent; - - expect(htmlPosition(node, parent.id)).toEqual(""); - }); -}); diff --git a/__tests__/html/builderImpl/htmlShadow.test.ts b/__tests__/html/builderImpl/htmlShadow.test.ts deleted file mode 100644 index edbab5b1..00000000 --- a/__tests__/html/builderImpl/htmlShadow.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; -import { htmlShadow } from "./../../../src/html/builderImpl/htmlShadow"; -describe("HTML Shadow", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("drop shadow", () => { - const node = new AltRectangleNode(); - - // no shadow - expect(htmlShadow(node)).toEqual(""); - - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(htmlShadow(node)).toEqual("0px 4px 4px rgba(0, 0, 0, 0.25)"); - }); - - it("inner shadow", () => { - const node = new AltRectangleNode(); - - node.effects = [ - { - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - type: "INNER_SHADOW", - visible: true, - }, - ]; - - expect(htmlShadow(node)).toEqual("0px 4px 4px rgba(0, 0, 0, 0.25) inset"); - }); -}); diff --git a/__tests__/html/builderImpl/htmlSize.test.ts b/__tests__/html/builderImpl/htmlSize.test.ts deleted file mode 100644 index ee9fcc53..00000000 --- a/__tests__/html/builderImpl/htmlSize.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; -import { htmlSize } from "../../../src/html/builderImpl/htmlSize"; - -describe("HTML Size", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("size for a rectangle", () => { - const node = new AltRectangleNode(); - - node.width = 16; - node.height = 16; - expect(htmlSize(node, false)).toEqual("width: 16px; height: 16px; "); - }); - - it("STRETCH inside AutoLayout", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.width = 100; - node.height = 100; - node.paddingLeft = 0; - node.paddingRight = 0; - node.paddingTop = 0; - node.paddingBottom = 0; - - const child = new AltRectangleNode(); - child.layoutAlign = "STRETCH"; - child.layoutGrow = 1; - child.width = 100; - child.height = 100; - - child.parent = node; - node.children = [child]; - - expect(htmlSize(child, false)).toEqual("flex: 1 1 0%; height: 100%; "); - - // fail - node.layoutMode = "VERTICAL"; - child.width = 16; - child.height = 16; - expect(htmlSize(child, false)).toEqual("width: 100%; flex: 1 1 0%; "); - }); - - it("counterAxisSizingMode is AUTO", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.primaryAxisSizingMode = "AUTO"; - node.counterAxisSizingMode = "AUTO"; - node.x = 0; - node.y = 0; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(htmlSize(node, false)).toEqual(""); - - // responsive - const parentNode = new AltFrameNode(); - parentNode.counterAxisSizingMode = "AUTO"; - parentNode.primaryAxisSizingMode = "FIXED"; - parentNode.x = 0; - parentNode.y = 0; - parentNode.width = 48; - parentNode.height = 48; - parentNode.children = [node]; - node.parent = parentNode; - expect(htmlSize(node, false)).toEqual(""); - expect(htmlSize(parentNode, false)).toEqual("width: 48px; height: 48px; "); - }); - - it("adjust parent if children's size + stroke > parent size", () => { - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - - const node = new AltRectangleNode(); - node.width = 8; - node.height = 8; - - node.strokeWeight = 4; - node.strokeAlign = "CENTER"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - expect(htmlSize(parentNode, false)).toEqual("width: 8px; height: 8px; "); - - parentNode.children = [node]; - node.parent = parentNode; - expect(htmlSize(parentNode, false)).toEqual("width: 12px; height: 12px; "); - - node.strokeAlign = "OUTSIDE"; - expect(htmlSize(parentNode, false)).toEqual("width: 16px; height: 16px; "); - }); -}); diff --git a/__tests__/html/htmlMain.test.ts b/__tests__/html/htmlMain.test.ts deleted file mode 100644 index 1e13979c..00000000 --- a/__tests__/html/htmlMain.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { - AltEllipseNode, - AltTextNode, - AltRectangleNode, - AltFrameNode, - AltGroupNode, -} from "../../src/altNodes/altMixins"; -import { convertToAutoLayout } from "../../src/altNodes/convertToAutoLayout"; -import { TailwindDefaultBuilder } from "../../src/tailwind/tailwindDefaultBuilder"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { htmlMain } from "./../../src/html/htmlMain"; - -describe("HTML Main", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("children is larger than 256", () => { - const node = new AltFrameNode(); - node.width = 320; - node.height = 320; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 385; - child1.height = 8; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 385; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(htmlMain([convertToAutoLayout(node)])) - .toEqual(`
-
-
-
`); - }); - - it("Group with relative position", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltGroupNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "GROUP"; - node.isRelative = true; - - const child = new AltRectangleNode(); - child.width = 4; - child.height = 4; - child.x = 9; - child.y = 9; - child.name = "RECT"; - child.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - node.children = [child]; - child.parent = node; - expect(htmlMain([node], "", true, true)) - .toEqual(`
-
-
`); - }); - - it("ellipse with no size", () => { - const node = new AltEllipseNode(); - - // undefined (unitialized, only happen on tests) - expect(htmlMain([node])).toEqual( - '
' - ); - // todo verify if it is working properly. - node.x = 0; - node.y = 0; - - node.width = 0; - node.height = 10; - expect(htmlMain([node])).toEqual(""); - - node.width = 10; - node.height = 0; - expect(htmlMain([node])).toEqual(""); - }); - - it("input", () => { - const textNode = new AltTextNode(); - textNode.characters = "username"; - textNode.fontSize = 26; - textNode.x = 0; - textNode.y = 0; - - const frameNode = new AltFrameNode(); - frameNode.layoutMode = "HORIZONTAL"; - frameNode.width = 100; - frameNode.height = 40; - frameNode.counterAxisSizingMode = "AUTO"; - frameNode.primaryAxisSizingMode = "AUTO"; - - frameNode.primaryAxisAlignItems = "SPACE_BETWEEN"; - frameNode.counterAxisAlignItems = "CENTER"; - - frameNode.children = [textNode]; - textNode.parent = frameNode; - - // In real life, justify-between would be converted to justify-center in the altConversion. - expect(tailwindMain([frameNode])).toEqual( - `
-

username

-
` - ); - - frameNode.name = "this is the InPuT"; - expect(htmlMain([frameNode])).toEqual( - '' - ); - }); - - it("JSX", () => { - const node = new AltRectangleNode(); - node.name = "RECT"; - - const builder = new TailwindDefaultBuilder(node, true, true); - - expect(builder.build()).toEqual(' className="RECT"'); - - builder.reset(); - expect(builder.attributes).toEqual(""); - }); - - it("JSX with relative position", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 4; - child1.height = 4; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 4; - child2.height = 4; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(htmlMain([convertToAutoLayout(node)], "", true, true)) - .toEqual(`
-
-
-
`); - }); - - it("AutoLayout", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "HORIZONTAL"; - node.itemSpacing = 4; - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 4; - child1.height = 4; - child1.x = 0; - child1.y = 0; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltFrameNode(); - child2.width = 4; - child2.height = 4; - child2.x = 8; - child2.y = 0; - child2.name = "RECT2"; - child2.counterAxisSizingMode = "FIXED"; - child2.primaryAxisSizingMode = "FIXED"; - child2.primaryAxisAlignItems = "CENTER"; - child2.counterAxisAlignItems = "CENTER"; - child2.layoutGrow = 0; - child2.layoutAlign = "INHERIT"; - child2.children = []; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(htmlMain([node], "", false, true)) - .toEqual(`
-
-
-
-
`); - - node.primaryAxisAlignItems = "MAX"; - node.counterAxisAlignItems = "MAX"; - - child2.primaryAxisAlignItems = "SPACE_BETWEEN"; - child2.counterAxisAlignItems = "CENTER"; - - expect(htmlMain([node], "", false, true)) - .toEqual(`
-
-
-
-
`); - }); - - it("Gradient Background with Gradient Text", () => { - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 1, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "HORIZONTAL"; - node.itemSpacing = 4; - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.fills = [gradientFill]; - node.effects = [ - { - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - type: "DROP_SHADOW", - visible: true, - }, - ]; - node.cornerRadius = 8; - - const text = new AltTextNode(); - text.width = 20; - text.height = 4; - text.x = 0; - text.y = 0; - text.name = "TEXT"; - text.fills = [gradientFill]; - text.characters = "gradient"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [text]; - text.parent = node; - - expect(htmlMain([node], "", false, true)) - .toEqual(`
-

gradient

-
`); - }); -}); diff --git a/__tests__/html/htmlText.test.ts b/__tests__/html/htmlText.test.ts deleted file mode 100644 index deaad6e8..00000000 --- a/__tests__/html/htmlText.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { AltTextNode } from "../../src/altNodes/altMixins"; -import { htmlMain } from "./../../src/html/htmlMain"; - -describe("HTML Text", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - it("textAutoResize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "NONE"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textAutoResize = "HEIGHT"; - expect(htmlMain([node])).toEqual('

'); - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - expect(htmlMain([node])).toEqual("

"); - }); - - it("textAlignHorizontal", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.textAlignHorizontal = "LEFT"; - expect(htmlMain([node])).toEqual("

"); - - node.textAutoResize = "NONE"; - node.textAlignHorizontal = "CENTER"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textAutoResize = "NONE"; - node.textAlignHorizontal = "RIGHT"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textAlignHorizontal = "JUSTIFIED"; - expect(htmlMain([node])).toEqual( - '

' - ); - }); - it("fontSize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.fontSize = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - expect(htmlMain([node])).toEqual('

'); - }); - - it("fontName", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.fontName = { - family: "inter", - style: "bold", - }; - expect(htmlMain([node])).toEqual('

'); - - node.fontName = { - family: "inter", - style: "medium italic", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.fontName = { - family: "inter", - style: "regular", - }; - expect(htmlMain([node])).toEqual("

"); - - node.fontName = { - family: "inter", - style: "doesn't exist", - }; - expect(htmlMain([node])).toEqual("

"); - }); - - it("letterSpacing", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.fontSize = 24; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.letterSpacing = { - value: 110, - unit: "PERCENT", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.letterSpacing = { - value: 10, - unit: "PIXELS", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - }); - - it("lineHeight", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.fontSize = 24; - - node.lineHeight = { - value: 110, - unit: "PERCENT", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.lineHeight = { - value: 10, - unit: "PIXELS", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.lineHeight = { - unit: "AUTO", - }; - expect(htmlMain([node])).toEqual( - '

' - ); - }); - - it("textCase", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textCase = "LOWER"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textCase = "TITLE"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textCase = "UPPER"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textCase = "ORIGINAL"; - expect(htmlMain([node])).toEqual("

"); - }); - - it("textDecoration", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textDecoration = "NONE"; - expect(htmlMain([node])).toEqual("

"); - - node.textDecoration = "STRIKETHROUGH"; - expect(htmlMain([node])).toEqual( - '

' - ); - - node.textDecoration = "UNDERLINE"; - expect(htmlMain([node])).toEqual( - '

' - ); - }); -}); diff --git a/__tests__/nearest-color/nearestColor.test.ts b/__tests__/nearest-color/nearestColor.test.ts deleted file mode 100644 index fe7c3bc0..00000000 --- a/__tests__/nearest-color/nearestColor.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { nearestColorFrom } from "../../src/nearest-color/nearestColor"; - -// the own developer didn't test it, but I'm testing. -describe("Nearest color", () => { - it("3 hex", () => { - const nearest = nearestColorFrom(["fff", "000"]); - expect(nearest("ff0")).toEqual("fff"); - expect(nearest("0f0")).toEqual("000"); - }); - - it("objects", () => { - const nearest = nearestColorFrom(["fff", "000"]); - expect(nearest({ r: 0, g: 0, b: 100 })).toEqual("000"); - expect(nearest({ r: 250, g: 220, b: 180 })).toEqual("fff"); - }); - - it("invalid", () => { - const nearest = nearestColorFrom(["fff", "000"]); - expect(() => nearest("ff111111111")).toThrow(); - }); -}); diff --git a/__tests__/retrieveUI/retrieveColors.test.ts b/__tests__/retrieveUI/retrieveColors.test.ts deleted file mode 100644 index 8c0c8aa3..00000000 --- a/__tests__/retrieveUI/retrieveColors.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { - retrieveGenericLinearGradients, - retrieveGenericSolidUIColors, -} from "../../src/common/retrieveUI/retrieveColors"; -import { - AltFrameNode, - AltRectangleNode, - AltTextNode, -} from "../../src/altNodes/altMixins"; - -describe("Retrieve Colors for UI", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - const child0 = new AltFrameNode(); - - const child1 = new AltRectangleNode(); - child1.parent = child0; - - const child2 = new AltFrameNode(); - child2.parent = child0; - - const child3 = new AltTextNode(); - child3.parent = child2; - - child2.children = [child3]; - - const child4 = new AltRectangleNode(); - child4.fills = []; - child4.strokes = []; - child4.parent = child0; - - child0.children = [child1, child2, child4]; - it("Solid Colors", () => { - const fills1: ReadonlyArray = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const fills2: ReadonlyArray = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - child1.fills = fills1; - child3.fills = fills2; - child3.strokes = fills1; - - expect(retrieveGenericSolidUIColors([child0], "html")).toEqual([ - { - colorName: "", - contrastBlack: 0, - contrastWhite: 0, - exported: "black", - hex: "000000", - }, - { - colorName: "", - contrastBlack: 0, - contrastWhite: 0, - exported: "white", - hex: "ffffff", - }, - ]); - - expect(retrieveGenericSolidUIColors([child0], "tailwind")).toEqual([ - { - colorName: "black", - contrastBlack: 0, - contrastWhite: 0, - exported: "text-black ", - hex: "000000", - }, - { - colorName: "white", - contrastBlack: 0, - contrastWhite: 0, - exported: "bg-white ", - hex: "ffffff", - }, - ]); - - expect(retrieveGenericSolidUIColors([child0], "flutter")).toEqual([ - { - colorName: "", - contrastBlack: 1, - contrastWhite: 21, - exported: "Colors.black", - hex: "000000", - }, - { - colorName: "", - contrastBlack: 21, - contrastWhite: 1, - exported: "Colors.white", - hex: "ffffff", - }, - ]); - - expect(retrieveGenericSolidUIColors([child0], "swiftui")).toEqual([ - { - colorName: "", - contrastBlack: 0, - contrastWhite: 0, - exported: "Color.black", - hex: "000000", - }, - { - colorName: "", - contrastBlack: 0, - contrastWhite: 0, - exported: "Color.white", - hex: "ffffff", - }, - ]); - - // Wrong - expect(retrieveGenericLinearGradients([child0], "swiftui")).toEqual([]); - }); - - it("Linear Gradients", () => { - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - child1.fills = [gradientFill]; - child3.fills = [gradientFill]; - child3.strokes = [gradientFill]; - - expect(retrieveGenericLinearGradients([child0], "html")).toEqual([ - { - css: "linear-gradient(90deg, black)", - exported: "linear-gradient(90deg, black)", - }, - ]); - - expect(retrieveGenericLinearGradients([child0], "tailwind")).toEqual([ - { - css: "linear-gradient(90deg, black)", - exported: "bg-gradient-to-r from-black ", - }, - ]); - - expect(retrieveGenericLinearGradients([child0], "flutter")).toEqual([ - { - css: "linear-gradient(90deg, black)", - exported: - "LinearGradient(begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Colors.black], )", - }, - ]); - - expect(retrieveGenericLinearGradients([child0], "swiftui")).toEqual([ - { - css: "linear-gradient(90deg, black)", - exported: - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .leading, endPoint: .trailing)", - }, - ]); - - // Wrong - expect(retrieveGenericSolidUIColors([child0], "swiftui")).toEqual([]); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiBlend.test.ts b/__tests__/swiftui/builderImpl/swiftuiBlend.test.ts deleted file mode 100644 index 9afb6dcc..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiBlend.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { swiftuiVisibility , - swiftuiOpacity, - swiftuiRotation, -, swiftuiBlendMode } from "../../../src/swiftui/builderImpl/swiftuiBlend"; -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; - - -describe("SwiftUI Blend", () => { - const node = new AltRectangleNode(); - - it("opacity", () => { - node.opacity = 0.1; - expect(swiftuiOpacity(node)).toEqual("\n.opacity(0.10)"); - - node.opacity = 0.45; - expect(swiftuiOpacity(node)).toEqual("\n.opacity(0.45)"); - - node.opacity = 0.0; - expect(swiftuiOpacity(node)).toEqual("\n.opacity(0)"); - }); - - it("visibility", () => { - // undefined (unitialized, only happen on tests) - expect(swiftuiVisibility(node)).toEqual(""); - - node.visible = false; - expect(swiftuiVisibility(node)).toEqual("\n.hidden()"); - - node.visible = true; - expect(swiftuiVisibility(node)).toEqual(""); - }); - - it("rotation", () => { - // avoid rounding errors - node.rotation = -7.0167096047110005e-15; - expect(swiftuiRotation(node)).toEqual(""); - - node.rotation = 45; - expect(swiftuiRotation(node)).toEqual(".rotationEffect(.degrees(45))"); - - node.rotation = -45; - expect(swiftuiRotation(node)).toEqual(".rotationEffect(.degrees(-45))"); - - node.rotation = -180; - expect(swiftuiRotation(node)).toEqual(".rotationEffect(.degrees(-180))"); - }); - - it("blend modes", () => { - node.blendMode = "PASS_THROUGH"; - expect(swiftuiBlendMode(node)).toEqual(""); - - node.blendMode = "COLOR"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.color)"); - - node.blendMode = "COLOR_BURN"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.colorBurn)"); - - node.blendMode = "COLOR_DODGE"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.colorDodge)"); - - node.blendMode = "DIFFERENCE"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.difference)"); - - node.blendMode = "EXCLUSION"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.exclusion)"); - - node.blendMode = "HARD_LIGHT"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.hardLight)"); - - node.blendMode = "HUE"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.hue)"); - - node.blendMode = "LIGHTEN"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.lighten)"); - - node.blendMode = "LIGHTEN"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.lighten)"); - - node.blendMode = "LUMINOSITY"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.luminosity)"); - - node.blendMode = "MULTIPLY"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.multiply)"); - - node.blendMode = "OVERLAY"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.overlay)"); - - node.blendMode = "SATURATION"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.saturation)"); - - node.blendMode = "SCREEN"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.screen)"); - - node.blendMode = "SOFT_LIGHT"; - expect(swiftuiBlendMode(node)).toEqual("\n.blendMode(.softLight)"); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiBorder.test.ts b/__tests__/swiftui/builderImpl/swiftuiBorder.test.ts deleted file mode 100644 index f935457a..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiBorder.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - swiftuiBorder, - swiftuiShapeStroke, -} from "../../../src/swiftui/builderImpl/swiftuiBorder"; -import { - AltRectangleNode, - AltEllipseNode, -} from "../../../src/altNodes/altMixins"; -describe("SwiftUI Border", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - const blackFill: Paint = { - type: "SOLID", - color: { r: 0, g: 0, b: 0 }, - opacity: 1, - }; - - it("border without corner", () => { - const node = new AltRectangleNode(); - node.strokes = [blackFill]; - - node.cornerRadius = 0; - node.strokeWeight = 0; - expect(swiftuiBorder(node)).toEqual(""); - expect(swiftuiShapeStroke(node)).toEqual(""); - - node.fills = [blackFill]; - node.strokeWeight = 10; - expect(swiftuiBorder(node)).toEqual("\n.border(Color.black, width: 10)"); - expect(swiftuiShapeStroke(node)).toEqual(""); - }); - - it("border with corner radius", () => { - const node = new AltRectangleNode(); - node.strokes = [blackFill]; - - node.cornerRadius = 0; - node.strokeWeight = 10; - - expect(swiftuiBorder(node)).toEqual(""); - expect(swiftuiShapeStroke(node)).toEqual( - "\n.stroke(Color.black, lineWidth: 10)" - ); - - node.topLeftRadius = 0; - node.cornerRadius = 8; - node.strokeWeight = 10; - - expect(swiftuiBorder(node)).toEqual( - "\n.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.black, lineWidth: 10))" - ); - expect(swiftuiShapeStroke(node)).toEqual(""); - - node.cornerRadius = figma.mixed; - node.topLeftRadius = 8; - node.topRightRadius = 6; - node.bottomLeftRadius = 4; - node.bottomRightRadius = 2; - - expect(swiftuiBorder(node)).toEqual( - `\n.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.black, lineWidth: 10))` - ); - expect(swiftuiShapeStroke(node)).toEqual(""); - - node.fills = [blackFill]; - node.topLeftRadius = 0; - node.topRightRadius = 0; - node.bottomLeftRadius = 0; - node.bottomRightRadius = 0; - - expect(swiftuiBorder(node)).toEqual(`\n.border(Color.black, width: 10)`); - expect(swiftuiShapeStroke(node)).toEqual(""); - }); - - it("Ellipse", () => { - const node = new AltEllipseNode(); - node.strokes = [blackFill]; - node.strokeWeight = 10; - - expect(swiftuiBorder(node)).toEqual(""); - expect(swiftuiShapeStroke(node)).toEqual( - "\n.stroke(Color.black, lineWidth: 10)" - ); - - node.fills = [blackFill]; - - expect(swiftuiBorder(node)).toEqual( - "\n.overlay(Ellipse().stroke(Color.black, lineWidth: 10))" - ); - expect(swiftuiShapeStroke(node)).toEqual(""); - }); - - it("border with random fill", () => { - const node = new AltRectangleNode(); - node.strokes = [ - { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [], - }, - ]; - - node.cornerRadius = 8; - node.strokeWeight = 10; - - expect(swiftuiBorder(node)).toEqual( - "\n.overlay(RoundedRectangle(cornerRadius: 8).stroke(LinearGradient(gradient: Gradient(colors: []), startPoint: .leading, endPoint: .trailing), lineWidth: 10))" - ); - expect(swiftuiShapeStroke(node)).toEqual(""); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiColor.test.ts b/__tests__/swiftui/builderImpl/swiftuiColor.test.ts deleted file mode 100644 index 55c1bea2..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiColor.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { swiftuiColorFromFills } from "../../../src/swiftui/builderImpl/swiftuiColor"; -import { AltRectangleNode, AltTextNode } from "../../../src/altNodes/altMixins"; -import { swiftuiMain } from "./../../../src/swiftui/swiftuiMain"; -describe("SwiftUI Color", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("standard set color", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 0.941, - g: 0.318, - b: 0.22, - }, - opacity: 1.0, - }, - ]; - - expect(swiftuiColorFromFills(node.fills)).toEqual( - "Color(red: 0.94, green: 0.32, blue: 0.22)" - ); - }); - - it("check for black and white on Text", () => { - const node = new AltTextNode(); - node.characters = ""; - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - }, - ]; - - expect(swiftuiColorFromFills(node.fills)).toEqual("Color.black"); - - node.fills = [ - { - type: "SOLID", - color: { - r: 1.0, - g: 1.0, - b: 1.0, - }, - opacity: 1.0, - }, - ]; - - expect(swiftuiColorFromFills(node.fills)).toEqual("Color.white"); - }); - - it("opacity and visibility changes", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - visible: false, - }, - ]; - - expect(swiftuiColorFromFills(node.fills)).toEqual(""); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: undefined, - visible: true, - }, - ]; - - // this scenario should never happen in real life; figma allows undefined to be set, but not to be get. - expect(swiftuiColorFromFills(node.fills)).toEqual("Color.black"); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 0.0, - visible: true, - }, - ]; - expect(swiftuiColorFromFills(node.fills)).toEqual( - "Color(red: 0, green: 0, blue: 0, opacity: 0)" - ); - }); - - it("Gradient Linear", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFill]; - - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .leading, endPoint: .trailing)" - ); - - // topLeft to bottomRight (135) - Object.assign(gradientFill.gradientTransform, [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .topLeading, endPoint: .bottomTrailing)" - ); - - // bottom to top (-90) - Object.assign(gradientFill.gradientTransform, [ - [7.734507789791678e-8, -1.2339448928833008, 1.1376146078109741], - [-2.3507132530212402, -1.0997783306265774e-7, 1.6796307563781738], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .bottom, endPoint: .top)" - ); - - // top to bottom (90) - Object.assign(gradientFill.gradientTransform, [ - [6.851496436866e-8, 2.085271120071411, -0.6976743936538696], - [3.9725232124328613, -1.4210854715202004e-14, -0.8289895057678223], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .top, endPoint: .bottom)" - ); - - // left to right (0) - Object.assign(gradientFill.gradientTransform, [ - [1.845637559890747, 1.9779233184635814e-7, -0.45637592673301697], - [6.030897026221282e-8, -3.364259719848633, 2.188383102416992], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .leading, endPoint: .trailing)" - ); - - // right to left (180) - Object.assign(gradientFill.gradientTransform, [ - [-2.3905811309814453, 0.04066795855760574, 1.707460880279541], - [0.07747448235750198, 4.357592582702637, -1.0299113988876343], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .trailing, endPoint: .leading)" - ); - - // bottom left to top right (-135) - Object.assign(gradientFill.gradientTransform, [ - [-1.2678464651107788, -1.9602917432785034, 1.6415824890136719], - [-3.7344324588775635, 2.3110527992248535, 0.4661891460418701], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .bottomTrailing, endPoint: .topLeading)" - ); - - // bottom left to top right (-45) - Object.assign(gradientFill.gradientTransform, [ - [0.7420053482055664, -0.6850813031196594, 0.4412658214569092], - [-1.3051068782806396, -1.3525396585464478, 1.8345310688018799], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .bottomLeading, endPoint: .topTrailing)" - ); - - // top right to bottom left (-45) - Object.assign(gradientFill.gradientTransform, [ - [-0.7061997652053833, 0.7888921499252319, 0.5180976986885071], - [1.5028705596923828, 1.2872726917266846, -1.0877336263656616], - ]); - expect(swiftuiColorFromFills(node.fills)).toEqual( - "LinearGradient(gradient: Gradient(colors: [Color.black]), startPoint: .topTrailing, endPoint: .bottomLeading)" - ); - }); - - it("Execute Main with Linear Gradient, corners and stroke", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFill]; - node.width = 10; - node.height = 10; - node.strokeWeight = 4; - node.strokeAlign = "OUTSIDE"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - node.cornerRadius = 16; - - expect(swiftuiMain([node])).toEqual( - `RoundedRectangle(cornerRadius: 16) -.fill(LinearGradient(gradient: Gradient(colors: [Color.black, Color(red: 1, green: 0, blue: 0)]), startPoint: .topLeading, endPoint: .bottomTrailing)) -.frame(width: 18, height: 18) -.overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(red: 0.25, green: 0.25, blue: 0.25), lineWidth: 4))` - ); - }); - - it("fail with other types", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "IMAGE", - scaleMode: "FILL", - imageHash: null, - }, - ]; - - expect(swiftuiColorFromFills(node.fills)).toEqual( - "Color(red: 0.50, green: 0.23, blue: 0.27, opacity: 0.50)" - ); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiEffects.test.ts b/__tests__/swiftui/builderImpl/swiftuiEffects.test.ts deleted file mode 100644 index c1aba731..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiEffects.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; -import { - swiftuiShadow, - swiftuiBlur, -} from "./../../../src/swiftui/builderImpl/swiftuiEffects"; -describe("SwiftUI Shadow and Blur", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("drop shadow", () => { - const node = new AltRectangleNode(); - - // no shadow - expect(swiftuiShadow(node)).toEqual(""); - - // x is zero (default) y is not zero - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(swiftuiShadow(node)).toEqual("\n.shadow(radius: 4, y: 4)"); - - // x is not zero y is zero (default) - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 4, y: 0 }, - radius: 4, - visible: true, - }, - ]; - - expect(swiftuiShadow(node)).toEqual("\n.shadow(radius: 4, x: 4)"); - - // x and y are different and both are not zero (default) - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 2, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(swiftuiShadow(node)).toEqual("\n.shadow(radius: 4, x: 2, y: 4)"); - - // x and y are the same, but not zero - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 1 }, - offset: { x: 4, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(swiftuiShadow(node)).toEqual( - "\n.shadow(color: Color(red: 0, green: 0, blue: 0, opacity: 1), radius: 4)" - ); - }); - - it("blur", () => { - const node = new AltRectangleNode(); - - // no shadow - expect(swiftuiBlur(node)).toEqual(""); - - node.effects = [ - { - type: "LAYER_BLUR", - radius: 4, - visible: true, - }, - ]; - - expect(swiftuiBlur(node)).toEqual("\n.blur(radius: 4)"); - }); - - it("inner shadow (invalid)", () => { - const node = new AltRectangleNode(); - - node.effects = [ - { - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - type: "INNER_SHADOW", - visible: true, - }, - ]; - - expect(swiftuiShadow(node)).toEqual(""); - expect(swiftuiBlur(node)).toEqual(""); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiPadding.test.ts b/__tests__/swiftui/builderImpl/swiftuiPadding.test.ts deleted file mode 100644 index b8f9082b..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiPadding.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { swiftuiPadding } from "../../../src/swiftui/builderImpl/swiftuiPadding"; -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; - -describe("SwiftUI padding", () => { - it("test all possible variations", () => { - const frameNode = new AltFrameNode(); - expect(swiftuiPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "NONE"; - expect(swiftuiPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "VERTICAL"; - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(swiftuiPadding(frameNode)).toEqual("\n.padding(.horizontal, 2)"); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 2; - frameNode.paddingBottom = 2; - expect(swiftuiPadding(frameNode)).toEqual("\n.padding(.vertical, 2)"); - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 2; - frameNode.paddingBottom = 2; - expect(swiftuiPadding(frameNode)).toEqual("\n.padding(2)"); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(swiftuiPadding(frameNode)).toEqual(""); - - frameNode.paddingLeft = 2; - frameNode.paddingRight = 2; - frameNode.paddingTop = 3; - frameNode.paddingBottom = 2; - expect(swiftuiPadding(frameNode)).toEqual(`\n.padding(.horizontal, 2) -.padding(.top, 3) -.padding(.bottom, 2)`); - - frameNode.paddingLeft = 3; - frameNode.paddingRight = 2; - frameNode.paddingTop = 2; - frameNode.paddingBottom = 2; - expect(swiftuiPadding(frameNode)).toEqual(`\n.padding(.vertical, 2) -.padding(.leading, 3) -.padding(.trailing, 2)`); - - frameNode.paddingLeft = 1; - frameNode.paddingRight = 2; - frameNode.paddingTop = 3; - frameNode.paddingBottom = 4; - expect(swiftuiPadding(frameNode)).toEqual( - `\n.padding(.leading, 1) -.padding(.trailing, 2) -.padding(.top, 3) -.padding(.bottom, 4)` - ); - - const notFrame = new AltRectangleNode(); - expect(swiftuiPadding(notFrame)).toEqual(""); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiPosition.test.ts b/__tests__/swiftui/builderImpl/swiftuiPosition.test.ts deleted file mode 100644 index 763c74c2..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiPosition.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { swiftuiPosition } from "../../../src/swiftui/builderImpl/swiftuiPosition"; -import { AltFrameNode } from "../../../src/altNodes/altMixins"; - -describe("SwiftUI Position", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Frame Absolute Position", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.x = 0; - parent.y = 0; - parent.id = "root"; - parent.layoutMode = "NONE"; - parent.isRelative = true; - - const node = new AltFrameNode(); - parent.id = "node"; - node.parent = parent; - node.x = 0; - node.y = 0; - - // child equals parent - node.width = 100; - node.height = 100; - expect(swiftuiPosition(node)).toEqual(""); - - node.width = 25; - node.height = 25; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 25; - nodeF2.height = 25; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - // position is set after the conversion to avoid AutoLayout auto converison - - // center - node.x = 37; - node.y = 37; - expect(swiftuiPosition(node)).toEqual(""); - - // top-left - node.x = 0; - node.y = 0; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -37.50, y: -37.50)"); - - // top-right - node.x = 75; - node.y = 0; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: 37.50, y: -37.50)"); - - // bottom-left - node.x = 0; - node.y = 75; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -37.50, y: 37.50)"); - - // bottom-right - node.x = 75; - node.y = 75; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: 37.50, y: 37.50)"); - - // top-center - node.x = 37; - node.y = 0; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -0.50, y: -37.50)"); - - // left-center - node.x = 0; - node.y = 37; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -37.50, y: -0.50)"); - - // bottom-center - node.x = 37; - node.y = 75; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -0.50, y: 37.50)"); - - // right-center - node.x = 75; - node.y = 37; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: 37.50, y: -0.50)"); - - // center Y, random X - node.x = 22; - node.y = 37; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -15.50, y: -0.50)"); - - // center X, random Y - node.x = 37; - node.y = 22; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: -0.50, y: -15.50)"); - - // without position - node.x = 45; - node.y = 88; - expect(swiftuiPosition(node)).toEqual("\n.offset(x: 7.50, y: 50.50)"); - }); - - it("Position: node has same size as parent", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.layoutMode = "NONE"; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.parent = parent; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 100; - nodeF2.height = 100; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - expect(swiftuiPosition(node, "")).toEqual(""); - }); - - it("No position when parent is root", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - - const parent = new AltFrameNode(); - parent.id = "root"; - parent.layoutMode = "NONE"; - - node.parent = parent; - - expect(swiftuiPosition(node, parent.id)).toEqual(""); - }); -}); diff --git a/__tests__/swiftui/builderImpl/swiftuiSize.test.ts b/__tests__/swiftui/builderImpl/swiftuiSize.test.ts deleted file mode 100644 index 3d66c34b..00000000 --- a/__tests__/swiftui/builderImpl/swiftuiSize.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; -import { swiftuiSize } from "../../../src/swiftui/builderImpl/swiftuiSize"; - -describe("swiftui Builder", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("size for a rectangle", () => { - const node = new AltRectangleNode(); - - node.width = 16; - node.height = 16; - expect(swiftuiSize(node)).toEqual(["width: 16", "height: 16"]); - }); - - it("STRETCH inside AutoLayout", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.paddingLeft = 0; - node.paddingRight = 0; - node.paddingTop = 0; - node.paddingBottom = 0; - node.width = 100; - node.height = 100; - - const child = new AltRectangleNode(); - child.layoutAlign = "STRETCH"; - child.layoutGrow = 1; - child.width = 100; - child.height = 100; - - child.parent = node; - node.children = [child]; - - expect(swiftuiSize(child)).toEqual([ - "maxWidth: .infinity", - "maxHeight: .infinity", - ]); - - child.layoutGrow = 0; - expect(swiftuiSize(child)).toEqual([ - "maxWidth: 100", - "maxHeight: .infinity", - ]); - - child.layoutGrow = 1; - child.layoutAlign = "INHERIT"; - expect(swiftuiSize(child)).toEqual([ - "maxWidth: .infinity", - "maxHeight: 100", - ]); - - // fail - node.layoutMode = "VERTICAL"; - child.layoutAlign = "INHERIT"; - child.layoutGrow = 0; - child.width = 16; - child.height = 16; - expect(swiftuiSize(child)).toEqual(["width: 16", "height: 16"]); - - // child is relative, therefore it must have a value - expect(swiftuiSize(node)).toEqual(["width: 100", "height: 100"]); - }); - - it("Vertical layout with FIXED counterAxis", () => { - const node = new AltFrameNode(); - node.layoutMode = "VERTICAL"; - node.counterAxisSizingMode = "FIXED"; - node.width = 16; - node.height = 16; - - const child = new AltRectangleNode(); - child.width = 8; - child.height = 8; - - child.parent = node; - node.children = [child]; - - expect(swiftuiSize(node)).toEqual(["width: 16", ""]); - }); - - it("Children are rectangles, size shouldn't be relative", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(swiftuiSize(node)).toEqual(["width: 48", "height: 48"]); - }); - - it("counterAxisSizingMode is FIXED", () => { - const node = new AltFrameNode(); - node.counterAxisSizingMode = "FIXED"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - node.layoutMode = "HORIZONTAL"; - expect(swiftuiSize(node)).toEqual(["", "height: 48"]); - - node.layoutMode = "VERTICAL"; - expect(swiftuiSize(node)).toEqual(["width: 48", ""]); - - node.layoutMode = "NONE"; - expect(swiftuiSize(node)).toEqual(["width: 48", "height: 48"]); - }); - - it("counterAxisSizingMode is AUTO", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.x = 0; - node.y = 0; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(swiftuiSize(node)).toEqual(["width: 48", "height: 48"]); - - // responsive - const parentNode = new AltFrameNode(); - parentNode.counterAxisSizingMode = "AUTO"; - parentNode.primaryAxisSizingMode = "AUTO"; - parentNode.x = 0; - parentNode.y = 0; - parentNode.width = 48; - parentNode.height = 48; - parentNode.children = [node]; - node.parent = parentNode; - - expect(swiftuiSize(node)).toEqual(["width: 48", "height: 48"]); - expect(swiftuiSize(parentNode)).toEqual(["width: 48", "height: 48"]); - }); - - it("width changes when there are strokes", () => { - const node = new AltRectangleNode(); - node.x = 0; - node.y = 0; - node.width = 8; - node.height = 8; - - expect(swiftuiSize(node)).toEqual(["width: 8", "height: 8"]); - - node.strokeWeight = 4; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - node.strokeAlign = "CENTER"; - expect(swiftuiSize(node)).toEqual(["width: 12", "height: 12"]); - - node.strokeAlign = "OUTSIDE"; - expect(swiftuiSize(node)).toEqual(["width: 16", "height: 16"]); - }); - - it("adjust parent if children's size + stroke > parent size", () => { - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - - const node = new AltRectangleNode(); - node.width = 8; - node.height = 8; - - node.strokeWeight = 4; - node.strokeAlign = "CENTER"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - expect(swiftuiSize(node)).toEqual(["width: 12", "height: 12"]); - - parentNode.children = [node]; - node.parent = parentNode; - expect(swiftuiSize(parentNode)).toEqual(["width: 12", "height: 12"]); - - node.strokeAlign = "OUTSIDE"; - expect(swiftuiSize(parentNode)).toEqual(["width: 16", "height: 16"]); - }); - - it("all branches with children's size + stroke < children's size", () => { - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - - const node = new AltRectangleNode(); - node.width = 4; - node.height = 4; - - node.strokeWeight = 2; - node.strokeAlign = "CENTER"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - expect(swiftuiSize(parentNode)).toEqual(["width: 8", "height: 8"]); - - parentNode.children = [node]; - node.parent = parentNode; - expect(swiftuiSize(parentNode)).toEqual(["width: 8", "height: 8"]); - - node.strokeAlign = "OUTSIDE"; - expect(swiftuiSize(parentNode)).toEqual(["width: 8", "height: 8"]); - - node.strokeAlign = "INSIDE"; - expect(swiftuiSize(parentNode)).toEqual(["width: 8", "height: 8"]); - }); - - it("full width when width is same to the parent", () => { - const node = new AltFrameNode(); - node.width = 12; - node.height = 12; - - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.width = 12; - parentNode.height = 12; - parentNode.children = [node]; - node.parent = parentNode; - - expect(swiftuiSize(parentNode)).toEqual(["width: 12", "height: 12"]); - expect(swiftuiSize(node)).toEqual(["width: 12", "height: 12"]); - }); - - it("set the width to max if the view is near the corner", () => { - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.x = 0; - node.y = 0; - - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.width = 120; - parentNode.height = 120; - - parentNode.children = [node]; - node.parent = parentNode; - - expect(swiftuiSize(node)).toEqual(["width: 100", "height: 100"]); - }); -}); diff --git a/__tests__/swiftui/swiftuiMain.test.ts b/__tests__/swiftui/swiftuiMain.test.ts deleted file mode 100644 index be9351ed..00000000 --- a/__tests__/swiftui/swiftuiMain.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { convertToAutoLayout } from "../../src/altNodes/convertToAutoLayout"; -import { - AltRectangleNode, - AltFrameNode, - AltGroupNode, - AltEllipseNode, - AltTextNode, -} from "../../src/altNodes/altMixins"; -import { swiftuiMain } from "./../../src/swiftui/swiftuiMain"; - -describe("SwiftUI Main", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - it("Standard flow", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 4; - child1.height = 4; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 4; - child2.height = 4; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(swiftuiMain([convertToAutoLayout(node)])).toEqual(`ZStack { - Rectangle() - .fill(Color.white) - .offset(x: -5, y: -5) - .frame(width: 4, height: 4) - - Rectangle() - .offset(x: -5, y: -5) - .frame(width: 4, height: 4) -} -.frame(width: 32, height: 32)`); - }); - it("Group with relative position", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltGroupNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "GROUP"; - node.isRelative = true; - - const child = new AltRectangleNode(); - child.width = 4; - child.height = 4; - child.x = 9; - child.y = 9; - child.name = "RECT"; - child.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - node.children = [child]; - child.parent = node; - expect(swiftuiMain([node])).toEqual(`ZStack { -Rectangle() -.fill(Color.white) -.offset(x: -5, y: -5) -.frame(width: 4, height: 4) -} -.frame(width: 32, height: 32)`); - }); - - it("Row and Column with 2 children", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltFrameNode(); - node.width = 32; - node.height = 8; - node.x = 0; - node.y = 0; - node.layoutMode = "VERTICAL"; - node.primaryAxisAlignItems = "MAX"; - node.counterAxisAlignItems = "MAX"; - node.counterAxisSizingMode = "AUTO"; - node.primaryAxisSizingMode = "AUTO"; - node.itemSpacing = 8; - - const child1 = new AltRectangleNode(); - child1.width = 8; - child1.height = 8; - child1.x = 0; - child1.y = 0; - child1.layoutAlign = "INHERIT"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 8; - child2.x = 16; - child2.y = 0; - child2.layoutAlign = "INHERIT"; - child2.fills = [ - { - type: "SOLID", - color: { - r: 0, - g: 0, - b: 0, - }, - }, - ]; - - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(swiftuiMain([node])) - .toEqual(`VStack(alignment: .trailing, spacing: 8) { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -}`); - - // variations in layoutAlign for test coverage - node.primaryAxisAlignItems = "CENTER"; - node.counterAxisAlignItems = "CENTER"; - node.itemSpacing = 16; - - expect(swiftuiMain([node])).toEqual(`VStack() { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -}`); - - // variations in layoutAlign and spacing for coverage - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - node.itemSpacing = 0; - node.fills = [ - { - type: "SOLID", - color: { r: 0, g: 0, b: 0 }, - opacity: 1, - }, - ]; - - expect(swiftuiMain([node])) - .toEqual(`VStack(alignment: .leading, spacing: 0) { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -} -.background(Color.black)`); - - // change orientation - node.layoutMode = "HORIZONTAL"; - node.primaryAxisAlignItems = "MIN"; - node.counterAxisAlignItems = "MIN"; - - expect(swiftuiMain([node])).toEqual(`HStack(alignment: .top, spacing: 0) { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -} -.background(Color.black)`); - - node.primaryAxisAlignItems = "CENTER"; - node.counterAxisAlignItems = "CENTER"; - - expect(swiftuiMain([node])).toEqual(`HStack(spacing: 0) { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -} -.background(Color.black)`); - - node.primaryAxisAlignItems = "MAX"; - node.counterAxisAlignItems = "MAX"; - - expect(swiftuiMain([node])) - .toEqual(`HStack(alignment: .bottom, spacing: 0) { - Rectangle() - .fill(Color.white) - .frame(width: 8, height: 8) - - Rectangle() - .fill(Color.black) - .frame(width: 8, height: 8) -} -.background(Color.black)`); - }); - - it("Row with 1 children", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltFrameNode(); - node.width = 32; - node.height = 8; - node.x = 0; - node.y = 0; - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "AUTO"; - node.itemSpacing = 8; - node.paddingLeft = 8; - node.paddingRight = 8; - node.paddingTop = 8; - node.paddingBottom = 8; - - const child1 = new AltRectangleNode(); - child1.width = 8; - child1.height = 8; - child1.x = 0; - child1.y = 0; - child1.cornerRadius = 8; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - node.children = [child1]; - child1.parent = node; - - expect(swiftuiMain([node])).toEqual(`RoundedRectangle(cornerRadius: 8) -.fill(Color.white) -.frame(width: 8, height: 8) -.padding(8)`); - }); - - it("101 items", () => { - const parent = new AltFrameNode(); - parent.layoutMode = "NONE"; - - const node = new AltRectangleNode(); - node.width = 20; - node.height = 20; - - parent.children = []; - for (let i = 0; i < 101; i++) { - parent.children.push(node); - } - - const conversion = swiftuiMain([parent]); - // detect if there are the slashes of the comment that only appears at > 100. - expect(conversion.match("//") !== null).toBe(true); - - // check the length. It is supposed to be long. - expect(conversion).toHaveLength(5429); - - // todo count the number of "Groups {" - }); - - it("ellipse with no size", () => { - const node = new AltEllipseNode(); - - // undefined (unitialized, only happen on tests) - expect(swiftuiMain([node])).toEqual("Ellipse()"); - - node.width = 0; - node.height = 10; - expect(swiftuiMain([node])).toEqual(""); - - node.width = 10; - node.height = 0; - expect(swiftuiMain([node])).toEqual(""); - }); - - it("Frame with round corners", () => { - const node = new AltFrameNode(); - node.width = 20; - node.height = 20; - node.layoutMode = "NONE"; - node.cornerRadius = 20; - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - visible: true, - }, - ]; - - const child = new AltTextNode(); - child.characters = ""; - child.width = 10; - child.height = 10; - child.x = 0; - child.y = 0; - child.parent = node; - - node.children = [child]; - - // undefined (unitialized, only happen on tests) - expect(swiftuiMain([convertToAutoLayout(node)])).toEqual(`Text("") -.frame(width: 10) -.padding(.trailing, 10) -.padding(.bottom, 10) -.frame(width: 20, height: 20) -.background(Color.black) -.cornerRadius(20)`); - }); -}); diff --git a/__tests__/swiftui/swiftuiText.test.ts b/__tests__/swiftui/swiftuiText.test.ts deleted file mode 100644 index 3cf66f9b..00000000 --- a/__tests__/swiftui/swiftuiText.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { swiftuiMain } from "../../src/swiftui/swiftuiMain"; -import { AltTextNode } from "../../src/altNodes/altMixins"; -import { SwiftuiTextBuilder } from "../../src/swiftui/swiftuiTextBuilder"; -import { - swiftuiFontMatcher, - swiftuiWeightMatcher, -} from "../../src/swiftui/builderImpl/swiftuiTextWeight"; - -describe("SwiftUI Text", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - it("textAlignHorizontal and textAlignVertical", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.characters = ""; - node.textAutoResize = "HEIGHT"; - - node.textAlignHorizontal = "LEFT"; - node.textAlignVertical = "TOP"; - expect(swiftuiMain([node])).toEqual(`Text("") -.frame(width: 16, alignment: .topLeading)`); - - node.textAlignHorizontal = "CENTER"; - node.textAlignVertical = "TOP"; - expect(swiftuiMain([node])).toEqual(`Text("") -.multilineTextAlignment(.center) -.frame(width: 16, alignment: .top)`); - - node.textAlignHorizontal = "RIGHT"; - node.textAlignVertical = "BOTTOM"; - expect(swiftuiMain([node])).toEqual(`Text("") -.multilineTextAlignment(.trailing) -.frame(width: 16, alignment: .bottomTrailing)`); - - node.textAlignHorizontal = "CENTER"; - node.textAlignVertical = "CENTER"; - expect(swiftuiMain([node])).toEqual( - `Text("") -.multilineTextAlignment(.center) -.frame(width: 16)` - ); - }); - - // it("fontName", () => { - // const node = new AltTextNode(); - // node.characters = ""; - // node.width = 16; - // node.height = 16; - // node.textAutoResize = "WIDTH_AND_HEIGHT"; - - // node.fontName = { - // family: "inter", - // style: "bold", - // }; - // expect(swiftuiMain([node])).toEqual(`Text("") - // .fontWeight(.bold)`); - - // node.fontName = { - // family: "inter", - // style: "medium italic", - // }; - // expect(swiftuiMain([node])).toEqual('

'); - - // node.fontName = { - // family: "inter", - // style: "regular", - // }; - // expect(swiftuiMain([node])).toEqual("

"); - // }); - - it("letterSpacing/tracking", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.fontSize = 10; - - node.letterSpacing = { - value: 110, - unit: "PERCENT", - }; - expect(swiftuiMain([node])).toEqual( - 'Text("")\n.font(.caption2)\n.tracking(11)' - ); - - node.letterSpacing = { - value: 10, - unit: "PIXELS", - }; - expect(swiftuiMain([node])).toEqual( - 'Text("")\n.font(.caption2)\n.tracking(10)' - ); - }); - - it("lineHeight/lineSpacing", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.fontSize = 24; - - node.lineHeight = { - value: 110, - unit: "PERCENT", - }; - expect(swiftuiMain([node])).toEqual( - 'Text("")\n.font(.title)\n.lineSpacing(26.40)' - ); - - node.lineHeight = { - value: 10, - unit: "PIXELS", - }; - expect(swiftuiMain([node])).toEqual( - 'Text("")\n.font(.title)\n.lineSpacing(10)' - ); - }); - - it("textCase", () => { - const node = new AltTextNode(); - node.characters = "ThInK dIfFeReNt"; - - node.textCase = "LOWER"; - expect(swiftuiMain([node])).toEqual('Text("think different")'); - - node.textCase = "TITLE"; - // todo solve this - expect(swiftuiMain([node])).toEqual('Text("ThInK dIfFeReNt")'); - - node.textCase = "UPPER"; - expect(swiftuiMain([node])).toEqual('Text("THINK DIFFERENT")'); - - node.textCase = "ORIGINAL"; - expect(swiftuiMain([node])).toEqual('Text("ThInK dIfFeReNt")'); - }); - - it("textDecoration", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textDecoration = "NONE"; - expect(swiftuiMain([node])).toEqual('Text("")'); - - node.textDecoration = "STRIKETHROUGH"; - expect(swiftuiMain([node])).toEqual(`Text("")\n.strikethrough()`); - - node.textDecoration = "UNDERLINE"; - expect(swiftuiMain([node])).toEqual(`Text("")\n.underline()`); - - node.textDecoration = "NONE"; - node.fontName = { - family: "inter", - style: "medium italic", - }; - expect(swiftuiMain([node])).toEqual(`Text("")\n.italic()`); - }); - - it("more complex examples", () => { - const node = new AltTextNode(); - node.width = 100; - node.height = 100; - node.characters = ""; - node.fontSize = 12; - node.fontName = { - family: "inter", - style: "bold", - }; - node.textAlignVertical = "CENTER"; - node.textAlignHorizontal = "RIGHT"; - node.textAutoResize = "NONE"; - - expect(swiftuiMain([node])).toEqual(`Text("") -.fontWeight(.bold) -.font(.caption) -.multilineTextAlignment(.trailing) -.frame(width: 100, height: 100, alignment: .trailing)`); - - node.textAlignHorizontal = "CENTER"; - node.fontName = figma.mixed; - - expect(swiftuiMain([node])).toEqual(`Text("") -.font(.caption) -.multilineTextAlignment(.center) -.frame(width: 100, height: 100)`); - - node.characters = "a\nb\nc"; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - expect(swiftuiMain([node])).toEqual('Text("a\\nb\\nc")\n.font(.caption)'); - }); - - it("swiftuiFontMatcher", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.fontSize = figma.mixed; - expect(swiftuiFontMatcher(node)).toEqual(""); - - node.fontSize = 11; - expect(swiftuiFontMatcher(node)).toEqual(".caption2"); - - node.fontSize = 12; - expect(swiftuiFontMatcher(node)).toEqual(`.caption`); - - node.fontSize = 13; - expect(swiftuiFontMatcher(node)).toEqual(`.footnote`); - - node.fontSize = 15; - expect(swiftuiFontMatcher(node)).toEqual(`.subheadline`); - - node.fontSize = 16; - expect(swiftuiFontMatcher(node)).toEqual(`.callout`); - - node.fontSize = 17; - expect(swiftuiFontMatcher(node)).toEqual(`.body`); - - node.fontSize = 20; - expect(swiftuiFontMatcher(node)).toEqual(`.title3`); - - node.fontSize = 22; - expect(swiftuiFontMatcher(node)).toEqual(`.title2`); - - node.fontSize = 28; - expect(swiftuiFontMatcher(node)).toEqual(`.title`); - - node.fontSize = 34; - expect(swiftuiFontMatcher(node)).toEqual(`.largeTitle`); - }); - - it("swiftuiWeightMatcher", () => { - expect(swiftuiWeightMatcher("100")).toEqual(".ultraLight"); - expect(swiftuiWeightMatcher("200")).toEqual(".thin"); - expect(swiftuiWeightMatcher("300")).toEqual(".light"); - expect(swiftuiWeightMatcher("400")).toEqual(".regular"); - expect(swiftuiWeightMatcher("500")).toEqual(".medium"); - expect(swiftuiWeightMatcher("600")).toEqual(".semibold"); - expect(swiftuiWeightMatcher("700")).toEqual(".bold"); - expect(swiftuiWeightMatcher("800")).toEqual(".heavy"); - expect(swiftuiWeightMatcher("900")).toEqual(".black"); - }); - it("reset", () => { - const node = new AltTextNode(); - node.characters = ""; - - const builder = new SwiftuiTextBuilder(); - builder.reset(); - expect(builder.build()).toEqual(""); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindBlend.test.ts b/__tests__/tailwind/builderImpl/tailwindBlend.test.ts deleted file mode 100644 index 0636349b..00000000 --- a/__tests__/tailwind/builderImpl/tailwindBlend.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - tailwindVisibility, - tailwindOpacity, - tailwindRotation, -} from "../../../src/tailwind/builderImpl/tailwindBlend"; -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; - -describe("Tailwind Blend", () => { - const node = new AltRectangleNode(); - - it("opacity", () => { - node.opacity = 0.1; - expect(tailwindOpacity(node)).toEqual("opacity-10 "); - - node.opacity = 0.3; - expect(tailwindOpacity(node)).toEqual("opacity-30 "); - - node.opacity = 0.45; - expect(tailwindOpacity(node)).toEqual("opacity-40 "); - - node.opacity = 0.65; - expect(tailwindOpacity(node)).toEqual("opacity-60 "); - - node.opacity = 0.95; - expect(tailwindOpacity(node)).toEqual("opacity-95 "); - }); - - it("visibility", () => { - // undefined (unitialized, only happen on tests) - expect(tailwindVisibility(node)).toEqual(""); - - node.visible = false; - expect(tailwindVisibility(node)).toEqual("invisible "); - - node.visible = true; - expect(tailwindVisibility(node)).toEqual(""); - }); - - it("rotation", () => { - // avoid rounding errors - node.rotation = -7.0167096047110005e-15; - expect(tailwindRotation(node)).toEqual(""); - - node.rotation = 45; - expect(tailwindRotation(node)).toEqual("transform rotate-45 "); - - node.rotation = 90; - expect(tailwindRotation(node)).toEqual("transform rotate-90 "); - - node.rotation = 180; - expect(tailwindRotation(node)).toEqual("transform rotate-180 "); - - node.rotation = -45; - expect(tailwindRotation(node)).toEqual("transform -rotate-45 "); - - node.rotation = -90; - expect(tailwindRotation(node)).toEqual("transform -rotate-90 "); - - node.rotation = -180; - expect(tailwindRotation(node)).toEqual("transform -rotate-180 "); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindBorder.test.ts b/__tests__/tailwind/builderImpl/tailwindBorder.test.ts deleted file mode 100644 index b88e9adf..00000000 --- a/__tests__/tailwind/builderImpl/tailwindBorder.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - AltRectangleNode, - AltTextNode, - AltEllipseNode, -} from "../../../src/altNodes/altMixins"; -import { - tailwindBorderWidth, - tailwindBorderRadius, -} from "../../../src/tailwind/builderImpl/tailwindBorder"; -describe("Tailwind Border", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - const node = new AltRectangleNode(); - node.topRightRadius = 0; - node.bottomLeftRadius = 0; - node.bottomRightRadius = 0; - - node.strokes = [ - { - type: "SOLID", - color: { r: 0, g: 0, b: 0 }, - }, - ]; - it("borderWidth", () => { - node.strokeWeight = 1; - expect(tailwindBorderWidth(node)).toEqual("border "); - - node.strokeWeight = 2; - expect(tailwindBorderWidth(node)).toEqual("border-2 "); - - node.strokeWeight = 4; - expect(tailwindBorderWidth(node)).toEqual("border-4 "); - - node.strokeWeight = 8; - expect(tailwindBorderWidth(node)).toEqual("border-8 "); - - // random large value to show the limit - node.strokeWeight = 22; - expect(tailwindBorderWidth(node)).toEqual("border-8 "); - }); - - it("standard corner radius", () => { - node.cornerRadius = 0; - expect(tailwindBorderRadius(node)).toEqual(""); - - node.height = 90; - node.cornerRadius = 45; - expect(tailwindBorderRadius(node)).toEqual("rounded-full "); - - node.topLeftRadius = 0; - node.cornerRadius = 0; - expect(tailwindBorderRadius(node)).toEqual(""); - - node.cornerRadius = 10; - expect(tailwindBorderRadius(node)).toEqual("rounded-lg "); - }); - - it("custom corner radius", () => { - node.cornerRadius = figma.mixed; - node.topLeftRadius = 4; - expect(tailwindBorderRadius(node)).toEqual("rounded-tl "); - - node.topLeftRadius = 0; - node.topRightRadius = 4; - expect(tailwindBorderRadius(node)).toEqual("rounded-tr "); - - node.topRightRadius = 0; - node.bottomLeftRadius = 4; - expect(tailwindBorderRadius(node)).toEqual("rounded-bl "); - - node.bottomLeftRadius = 0; - node.bottomRightRadius = 4; - expect(tailwindBorderRadius(node)).toEqual("rounded-br "); - }); - - it("other nodes", () => { - // Ellipses are always round - expect(tailwindBorderRadius(new AltEllipseNode())).toEqual("rounded-full "); - - // Text is unsupported - expect(tailwindBorderRadius(new AltTextNode())).toEqual(""); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindColor.test.ts b/__tests__/tailwind/builderImpl/tailwindColor.test.ts deleted file mode 100644 index 1f55f286..00000000 --- a/__tests__/tailwind/builderImpl/tailwindColor.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { - tailwindColorFromFills, - tailwindGradientFromFills, -} from "../../../src/tailwind/builderImpl/tailwindColor"; -import { AltRectangleNode, AltTextNode } from "../../../src/altNodes/altMixins"; -import { tailwindMain } from "./../../../src/tailwind/tailwindMain"; -describe("Tailwind Color", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("no text color when black", () => { - const node = new AltTextNode(); - node.characters = ""; - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - }, - ]; - - expect(tailwindColorFromFills(node.fills, "text")).toEqual(""); - }); - - it("opacity and visibility changes", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 1.0, - visible: false, - }, - ]; - - expect(tailwindColorFromFills(node.fills, "")).toEqual(""); - - node.fills = [ - { - type: "SOLID", - color: { - r: 0.0, - g: 0.0, - b: 0.0, - }, - opacity: 0.0, - visible: true, - }, - ]; - expect(tailwindColorFromFills(node.fills, "bg")).toEqual( - "bg-black bg-opacity-0 " - ); - }); - - it("Gradient Linear", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFill]; - - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-r from-black " - ); - - // topLeft to bottomRight (135) - Object.assign(gradientFill.gradientTransform, [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-br from-black " - ); - - // bottom to top (-90) - Object.assign(gradientFill.gradientTransform, [ - [7.734507789791678e-8, -1.2339448928833008, 1.1376146078109741], - [-2.3507132530212402, -1.0997783306265774e-7, 1.6796307563781738], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-t from-black " - ); - - // top to bottom (90) - Object.assign(gradientFill.gradientTransform, [ - [6.851496436866e-8, 2.085271120071411, -0.6976743936538696], - [3.9725232124328613, -1.4210854715202004e-14, -0.8289895057678223], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-b from-black " - ); - - // left to right (0) - Object.assign(gradientFill.gradientTransform, [ - [1.845637559890747, 1.9779233184635814e-7, -0.45637592673301697], - [6.030897026221282e-8, -3.364259719848633, 2.188383102416992], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-r from-black " - ); - - // right to left (180) - Object.assign(gradientFill.gradientTransform, [ - [-2.3905811309814453, 0.04066795855760574, 1.707460880279541], - [0.07747448235750198, 4.357592582702637, -1.0299113988876343], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-l from-black " - ); - - // bottom left to top right (-135) - Object.assign(gradientFill.gradientTransform, [ - [-1.2678464651107788, -1.9602917432785034, 1.6415824890136719], - [-3.7344324588775635, 2.3110527992248535, 0.4661891460418701], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-tl from-black " - ); - - // bottom left to top right (-45) - Object.assign(gradientFill.gradientTransform, [ - [0.7420053482055664, -0.6850813031196594, 0.4412658214569092], - [-1.3051068782806396, -1.3525396585464478, 1.8345310688018799], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-tr from-black " - ); - - // top right to bottom left (-45) - Object.assign(gradientFill.gradientTransform, [ - [-0.7061997652053833, 0.7888921499252319, 0.5180976986885071], - [1.5028705596923828, 1.2872726917266846, -1.0877336263656616], - ]); - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-bl from-black " - ); - - const gradientFillTwo: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 1, - b: 1, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFillTwo]; - - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-r from-black to-white " - ); - - const gradientFillThree: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 0.5, - color: { - r: 0.5, - g: 0.5, - b: 0.5, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 1, - b: 1, - a: 1, - }, - }, - ], - }; - - node.fills = [gradientFillThree]; - - expect(tailwindGradientFromFills(node.fills)).toEqual( - "bg-gradient-to-r from-black via-gray-500 to-white " - ); - }); - - it("Execute Main with Linear Gradient, corners and stroke", () => { - const node = new AltRectangleNode(); - const gradientFill: GradientPaint = { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0.8038461208343506, 0.7035384774208069, -0.2932307720184326], - [1.3402682542800903, -1.4652644395828247, 0.5407097935676575], - ], - gradientStops: [ - { - position: 0, - color: { - r: 0, - g: 0, - b: 0, - a: 1, - }, - }, - { - position: 1, - color: { - r: 1, - g: 0, - b: 0, - a: 1, - }, - }, - ], - }; - - // width is going be 18 because 10 + 4 + 4 of stroke. - node.height = 10; - node.width = 10; - node.fills = [gradientFill]; - node.strokeWeight = 4; - node.strokeAlign = "OUTSIDE"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - node.cornerRadius = 16; - - expect(tailwindMain([node])).toEqual( - `
` - ); - }); - - it("fail with other fill types", () => { - const node = new AltRectangleNode(); - node.fills = [ - { - type: "GRADIENT_LINEAR", - gradientTransform: [ - [0, 0, 0], - [0, 0, 0], - ], - gradientStops: [], - }, - ]; - - expect(tailwindColorFromFills(node.fills, "")).toEqual(""); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindPadding.test.ts b/__tests__/tailwind/builderImpl/tailwindPadding.test.ts deleted file mode 100644 index a7cb0077..00000000 --- a/__tests__/tailwind/builderImpl/tailwindPadding.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { tailwindPadding } from "../../../src/tailwind/builderImpl/tailwindPadding"; -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; - -describe("Tailwind padding", () => { - it("test tailwind padding", () => { - const frameNode = new AltFrameNode(); - expect(tailwindPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "NONE"; - expect(tailwindPadding(frameNode)).toEqual(""); - - frameNode.layoutMode = "VERTICAL"; - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 4.1; - frameNode.paddingBottom = 4.2; - expect(tailwindPadding(frameNode)).toEqual("py-1 "); - - frameNode.paddingLeft = 8; - frameNode.paddingRight = 8.01; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 4; - expect(tailwindPadding(frameNode)).toEqual("px-2 py-1 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(tailwindPadding(frameNode)).toEqual("px-1 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 8; - frameNode.paddingBottom = 8; - expect(tailwindPadding(frameNode)).toEqual("px-1 py-2 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 8; - frameNode.paddingBottom = 8; - expect(tailwindPadding(frameNode)).toEqual("px-1 py-2 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 4; - expect(tailwindPadding(frameNode)).toEqual("p-1 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 4.5; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 4.5; - expect(tailwindPadding(frameNode)).toEqual("px-1 py-1 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 8; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 8; - expect(tailwindPadding(frameNode)).toEqual("pl-1 pr-2 pt-1 pb-2 "); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 4; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 4; - expect(tailwindPadding(frameNode)).toEqual("pr-1 pb-1 "); - - frameNode.paddingLeft = 4; - frameNode.paddingRight = 0; - frameNode.paddingTop = 4; - frameNode.paddingBottom = 0; - expect(tailwindPadding(frameNode)).toEqual("pl-1 pt-1 "); - - frameNode.paddingLeft = 0; - frameNode.paddingRight = 0; - frameNode.paddingTop = 0; - frameNode.paddingBottom = 0; - expect(tailwindPadding(frameNode)).toEqual(""); - - const notFrame = new AltRectangleNode(); - expect(tailwindPadding(notFrame)).toEqual(""); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindPosition.test.ts b/__tests__/tailwind/builderImpl/tailwindPosition.test.ts deleted file mode 100644 index b33c1285..00000000 --- a/__tests__/tailwind/builderImpl/tailwindPosition.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { tailwindPosition } from "../../../src/tailwind/builderImpl/tailwindPosition"; -import { AltFrameNode } from "../../../src/altNodes/altMixins"; - -describe("Tailwind Position", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("Frame Absolute Position", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.x = 0; - parent.y = 0; - parent.id = "root"; - parent.layoutMode = "NONE"; - parent.isRelative = true; - - const node = new AltFrameNode(); - parent.id = "node"; - node.parent = parent; - - // child equals parent - node.width = 100; - node.height = 100; - expect(tailwindPosition(node)).toEqual(""); - - node.width = 25; - node.height = 25; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 25; - nodeF2.height = 25; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - // position is set after the conversion to avoid AutoLayout auto converison - - // center - node.x = 37; - node.y = 37; - expect(tailwindPosition(node, "", true)).toEqual( - "absolute m-auto inset-0 " - ); - expect(tailwindPosition(node, "", false)).toEqual("absoluteManualLayout"); - - // top-left - node.x = 0; - node.y = 0; - expect(tailwindPosition(node)).toEqual("absolute left-0 top-0 "); - - // top-right - node.x = 75; - node.y = 0; - expect(tailwindPosition(node)).toEqual("absolute right-0 top-0 "); - - // bottom-left - node.x = 0; - node.y = 75; - expect(tailwindPosition(node)).toEqual("absolute left-0 bottom-0 "); - - // bottom-right - node.x = 75; - node.y = 75; - expect(tailwindPosition(node)).toEqual("absolute right-0 bottom-0 "); - - // top-center - node.x = 37; - node.y = 0; - expect(tailwindPosition(node, "", true)).toEqual( - "absolute inset-x-0 top-0 mx-auto " - ); - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // left-center - node.x = 0; - node.y = 37; - expect(tailwindPosition(node, "", true)).toEqual( - "absolute inset-y-0 left-0 my-auto " - ); - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // bottom-center - node.x = 37; - node.y = 75; - expect(tailwindPosition(node, "", true)).toEqual( - "absolute inset-x-0 bottom-0 mx-auto " - ); - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // right-center - node.x = 75; - node.y = 37; - expect(tailwindPosition(node, "", true)).toEqual( - "absolute inset-y-0 right-0 my-auto " - ); - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // center Y, random X - node.x = 22; - node.y = 37; - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // center X, random Y - node.x = 37; - node.y = 22; - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - - // without position - node.x = 45; - node.y = 88; - expect(tailwindPosition(node)).toEqual("absoluteManualLayout"); - }); - - it("Position: node has same size as parent", () => { - const parent = new AltFrameNode(); - parent.width = 100; - parent.height = 100; - parent.layoutMode = "NONE"; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.parent = parent; - - const nodeF2 = new AltFrameNode(); - nodeF2.width = 100; - nodeF2.height = 100; - nodeF2.parent = parent; - - parent.children = [node, nodeF2]; - - expect(tailwindPosition(node, "")).toEqual(""); - }); - - it("No position when parent is root", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - - const parent = new AltFrameNode(); - parent.id = "root"; - parent.layoutMode = "NONE"; - - node.parent = parent; - - expect(tailwindPosition(node, parent.id)).toEqual(""); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindShadow.test.ts b/__tests__/tailwind/builderImpl/tailwindShadow.test.ts deleted file mode 100644 index 95c99ebf..00000000 --- a/__tests__/tailwind/builderImpl/tailwindShadow.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { tailwindShadow } from "../../../src/tailwind/builderImpl/tailwindShadow"; -import { AltRectangleNode } from "../../../src/altNodes/altMixins"; -describe("Tailwind Shadow", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("drop shadow", () => { - const node = new AltRectangleNode(); - - // no shadow - expect(tailwindShadow(node)).toEqual(""); - - node.effects = [ - { - type: "DROP_SHADOW", - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - visible: true, - }, - ]; - - expect(tailwindShadow(node)).toEqual("shadow "); - }); - - it("inner shadow", () => { - const node = new AltRectangleNode(); - - node.effects = [ - { - blendMode: "NORMAL", - color: { r: 0, g: 0, b: 0, a: 0.25 }, - offset: { x: 0, y: 4 }, - radius: 4, - type: "INNER_SHADOW", - visible: true, - }, - ]; - - expect(tailwindShadow(node)).toEqual("shadow-inner "); - }); -}); diff --git a/__tests__/tailwind/builderImpl/tailwindSize.test.ts b/__tests__/tailwind/builderImpl/tailwindSize.test.ts deleted file mode 100644 index 7a02d7b8..00000000 --- a/__tests__/tailwind/builderImpl/tailwindSize.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, -} from "../../../src/altNodes/altMixins"; -import { tailwindSize } from "../../../src/tailwind/builderImpl/tailwindSize"; - -describe("Tailwind Builder", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("size for a rectangle", () => { - const node = new AltRectangleNode(); - - node.width = 16; - node.height = 16; - expect(tailwindSize(node)).toEqual("w-4 h-4 "); - - node.width = 100; - node.height = 200; - expect(tailwindSize(node)).toEqual("w-24 h-48 "); - - node.width = 500; - node.height = 500; - expect(tailwindSize(node)).toEqual("w-full h-96 "); - }); - - it("STRETCH inside AutoLayout", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.width = 100; - node.height = 100; - node.paddingLeft = 0; - node.paddingRight = 0; - node.paddingTop = 0; - node.paddingBottom = 0; - - const child = new AltRectangleNode(); - child.layoutAlign = "STRETCH"; - child.width = 100; - child.height = 100; - - child.parent = node; - node.children = [child]; - - expect(tailwindSize(child)).toEqual("flex-1 h-full "); - - // fail - node.layoutMode = "VERTICAL"; - child.width = 16; - child.height = 16; - expect(tailwindSize(child)).toEqual("w-full h-1/6 "); - - // child is relative, therefore it must have a value - expect(tailwindSize(node)).toEqual("w-24 h-24 "); - }); - - it("Vertical layout with FIXED counterAxis", () => { - const node = new AltFrameNode(); - node.layoutMode = "VERTICAL"; - node.counterAxisSizingMode = "FIXED"; - node.width = 16; - node.height = 16; - - const child = new AltRectangleNode(); - child.width = 8; - child.height = 8; - - child.parent = node; - node.children = [child]; - - expect(tailwindSize(node)).toEqual("w-4 "); - }); - - it("Children are rectangles, size shouldn't be relative", () => { - const node = new AltFrameNode(); - node.layoutMode = "NONE"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(tailwindSize(node)).toEqual("w-12 h-12 "); - }); - - it("counterAxisSizingMode is FIXED", () => { - const node = new AltFrameNode(); - node.counterAxisSizingMode = "FIXED"; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - node.layoutMode = "HORIZONTAL"; - expect(tailwindSize(node)).toEqual("h-12 "); - - node.layoutMode = "VERTICAL"; - expect(tailwindSize(node)).toEqual("w-12 "); - - node.layoutMode = "NONE"; - expect(tailwindSize(node)).toEqual("w-12 h-12 "); - }); - - it("counterAxisSizingMode is AUTO", () => { - const node = new AltFrameNode(); - node.layoutMode = "HORIZONTAL"; - node.counterAxisSizingMode = "AUTO"; - node.primaryAxisSizingMode = "AUTO"; - node.x = 0; - node.y = 0; - node.width = 48; - node.height = 48; - node.children = [new AltRectangleNode(), new AltRectangleNode()]; - - expect(tailwindSize(node)).toEqual(""); - - // responsive - const parentNode = new AltFrameNode(); - parentNode.counterAxisSizingMode = "FIXED"; - parentNode.primaryAxisSizingMode = "FIXED"; - parentNode.x = 0; - parentNode.y = 0; - parentNode.width = 48; - parentNode.height = 48; - parentNode.children = [node]; - node.parent = parentNode; - expect(tailwindSize(node)).toEqual(""); - expect(tailwindSize(parentNode)).toEqual("w-12 h-12 "); - }); - - it("width changes when there are strokes", () => { - const node = new AltRectangleNode(); - node.x = 0; - node.y = 0; - node.width = 8; - node.height = 8; - - expect(tailwindSize(node)).toEqual("w-2 h-2 "); - - node.strokeWeight = 4; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - node.strokeAlign = "CENTER"; - expect(tailwindSize(node)).toEqual("w-3 h-3 "); - - node.strokeAlign = "OUTSIDE"; - expect(tailwindSize(node)).toEqual("w-4 h-4 "); - }); - - it("adjust parent if children's size + stroke > parent size", () => { - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - - const node = new AltRectangleNode(); - node.width = 8; - node.height = 8; - - node.strokeWeight = 4; - node.strokeAlign = "CENTER"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - expect(tailwindSize(parentNode)).toEqual("w-2 h-2 "); - - parentNode.children = [node]; - node.parent = parentNode; - expect(tailwindSize(parentNode)).toEqual("w-3 h-3 "); - - node.strokeAlign = "OUTSIDE"; - expect(tailwindSize(parentNode)).toEqual("w-4 h-4 "); - }); - - it("all branches with children's size + stroke < children's size", () => { - const parentNode = new AltFrameNode(); - parentNode.width = 8; - parentNode.height = 8; - - const node = new AltRectangleNode(); - node.width = 4; - node.height = 4; - - node.strokeWeight = 2; - node.strokeAlign = "CENTER"; - node.strokes = [ - { - type: "SOLID", - color: { r: 0.25, g: 0.25, b: 0.25 }, - }, - ]; - - expect(tailwindSize(parentNode)).toEqual("w-2 h-2 "); - - parentNode.children = [node]; - node.parent = parentNode; - expect(tailwindSize(parentNode)).toEqual("w-2 h-2 "); - - node.strokeAlign = "OUTSIDE"; - expect(tailwindSize(parentNode)).toEqual("w-2 h-2 "); - - node.strokeAlign = "INSIDE"; - expect(tailwindSize(parentNode)).toEqual("w-2 h-2 "); - }); - - it("full width when width is same to the parent", () => { - const node = new AltFrameNode(); - node.width = 12; - node.height = 12; - - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.width = 12; - parentNode.height = 12; - parentNode.children = [node]; - node.parent = parentNode; - - expect(tailwindSize(parentNode)).toEqual("w-3 h-3 "); - expect(tailwindSize(node)).toEqual("w-full h-full "); - }); - - it("set the width to max if the view is near the corner", () => { - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.width = 120; - parentNode.height = 120; - - const node = new AltFrameNode(); - node.width = 100; - node.height = 100; - node.x = 0; - node.y = 0; - - node.parent = parentNode; - parentNode.children = [node]; - - expect(tailwindSize(node)).toEqual("w-5/6 h-5/6 "); - }); - - it("responsive width", () => { - const node = new AltFrameNode(); - node.width = 20; - node.height = 20; - node.primaryAxisSizingMode = "FIXED"; - node.counterAxisSizingMode = "FIXED"; - - const parentNode = new AltFrameNode(); - parentNode.layoutMode = "NONE"; - parentNode.primaryAxisSizingMode = "FIXED"; - parentNode.counterAxisSizingMode = "FIXED"; - parentNode.width = 20; - parentNode.height = 20; - parentNode.children = [node]; - node.parent = parentNode; - - expect(tailwindSize(node)).toEqual("w-full h-full "); - - node.width = 10; - node.height = 10; - expect(tailwindSize(node)).toEqual("w-1/2 h-1/2 "); - - node.width = 20 / 3; - node.height = 20 / 3; - expect(tailwindSize(node)).toEqual("w-1/3 h-1/3 "); - - node.width = 40 / 3; - node.height = 40 / 3; - expect(tailwindSize(node)).toEqual("w-2/3 h-2/3 "); - - node.width = 5; - node.height = 5; - expect(tailwindSize(node)).toEqual("w-1/4 h-1/4 "); - - node.width = 15; - node.height = 15; - expect(tailwindSize(node)).toEqual("w-3/4 h-3/4 "); - - node.width = 4; - node.height = 4; - expect(tailwindSize(node)).toEqual("w-1/5 h-1/5 "); - - node.width = 10 / 3; - node.height = 10 / 3; - expect(tailwindSize(node)).toEqual("w-1/6 h-1/6 "); - - node.width = 50 / 3; - node.height = 50 / 3; - expect(tailwindSize(node)).toEqual("w-5/6 h-5/6 "); - }); -}); diff --git a/__tests__/tailwind/colors.test.ts b/__tests__/tailwind/colors.test.ts deleted file mode 100644 index dc8eee0d..00000000 --- a/__tests__/tailwind/colors.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - tailwindNearestColor, - getTailwindColor, -} from "../../src/tailwind/builderImpl/tailwindColor"; - -describe("Nearest colors", () => { - it("can it identify nearby colors?", () => { - expect(tailwindNearestColor("#fff5f5")).toEqual("#fef2f2"); - expect(tailwindNearestColor("#fff5f4")).toEqual("#fef2f2"); - expect(tailwindNearestColor("#fff5f6")).toEqual("#fdf2f8"); - }); - - it("can it identify tailwind colors?", () => { - const tailwindCompare = (color: string | RGB, equals: string) => { - expect(getTailwindColor(color)).toEqual(equals); - }; - - tailwindCompare({ r: 255, g: 245, b: 244 }, "red-50"); - - tailwindCompare("#fed7d6", "red-100"); - tailwindCompare("#fed7d7", "red-100"); - tailwindCompare("#fed7d8", "red-100"); - - tailwindCompare("#feb2b1", "red-300"); - tailwindCompare("#feb2b2", "red-300"); - tailwindCompare("#feb2b3", "red-300"); - - tailwindCompare("#fc8180", "red-400"); - tailwindCompare("#fc8181", "red-400"); - tailwindCompare("#fc8182", "red-400"); - }); -}); diff --git a/__tests__/tailwind/conversionTables.test.ts b/__tests__/tailwind/conversionTables.test.ts deleted file mode 100644 index eb8d265c..00000000 --- a/__tests__/tailwind/conversionTables.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - nearestValue, - pxToFontSize, - pxToBorderRadius, - pxToLayoutSize, - pxToLetterSpacing, - pxToLineHeight, -} from "../../src/tailwind/conversionTables"; - -describe("Tailwind Conversion Table", () => { - it("test nearestValue", () => { - expect(nearestValue(1, [0, 2])).toEqual(0); - expect(nearestValue(1, [0, 3])).toEqual(0); - expect(nearestValue(2, [0, 3])).toEqual(3); - - expect(nearestValue(0.3, [0, 0.5])).toEqual(0.5); - expect(nearestValue(0.25, [0, 0.5])).toEqual(0); - expect(nearestValue(0, [0, 0.01])).toEqual(0); - }); - - it("convert pixels to tailwind values", () => { - expect(pxToLineHeight(16)).toEqual("none"); - expect(pxToLineHeight(40)).toEqual("10"); - - expect(pxToLetterSpacing(-0.4)).toEqual("tight"); - expect(pxToLetterSpacing(0.4)).toEqual("wide"); - - expect(pxToFontSize(14)).toEqual("sm"); - expect(pxToFontSize(18)).toEqual("lg"); - - expect(pxToBorderRadius(2)).toEqual("-sm"); - expect(pxToBorderRadius(8)).toEqual("-lg"); - - expect(pxToLayoutSize(4, false)).toEqual("1"); - expect(pxToLayoutSize(385, false)).toEqual("96"); - }); -}); diff --git a/__tests__/tailwind/size.test.ts b/__tests__/tailwind/size.test.ts deleted file mode 100644 index 099ac476..00000000 --- a/__tests__/tailwind/size.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { createFigma } from "figma-api-stub"; -import { - frameNodeToAlt, - convertSingleNodeToAlt, -} from "../../src/altNodes/altConversion"; -import { tailwindSize } from "../../src/tailwind/builderImpl/tailwindSize"; -import { tailwindMain } from "./../../src/tailwind/tailwindMain"; -import { AltFrameNode, AltRectangleNode } from "./../../src/altNodes/altMixins"; - -describe("Tailwind Size", () => { - const figma = createFigma({ - simulateErrors: true, - isWithoutTimeout: false, - }); - - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = figma; - - it("rect", () => { - const node = figma.createRectangle(); - node.resize(16, 16); - const updatedNode = convertSingleNodeToAlt(node); - expect(tailwindSize(updatedNode)).toEqual("w-4 h-4 "); - }); - - it("frame", () => { - const node = figma.createFrame(); - node.resize(16, 16); - expect(tailwindSize(frameNodeToAlt(node))).toEqual("w-4 h-4 "); - }); - - // todo figure out why it is failing - // it("frame inside frame", () => { - // const node = figma.createFrame(); - // node.resize(16, 16); - // node.paddingLeft = 0; - // node.paddingRight = 0; - // node.paddingTop = 0; - // node.paddingBottom = 0; - // node.primaryAxisSizingMode = "FIXED"; - // node.counterAxisSizingMode = "FIXED"; - - // const subnode = figma.createFrame(); - // subnode.resize(16, 16); - // subnode.paddingLeft = 0; - // subnode.paddingRight = 0; - // subnode.paddingTop = 0; - // subnode.paddingBottom = 0; - // subnode.primaryAxisSizingMode = "FIXED"; - // subnode.counterAxisSizingMode = "FIXED"; - // node.appendChild(subnode); - - // expect(tailwindSize(frameNodeToAlt(node))).toEqual("w-4 "); - // expect(tailwindSize(frameNodeToAlt(subnode))).toEqual("w-4 h-4 "); - // }); - - it("frame inside frame (1/2)", () => { - const node = new AltFrameNode(); - node.width = 8; - node.height = 8; - node.primaryAxisSizingMode = "AUTO"; - node.counterAxisSizingMode = "AUTO"; - - const subnode = new AltFrameNode(); - subnode.width = 8; - subnode.height = 8; - node.primaryAxisSizingMode = "FIXED"; - node.counterAxisSizingMode = "FIXED"; - - subnode.parent = node; - node.children = [subnode]; - - expect(tailwindSize(node)).toEqual("w-2 h-2 "); - expect(tailwindSize(subnode)).toEqual("w-full h-full "); - }); - - it("small frame inside large frame", () => { - const node = figma.createFrame(); - node.resize(500, 500); - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - node.x = 0; - node.y = 0; - - const subnode = figma.createFrame(); - subnode.resize(8, 8); - subnode.x = 246; - subnode.y = 246; - - node.appendChild(subnode); - - expect(tailwindMain([frameNodeToAlt(node)])) - .toEqual(`
- -
`); - - expect(tailwindSize(frameNodeToAlt(subnode))).toEqual("w-2 h-2 "); - }); - - describe("when layoutAlign is STRETCH", () => { - it("autolayout is horizontal, width should be full", () => { - const node = figma.createFrame(); - node.resize(500, 500); - node.layoutMode = "HORIZONTAL"; - - const subnode = figma.createRectangle(); - subnode.resize(25, 25); - subnode.layoutAlign = "STRETCH"; - // todo is this correct? Maybe it should be w-full - node.appendChild(subnode); - - expect(tailwindSize(frameNodeToAlt(node))).toEqual(""); - expect(tailwindSize(convertSingleNodeToAlt(subnode))).toEqual("w-6 h-6 "); - }); - - it("autolayout is vertical, height should be ???", () => { - const node = figma.createFrame(); - node.resize(500, 500); - node.layoutMode = "VERTICAL"; - - const subnode = figma.createRectangle(); - subnode.resize(25, 25); - subnode.layoutAlign = "STRETCH"; - // todo is this correct? Maybe it should be w-full - node.appendChild(subnode); - - expect(tailwindSize(frameNodeToAlt(node))).toEqual(""); - expect(tailwindSize(convertSingleNodeToAlt(subnode))).toEqual("w-6 h-6 "); - }); - }); - - describe("parent is frame, node is frame, both the same layoutMode", () => { - it("when parent is horizontal and node is horizontal, child defines the size", () => { - const node = figma.createFrame(); - node.resize(500, 500); - node.layoutMode = "HORIZONTAL"; - - const subnode = figma.createFrame(); - subnode.resize(500, 250); - subnode.layoutGrow = 1; - subnode.layoutAlign = "INHERIT"; - subnode.layoutMode = "HORIZONTAL"; - - const child = figma.createFrame(); - child.resize(16, 16); - child.layoutGrow = 0; - child.layoutAlign = "INHERIT"; - - subnode.appendChild(child); - node.appendChild(subnode); - - expect(tailwindSize(frameNodeToAlt(node))).toEqual(""); - expect(tailwindSize(frameNodeToAlt(subnode))).toEqual(""); - expect(tailwindSize(frameNodeToAlt(child))).toEqual("w-4 h-4 "); - }); - - it("when parent is vertical and node is vertical, child defines the size", () => { - const node = new AltFrameNode(); - node.width = 500; - node.height = 500; - node.counterAxisSizingMode = "FIXED"; - node.layoutMode = "VERTICAL"; - - const subnode = new AltFrameNode(); - subnode.width = 500; - subnode.height = 255; - subnode.counterAxisSizingMode = "FIXED"; - subnode.layoutMode = "VERTICAL"; - - const child = new AltFrameNode(); - child.width = 16; - child.height = 255; - child.layoutGrow = 1; - child.layoutMode = "NONE"; - - node.children = [subnode]; - subnode.parent = node; - - subnode.children = [child]; - child.parent = subnode; - - expect(tailwindSize(node)).toEqual("w-full "); - expect(tailwindSize(subnode)).toEqual(""); - expect(tailwindSize(child)).toEqual("w-4 flex-1 "); - - // additional test for layoutGrow - subnode.layoutMode = "HORIZONTAL"; - expect(tailwindSize(child)).toEqual("flex-1 h-64 "); - }); - - it("complex autolayout example", () => { - const node = new AltFrameNode(); - node.width = 225; - node.height = 300; - node.counterAxisSizingMode = "FIXED"; - node.primaryAxisSizingMode = "FIXED"; - node.counterAxisAlignItems = "CENTER"; - node.primaryAxisAlignItems = "CENTER"; - node.layoutMode = "VERTICAL"; - node.layoutAlign = "INHERIT"; - node.paddingLeft = 10; - node.paddingRight = 10; - node.itemSpacing = 10; - node.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 0, - b: 0, - }, - }, - ]; - - const fills: ReadonlyArray = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child1 = new AltRectangleNode(); - child1.width = 205; - child1.height = 20; - child1.x = 10; - child1.y = 10; - child1.layoutAlign = "STRETCH"; - child1.fills = fills; - child1.parent = node; - - const child2 = new AltRectangleNode(); - child2.width = 205; - child2.height = 20; - child2.x = 10; - child2.y = 10; - child2.layoutAlign = "STRETCH"; - child2.fills = fills; - child2.parent = node; - - const child3 = new AltRectangleNode(); - child3.width = 100; - child3.height = 20; - child3.x = 10; - child3.y = 10; - child3.layoutAlign = "INHERIT"; - child3.fills = fills; - child3.parent = node; - - const child4 = new AltRectangleNode(); - child4.width = 30; - child4.height = 20; - child4.x = 10; - child4.y = 10; - child4.layoutAlign = "INHERIT"; - child4.fills = fills; - child4.parent = node; - - node.children = [child1, child2, child3, child4]; - - expect(tailwindMain([node])) - .toEqual(`
-
-
-
-
-
`); - }); - }); - - // describe("frame is too large for Tailwind to handle", () => { - // it("frame with no children becomes a rectangle (too large width)", () => { - // const node = figma.createFrame(); - // node.resize(500, 64); - // node.layoutMode = "NONE"; - // node.counterAxisSizingMode = "FIXED"; - - // expect(getContainerSizeProp(frameNodeToAlt(node))).toEqual( - // "w-full h-16 " - // ); - // }); - - // it("frame with no children becomes a rectangle (too large height)", () => { - // const node = figma.createFrame(); - // node.resize(64, 500); - // node.layoutMode = "NONE"; - - // // max of h-64 - // expect(getContainerSizeProp(frameNodeToAlt(node))).toEqual("w-16 h-64 "); - // }); - - // it("if height is too large with children", () => { - // const node = figma.createFrame(); - // node.resize(64, 500); - // node.layoutMode = "NONE"; - // node.counterAxisSizingMode = "FIXED"; - - // const subnode = figma.createFrame(); - // subnode.resize(64, 250); - // subnode.layoutMode = "NONE"; - // node.appendChild(subnode); - - // // h-auto - // expect(getContainerSizeProp(convertSingleNodeToAlt(node))).toEqual( - // "w-16 " - // ); - // }); - // // todo improve this. Try to set the parent height to be the same as children before h-auto - // it("children are higher than node", () => { - // const node = figma.createFrame(); - // node.resize(16, 16); - // node.layoutMode = "NONE"; - // node.counterAxisSizingMode = "FIXED"; - - // const subnode = figma.createFrame(); - // subnode.resize(32, 32); - // subnode.layoutMode = "NONE"; - // node.appendChild(subnode); - - // // h-auto - // expect(getContainerSizeProp(frameNodeToAlt(node))).toEqual("w-4 h-4 "); - // }); - - // it("child is way larger than node", () => { - // const node = figma.createFrame(); - // node.resize(16, 16); - // node.layoutMode = "NONE"; - // node.counterAxisSizingMode = "FIXED"; - - // const subnode = figma.createFrame(); - // subnode.resize(257, 257); - // subnode.layoutMode = "NONE"; - // node.appendChild(subnode); - - // // todo this seems wrong - // // h-auto - // expect(getContainerSizeProp(frameNodeToAlt(node))).toEqual("w-4 h-4 "); - // }); - // }); - - // // todo stroke - - // // when parent is HORIZONTAL and child is HORIZONTAL, let the child define the size - // }); - - // describe("Complex CustomAutoLayout Tests", () => { - // const figma = createFigma({ - // simulateErrors: true, - // isWithoutTimeout: false, - // }); - - // // @ts-ignore for some reason, need to override this for figma.mixed to work - // global.figma = figma; - - // it("Frame 73 (black frame with gray rect with two rects inside should become a black frame with a gray autolayout)", () => { - // const node = figma.createFrame(); - // node.resize(200, 200); - // node.counterAxisSizingMode = "FIXED"; - // node.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0, - // g: 0, - // b: 0, - // }, - // }, - // ]; - - // const grayLargeRect = figma.createRectangle(); - // grayLargeRect.resize(150, 150); - // grayLargeRect.x = 0; - // grayLargeRect.y = 50; - // grayLargeRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0.25, - // g: 0.25, - // b: 0.25, - // }, - // }, - // ]; - - // node.appendChild(grayLargeRect); - - // const redSmallRect = figma.createRectangle(); - // redSmallRect.resize(100, 50); - // redSmallRect.x = 25; - // redSmallRect.y = 125; - // redSmallRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 1, - // g: 0, - // b: 0, - // }, - // }, - // ]; - - // node.appendChild(redSmallRect); - - // const blueSmallRect = figma.createRectangle(); - // blueSmallRect.resize(100, 50); - // blueSmallRect.x = 25; - // blueSmallRect.y = 62; - // blueSmallRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0, - // g: 0, - // b: 1, - // }, - // }, - // ]; - - // node.appendChild(blueSmallRect); - - // expect(tailwindMain(node.id, [frameNodeToAlt(node)], false, false)).toEqual( - // `\n
- //
- //
- //
` - // ); - // }); - - // // imagine a Rect, Text and Frame. Rect will be changed to become the Frame. - // // The parent of Rect is the Frame, and the parent of Text will be Rect. - // it("Test rect becoming Frame", () => { - // const node = figma.createFrame(); - // node.resize(100, 50); - // node.x = 0; - // node.y = 0; - // node.counterAxisSizingMode = "FIXED"; - // node.layoutMode = "NONE"; - - // const grayLargeRect = figma.createRectangle(); - // grayLargeRect.resize(80, 40); - // grayLargeRect.x = 0; - // grayLargeRect.y = 0; - // grayLargeRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0.25, - // g: 0.25, - // b: 0.25, - // }, - // }, - // ]; - - // node.appendChild(grayLargeRect); - - // const blueSmallRect = figma.createRectangle(); - // blueSmallRect.resize(50, 20); - // blueSmallRect.x = 20; - // blueSmallRect.y = 20; - // blueSmallRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0, - // g: 0, - // b: 1, - // }, - // }, - // ]; - // node.appendChild(blueSmallRect); - - // const superNode = figma.createFrame(); - // superNode.resize(100, 50); - // superNode.x = 0; - // superNode.y = 0; - // superNode.counterAxisSizingMode = "FIXED"; - // superNode.layoutMode = "NONE"; - - // superNode.appendChild(node); - - // expect(tailwindMain(superNode.parent?.id ?? "", [superNode])).toEqual( - // `\n
- //
` - // ); - // }); - - // it("Test rect becoming Frame in a Group", () => { - // const node = figma.createFrame(); - // node.resize(100, 50); - // node.x = 0; - // node.y = 0; - // node.counterAxisSizingMode = "FIXED"; - // node.layoutMode = "NONE"; - - // const grayLargeRect = figma.createRectangle(); - // grayLargeRect.resize(80, 40); - // grayLargeRect.x = 0; - // grayLargeRect.y = 0; - // grayLargeRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0.25, - // g: 0.25, - // b: 0.25, - // }, - // }, - // ]; - - // const blueSmallRect = figma.createRectangle(); - // blueSmallRect.resize(50, 20); - // blueSmallRect.x = 20; - // blueSmallRect.y = 20; - // blueSmallRect.fills = [ - // { - // type: "SOLID", - // color: { - // r: 0, - // g: 0, - // b: 1, - // }, - // }, - // ]; - - // const group = figma.group([grayLargeRect, blueSmallRect], node); - - // expect(tailwindMain(node.id, [group])).toEqual( - // `\n
- //
` - // ); - // }); -}); diff --git a/__tests__/tailwind/tailwindMain.test.ts b/__tests__/tailwind/tailwindMain.test.ts deleted file mode 100644 index f7c37a4b..00000000 --- a/__tests__/tailwind/tailwindMain.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - AltRectangleNode, - AltFrameNode, - AltGroupNode, - AltEllipseNode, - AltTextNode, -} from "../../src/altNodes/altMixins"; -import { TailwindDefaultBuilder } from "../../src/tailwind/tailwindDefaultBuilder"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { convertToAutoLayout } from "./../../src/altNodes/convertToAutoLayout"; - -describe("Tailwind Main", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - - it("children is larger than 256", () => { - const node = new AltFrameNode(); - node.width = 320; - node.height = 320; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 385; - child1.height = 8; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 8; - child2.height = 385; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(tailwindMain([convertToAutoLayout(node)])) - .toEqual(`
-
-
-
`); - }); - - it("Group with relative position", () => { - // this also should neve happen in reality, because Group must have the same size as the children. - - const node = new AltGroupNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "GROUP"; - node.isRelative = true; - - const child = new AltRectangleNode(); - child.width = 4; - child.height = 4; - child.x = 9; - child.y = 9; - child.name = "RECT"; - child.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - node.children = [child]; - child.parent = node; - expect(tailwindMain([node], "", true, true)) - .toEqual(`
-
-
`); - }); - - it("ellipse with no size", () => { - const node = new AltEllipseNode(); - - // undefined (unitialized, only happen on tests) - expect(tailwindMain([node])).toEqual('
'); - - node.width = 0; - node.height = 10; - expect(tailwindMain([node])).toEqual(""); - - node.width = 10; - node.height = 0; - expect(tailwindMain([node])).toEqual(""); - }); - - it("input", () => { - const textNode = new AltTextNode(); - textNode.characters = "username"; - textNode.fontSize = 26; - textNode.x = 0; - textNode.y = 0; - - const frameNode = new AltFrameNode(); - frameNode.layoutMode = "HORIZONTAL"; - frameNode.width = 100; - frameNode.height = 40; - frameNode.counterAxisSizingMode = "AUTO"; - frameNode.primaryAxisSizingMode = "AUTO"; - - frameNode.primaryAxisAlignItems = "SPACE_BETWEEN"; - frameNode.counterAxisAlignItems = "CENTER"; - - frameNode.children = [textNode]; - textNode.parent = frameNode; - - // In real life, justify-between would be converted to justify-center in the altConversion. - expect(tailwindMain([frameNode])).toEqual( - `
-

username

-
` - ); - - frameNode.name = "this is the InPuT"; - expect(tailwindMain([frameNode])).toEqual( - '' - ); - }); - - it("JSX", () => { - const node = new AltRectangleNode(); - node.name = "RECT"; - - const builder = new TailwindDefaultBuilder(node, true, true); - - expect(builder.build()).toEqual(' className="RECT"'); - - builder.reset(); - expect(builder.attributes).toEqual(""); - }); - - it("JSX with relative position", () => { - const node = new AltFrameNode(); - node.width = 32; - node.height = 32; - node.x = 0; - node.y = 0; - node.name = "FRAME"; - node.layoutMode = "NONE"; - node.counterAxisSizingMode = "FIXED"; - - const child1 = new AltRectangleNode(); - child1.width = 4; - child1.height = 4; - child1.x = 9; - child1.y = 9; - child1.name = "RECT1"; - child1.fills = [ - { - type: "SOLID", - color: { - r: 1, - g: 1, - b: 1, - }, - }, - ]; - - const child2 = new AltRectangleNode(); - child2.width = 4; - child2.height = 4; - child2.x = 9; - child2.y = 9; - child2.name = "RECT2"; - - // this works as a test for JSX, but should never happen in reality. In reality Frame would need to have 2 children and be relative. - node.children = [child1, child2]; - child1.parent = node; - child2.parent = node; - - expect(tailwindMain([convertToAutoLayout(node)], "", true, true)) - .toEqual(`
-
-
-
`); - }); -}); diff --git a/__tests__/tailwind/tailwindText.test.ts b/__tests__/tailwind/tailwindText.test.ts deleted file mode 100644 index 40541a4b..00000000 --- a/__tests__/tailwind/tailwindText.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { TailwindTextBuilder } from "../../src/tailwind/tailwindTextBuilder"; -import { tailwindMain } from "../../src/tailwind/tailwindMain"; -import { AltTextNode } from "../../src/altNodes/altMixins"; -import { convertFontWeight } from "../../src/common/convertFontWeight"; - -describe("Tailwind Text", () => { - // @ts-expect-error for some reason, need to override this for figma.mixed to work - global.figma = { - mixed: undefined, - }; - it("textAutoResize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "NONE"; - expect(tailwindMain([node])).toEqual('

'); - - node.textAutoResize = "HEIGHT"; - expect(tailwindMain([node])).toEqual('

'); - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - expect(tailwindMain([node])).toEqual("

"); - }); - - it("textAlignHorizontal", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.textAlignHorizontal = "LEFT"; - expect(tailwindMain([node])).toEqual("

"); - - node.textAutoResize = "NONE"; - node.textAlignHorizontal = "CENTER"; - expect(tailwindMain([node])).toEqual('

'); - - node.textAlignHorizontal = "RIGHT"; - expect(tailwindMain([node])).toEqual('

'); - - node.textAlignHorizontal = "JUSTIFIED"; - expect(tailwindMain([node])).toEqual( - '

' - ); - }); - it("fontSize", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.fontSize = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - expect(tailwindMain([node])).toEqual('

'); - }); - - it("fontName", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.fontName = { - family: "inter", - style: "bold", - }; - expect(tailwindMain([node])).toEqual('

'); - - node.fontName = { - family: "inter", - style: "medium italic", - }; - expect(tailwindMain([node])).toEqual('

'); - - node.fontName = { - family: "inter", - style: "regular", - }; - expect(tailwindMain([node])).toEqual("

"); - }); - - it("letterSpacing", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.fontSize = 24; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - - node.letterSpacing = { - value: 110, - unit: "PERCENT", - }; - expect(tailwindMain([node])).toEqual( - '

' - ); - - node.letterSpacing = { - value: 10, - unit: "PIXELS", - }; - expect(tailwindMain([node])).toEqual( - '

' - ); - }); - - it("lineHeight", () => { - const node = new AltTextNode(); - node.characters = ""; - node.width = 16; - node.height = 16; - node.textAutoResize = "WIDTH_AND_HEIGHT"; - node.fontSize = 24; - - node.lineHeight = { - value: 110, - unit: "PERCENT", - }; - expect(tailwindMain([node])).toEqual( - '

' - ); - - node.lineHeight = { - value: 10, - unit: "PIXELS", - }; - expect(tailwindMain([node])).toEqual('

'); - }); - - it("textCase", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textCase = "LOWER"; - expect(tailwindMain([node])).toEqual('

'); - - node.textCase = "TITLE"; - expect(tailwindMain([node])).toEqual('

'); - - node.textCase = "UPPER"; - expect(tailwindMain([node])).toEqual('

'); - - node.textCase = "ORIGINAL"; - expect(tailwindMain([node])).toEqual("

"); - }); - - it("textDecoration", () => { - const node = new AltTextNode(); - node.characters = ""; - - node.textDecoration = "NONE"; - expect(tailwindMain([node])).toEqual("

"); - - node.textDecoration = "STRIKETHROUGH"; - expect(tailwindMain([node])).toEqual('

'); - - node.textDecoration = "UNDERLINE"; - expect(tailwindMain([node])).toEqual('

'); - }); - - it("weight", () => { - expect(convertFontWeight("tHIN")).toEqual("100"); - expect(convertFontWeight("Default")).toEqual(null); - - expect(convertFontWeight("Thin")).toEqual("100"); - expect(convertFontWeight("Extra Light")).toEqual("200"); - expect(convertFontWeight("Light")).toEqual("300"); - expect(convertFontWeight("Regular")).toEqual("400"); - expect(convertFontWeight("Medium")).toEqual("500"); - expect(convertFontWeight("Semi Bold")).toEqual("600"); - expect(convertFontWeight("SemiBold")).toEqual("600"); - expect(convertFontWeight("Bold")).toEqual("700"); - expect(convertFontWeight("Heavy")).toEqual("800"); - expect(convertFontWeight("Extra Bold")).toEqual("800"); - expect(convertFontWeight("Black")).toEqual("900"); - }); - it("reset", () => { - const node = new AltTextNode(); - node.characters = ""; - - const builder = new TailwindTextBuilder(node, false, false); - builder.reset(); - expect(builder.build()).toEqual(""); - }); -}); 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 4f11a03d..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/basic-features/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 b3a8ada4..0cee8e2d 100644 --- a/apps/debug/package.json +++ b/apps/debug/package.json @@ -11,20 +11,21 @@ }, "dependencies": { "backend": "workspace:*", - "next": "^14.1.0", + "next": "^16.2.6", "plugin-ui": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.2.6", + "react-dom": "^19.2.6" }, "devDependencies": { - "@types/node": "^20.11.6", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "autoprefixer": "^10.4.17", + "@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.33", - "tailwindcss": "3.4.1", + "postcss": "^8.5.14", + "tailwindcss": "4.3.0", "tsconfig": "workspace:*", - "typescript": "^5.3.3" + "types": "workspace:*", + "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 1cab95a0..00000000 --- a/apps/debug/pages/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { FrameworkTypes, PluginUI } from "plugin-ui"; -import * as React from "react"; - -export default function Web() { - const [selectedFramework, setSelectedFramework] = - React.useState("HTML"); - - return ( -
-

Debug Mode

-
- -
-
-
- - {}} - colors={[]} - gradients={[]} - /> -
-
- -
-
- - {}} - colors={[]} - gradients={[]} - /> -
-
-
- {/*
Templates for debugging
-
- {[1, 2, 3, 4, 5].map((d) => ( -
- A random image -
- ))} -
*/} - -
Plugin dropdown selection (each frame a different breakpoint)
-
-
-
-
-
- -
Outputs from plugin (different screen sizes)
-
-
-
-
-
- -
- Experiment on dark mode (invert colors on output)
-
-
-
-
-
-
- ); -} - -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 17df89e1..00783aab 100644 --- a/apps/plugin/package.json +++ b/apps/plugin/package.json @@ -10,31 +10,38 @@ "dev": "pnpm build:watch" }, "dependencies": { - "@figma/plugin-typings": "^1.84.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.2.0", - "react-dom": "^18.2.0" + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@types/node": "^20.11.6", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@typescript-eslint/eslint-plugin": "^6.19.1", - "@typescript-eslint/parser": "^6.19.1", - "@vitejs/plugin-react": "^4.2.1", - "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.17", - "concurrently": "^8.2.2", - "esbuild": "^0.19.12", + "@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.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.33", - "tailwindcss": "3.4.1", + "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.3.3", - "vite": "^5.0.12", - "vite-plugin-singlefile": "^1.0.0" + "types": "workspace:*", + "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 c98f51ec..1a4bd3da 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -4,27 +4,41 @@ import { flutterMain, tailwindMain, swiftuiMain, - convertIntoNodes, htmlMain, - PluginSettings, + 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; -const defaultPluginSettings: PluginSettings = { +export const defaultPluginSettings: PluginSettings = { framework: "HTML", - jsx: false, - optimizeLayout: true, - layerName: false, - inlineStyle: true, + showLayerNames: false, + useOldPluginVersion2025: false, responsiveRoot: false, flutterGenerationMode: "snippet", swiftUIGenerationMode: "snippet", - roundTailwind: 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 @@ -33,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, @@ -52,195 +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(); - figma.ui.postMessage({ - type: "pluginSettingChanged", - data: userPluginSettings, - }); - + 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) { - console.log("error: ", (e as any).stack); - figma.ui.postMessage({ - type: "error", - data: e.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 () => { - figma.showUI(__html__, { width: 450, height: 550, themeColors: true }); - await initSettings(); + console.log("[DEBUG] standardMode - Starting standard mode initialization"); + figma.showUI(__html__, { width: 450, height: 700, themeColors: true }); + 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 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); - if (msg.type === "pluginSettingChanged") { - (userPluginSettings as any)[msg.key] = msg.value; + figma.ui.onmessage = async (msg) => { + console.log( + "[DEBUG] figma.ui.onmessage", + msg?.type ? `type=${msg.type}` : "unknown type", + ); + + 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); - // figma.ui.postMessage({ - // type: "pluginSettingChanged", - // data: 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(false), - language: "HTML", - }, - ]; - case "html_jsx": - return [ - { - title: `Code`, - code: htmlMain( - convertedSelection, - { ...userPluginSettings, jsx: true }, - true - ), - language: "HTML", - }, - { - title: `Text Styles`, - code: htmlCodeGenTextStyles(true), - language: "HTML", - }, - ]; - case "tailwind": - return [ - { - title: `Code`, - code: tailwindMain(convertedSelection, { - ...userPluginSettings, - jsx: false, - }), - language: "HTML", - }, - { - title: `Colors`, - code: retrieveGenericSolidUIColors("Tailwind") - .map((d) => `#${d.hex} <- ${d.colorName}`) - .join("\n"), - language: "HTML", - }, - { - title: `Text Styles`, - code: tailwindCodeGenTextStyles(), - language: "HTML", - }, - ]; - case "tailwind_jsx": - return [ - { - title: `Code`, - code: tailwindMain(convertedSelection, { - ...userPluginSettings, - jsx: true, - }), - language: "HTML", - }, - // { - // title: `Style`, - // code: tailwindMain(convertedSelection, defaultPluginSettings), - // language: "HTML", - // }, - { - title: `Colors`, - code: retrieveGenericSolidUIColors("Tailwind") - .map((d) => `#${d.hex} <- ${d.colorName}`) - .join("\n"), - language: "HTML", - }, - { - 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 71e6113f..56451aba 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -1,34 +1,54 @@ import { useEffect, useState } from "react"; -import { FrameworkTypes, PluginSettings, PluginUI } from "plugin-ui"; +import { PluginUI } from "plugin-ui"; +import { + Framework, + PluginSettings, + ConversionMessage, + Message, + HTMLPreview, + LinearGradientConversion, + SolidColorConversion, + ErrorMessage, + SettingsChangedMessage, + Warning, +} from "types"; +import { postUISettingsChangingMessage } from "./messaging"; +import copy from "copy-to-clipboard"; interface AppState { code: string; - selectedFramework: FrameworkTypes | null; + selectedFramework: Framework; isLoading: boolean; - htmlPreview: { - size: { width: number; height: number }; - content: string; - } | null; - preferences: PluginSettings | null; - colors: { - hex: string; - colorName: string; - exportValue: string; - contrastWhite: number; - contrastBlack: number; - }[]; - gradients: { cssPreview: string; exportedValue: string }[]; + htmlPreview: HTMLPreview; + settings: PluginSettings | null; + colors: SolidColorConversion[]; + gradients: LinearGradientConversion[]; + warnings: Warning[]; } +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: null, - isLoading: false, - htmlPreview: null, - preferences: null, + selectedFramework: "HTML", + isLoading: true, + htmlPreview: emptyPreview, + settings: null, colors: [], gradients: [], + warnings: [], }); const rootStyles = getComputedStyle(document.documentElement); @@ -38,44 +58,66 @@ export default function App() { useEffect(() => { window.onmessage = (event: MessageEvent) => { - const message = event.data.pluginMessage; - console.log("[ui] message received:", message); - switch (message.type) { + const untypedMessage = event.data.pluginMessage as Message; + 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, - code: message.data, - htmlPreview: message.htmlPreview, - colors: message.colors, - gradients: message.gradients, - preferences: message.preferences, - selectedFramework: message.preferences.framework, + ...conversionMessage, + selectedFramework: conversionMessage.settings.framework, + isLoading: false, })); break; - case "pluginSettingChanged": + + case "pluginSettingsChanged": + const settingsMessage = untypedMessage as SettingsChangedMessage; setState((prevState) => ({ ...prevState, - preferences: message.data, - selectedFramework: message.data.framework, + settings: settingsMessage.settings, + selectedFramework: settingsMessage.settings.framework, })); break; + case "empty": + // const emptyMessage = untypedMessage as EmptyMessage; setState((prevState) => ({ ...prevState, - code: "// No layer is selected.", - htmlPreview: null, + code: "", + htmlPreview: emptyPreview, + warnings: [], colors: [], gradients: [], + isLoading: false, })); break; + case "error": + const errorMessage = untypedMessage as ErrorMessage; + setState((prevState) => ({ ...prevState, colors: [], gradients: [], - code: `Error :(\n// ${message.data}`, + 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; } @@ -87,70 +129,47 @@ export default function App() { }, []); useEffect(() => { - if (state.selectedFramework === null) { - const timer = setTimeout( - () => setState((prevState) => ({ ...prevState, isLoading: true })), - 300 - ); - return () => clearTimeout(timer); + parent.postMessage({ pluginMessage: { type: "ui-ready" } }, "*"); + }, []); + + const handleFrameworkChange = (updatedFramework: Framework) => { + if (updatedFramework !== state.selectedFramework) { + setState((prevState) => ({ + ...prevState, + // code: "// Loading...", + selectedFramework: updatedFramework, + })); + postUISettingsChangingMessage("framework", updatedFramework, { + targetOrigin: "*", + }); + } + }; + const handlePreferencesChange = ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => { + if (state.settings && state.settings[key] === value) { + // do nothing } else { - setState((prevState) => ({ ...prevState, isLoading: false })); + postUISettingsChangingMessage(key, value, { targetOrigin: "*" }); } - }, [state.selectedFramework]); - - if (state.selectedFramework === null) { - return state.isLoading ? ( -
- Loading Plugin... -
- ) : null; - } - - const handleFrameworkChange = (updatedFramework: FrameworkTypes) => { - setState((prevState) => ({ - ...prevState, - // code: "// Loading...", - selectedFramework: updatedFramework, - })); - - parent.postMessage( - { - pluginMessage: { - type: "pluginSettingChanged", - key: "framework", - value: updatedFramework, - }, - }, - "*" - ); }; - console.log("state.code", state.code.slice(0, 25)); + + const darkMode = isDarkFigmaBackground(figmaColorBgValue); return (
{ - parent.postMessage( - { - pluginMessage: { - type: "pluginSettingChanged", - key: key, - value: value, - }, - }, - "*" - ); - }} + settings={state.settings} 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/ui-src/messaging.ts b/apps/plugin/ui-src/messaging.ts new file mode 100644 index 00000000..bc51e23a --- /dev/null +++ b/apps/plugin/ui-src/messaging.ts @@ -0,0 +1,25 @@ +import { Message, SettingWillChangeMessage, UIMessage } from "types"; + +if (!parent || !parent.postMessage) { + throw new Error("parent.postMessage() is not defined"); +} +const postMessage = (message: UIMessage, options?: WindowPostMessageOptions) => + parent.postMessage(message, options); + +export const postUIMessage = ( + message: Message, + options?: WindowPostMessageOptions, +) => postMessage({ pluginMessage: message }, options); + +export const postUISettingsChangingMessage = ( + key: string, + value: T, + options?: WindowPostMessageOptions, +) => { + const message: SettingWillChangeMessage = { + type: "pluginSettingWillChange", + key, + value, + }; + postUIMessage(message, options); +}; 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/assets/convert_tailwind_colors.js b/assets/convert_tailwind_colors.js deleted file mode 100644 index 8de413ae..00000000 --- a/assets/convert_tailwind_colors.js +++ /dev/null @@ -1,388 +0,0 @@ -// Step 1: Remove transparent, black and white from tailwind_colors. - -// transparent: "transparent", -// black: "#000", -// white: "#fff", -const tailwind_colors_all = { - rose: { - 50: "#fff1f2", - 100: "#ffe4e6", - 200: "#fecdd3", - 300: "#fda4af", - 400: "#fb7185", - 500: "#f43f5e", - 600: "#e11d48", - 700: "#be123c", - 800: "#9f1239", - 900: "#881337", - }, - pink: { - 50: "#fdf2f8", - 100: "#fce7f3", - 200: "#fbcfe8", - 300: "#f9a8d4", - 400: "#f472b6", - 500: "#ec4899", - 600: "#db2777", - 700: "#be185d", - 800: "#9d174d", - 900: "#831843", - }, - fuchsia: { - 50: "#fdf4ff", - 100: "#fae8ff", - 200: "#f5d0fe", - 300: "#f0abfc", - 400: "#e879f9", - 500: "#d946ef", - 600: "#c026d3", - 700: "#a21caf", - 800: "#86198f", - 900: "#701a75", - }, - purple: { - 50: "#faf5ff", - 100: "#f3e8ff", - 200: "#e9d5ff", - 300: "#d8b4fe", - 400: "#c084fc", - 500: "#a855f7", - 600: "#9333ea", - 700: "#7e22ce", - 800: "#6b21a8", - 900: "#581c87", - }, - violet: { - 50: "#f5f3ff", - 100: "#ede9fe", - 200: "#ddd6fe", - 300: "#c4b5fd", - 400: "#a78bfa", - 500: "#8b5cf6", - 600: "#7c3aed", - 700: "#6d28d9", - 800: "#5b21b6", - 900: "#4c1d95", - }, - indigo: { - 50: "#eef2ff", - 100: "#e0e7ff", - 200: "#c7d2fe", - 300: "#a5b4fc", - 400: "#818cf8", - 500: "#6366f1", - 600: "#4f46e5", - 700: "#4338ca", - 800: "#3730a3", - 900: "#312e81", - }, - blue: { - 50: "#eff6ff", - 100: "#dbeafe", - 200: "#bfdbfe", - 300: "#93c5fd", - 400: "#60a5fa", - 500: "#3b82f6", - 600: "#2563eb", - 700: "#1d4ed8", - 800: "#1e40af", - 900: "#1e3a8a", - }, - lightBlue: { - 50: "#f0f9ff", - 100: "#e0f2fe", - 200: "#bae6fd", - 300: "#7dd3fc", - 400: "#38bdf8", - 500: "#0ea5e9", - 600: "#0284c7", - 700: "#0369a1", - 800: "#075985", - 900: "#0c4a6e", - }, - cyan: { - 50: "#ecfeff", - 100: "#cffafe", - 200: "#a5f3fc", - 300: "#67e8f9", - 400: "#22d3ee", - 500: "#06b6d4", - 600: "#0891b2", - 700: "#0e7490", - 800: "#155e75", - 900: "#164e63", - }, - teal: { - 50: "#f0fdfa", - 100: "#ccfbf1", - 200: "#99f6e4", - 300: "#5eead4", - 400: "#2dd4bf", - 500: "#14b8a6", - 600: "#0d9488", - 700: "#0f766e", - 800: "#115e59", - 900: "#134e4a", - }, - emerald: { - 50: "#ecfdf5", - 100: "#d1fae5", - 200: "#a7f3d0", - 300: "#6ee7b7", - 400: "#34d399", - 500: "#10b981", - 600: "#059669", - 700: "#047857", - 800: "#065f46", - 900: "#064e3b", - }, - green: { - 50: "#f0fdf4", - 100: "#dcfce7", - 200: "#bbf7d0", - 300: "#86efac", - 400: "#4ade80", - 500: "#22c55e", - 600: "#16a34a", - 700: "#15803d", - 800: "#166534", - 900: "#14532d", - }, - lime: { - 50: "#f7fee7", - 100: "#ecfccb", - 200: "#d9f99d", - 300: "#bef264", - 400: "#a3e635", - 500: "#84cc16", - 600: "#65a30d", - 700: "#4d7c0f", - 800: "#3f6212", - 900: "#365314", - }, - yellow: { - 50: "#fefce8", - 100: "#fef9c3", - 200: "#fef08a", - 300: "#fde047", - 400: "#facc15", - 500: "#eab308", - 600: "#ca8a04", - 700: "#a16207", - 800: "#854d0e", - 900: "#713f12", - }, - amber: { - 50: "#fffbeb", - 100: "#fef3c7", - 200: "#fde68a", - 300: "#fcd34d", - 400: "#fbbf24", - 500: "#f59e0b", - 600: "#d97706", - 700: "#b45309", - 800: "#92400e", - 900: "#78350f", - }, - orange: { - 50: "#fff7ed", - 100: "#ffedd5", - 200: "#fed7aa", - 300: "#fdba74", - 400: "#fb923c", - 500: "#f97316", - 600: "#ea580c", - 700: "#c2410c", - 800: "#9a3412", - 900: "#7c2d12", - }, - red: { - 50: "#fef2f2", - 100: "#fee2e2", - 200: "#fecaca", - 300: "#fca5a5", - 400: "#f87171", - 500: "#ef4444", - 600: "#dc2626", - 700: "#b91c1c", - 800: "#991b1b", - 900: "#7f1d1d", - }, - warmGray: { - 50: "#fafaf9", - 100: "#f5f5f4", - 200: "#e7e5e4", - 300: "#d6d3d1", - 400: "#a8a29e", - 500: "#78716c", - 600: "#57534e", - 700: "#44403c", - 800: "#292524", - 900: "#1c1917", - }, - trueGray: { - 50: "#fafafa", - 100: "#f5f5f5", - 200: "#e5e5e5", - 300: "#d4d4d4", - 400: "#a3a3a3", - 500: "#737373", - 600: "#525252", - 700: "#404040", - 800: "#262626", - 900: "#171717", - }, - gray: { - 50: "#fafafa", - 100: "#f4f4f5", - 200: "#e4e4e7", - 300: "#d4d4d8", - 400: "#a1a1aa", - 500: "#71717a", - 600: "#52525b", - 700: "#3f3f46", - 800: "#27272a", - 900: "#18181b", - }, - coolGray: { - 50: "#f9fafb", - 100: "#f3f4f6", - 200: "#e5e7eb", - 300: "#d1d5db", - 400: "#9ca3af", - 500: "#6b7280", - 600: "#4b5563", - 700: "#374151", - 800: "#1f2937", - 900: "#111827", - }, - blueGray: { - 50: "#f8fafc", - 100: "#f1f5f9", - 200: "#e2e8f0", - 300: "#cbd5e1", - 400: "#94a3b8", - 500: "#64748b", - 600: "#475569", - 700: "#334155", - 800: "#1e293b", - 900: "#0f172a", - }, -}; - -// default colors available -const tailwind_colors = { - pink: { - 50: "#fdf2f8", - 100: "#fce7f3", - 200: "#fbcfe8", - 300: "#f9a8d4", - 400: "#f472b6", - 500: "#ec4899", - 600: "#db2777", - 700: "#be185d", - 800: "#9d174d", - 900: "#831843", - }, - purple: { - 50: "#f5f3ff", - 100: "#ede9fe", - 200: "#ddd6fe", - 300: "#c4b5fd", - 400: "#a78bfa", - 500: "#8b5cf6", - 600: "#7c3aed", - 700: "#6d28d9", - 800: "#5b21b6", - 900: "#4c1d95", - }, - indigo: { - 50: "#eef2ff", - 100: "#e0e7ff", - 200: "#c7d2fe", - 300: "#a5b4fc", - 400: "#818cf8", - 500: "#6366f1", - 600: "#4f46e5", - 700: "#4338ca", - 800: "#3730a3", - 900: "#312e81", - }, - blue: { - 50: "#eff6ff", - 100: "#dbeafe", - 200: "#bfdbfe", - 300: "#93c5fd", - 400: "#60a5fa", - 500: "#3b82f6", - 600: "#2563eb", - 700: "#1d4ed8", - 800: "#1e40af", - 900: "#1e3a8a", - }, - green: { - 50: "#ecfdf5", - 100: "#d1fae5", - 200: "#a7f3d0", - 300: "#6ee7b7", - 400: "#34d399", - 500: "#10b981", - 600: "#059669", - 700: "#047857", - 800: "#065f46", - 900: "#064e3b", - }, - yellow: { - 50: "#fffbeb", - 100: "#fef3c7", - 200: "#fde68a", - 300: "#fcd34d", - 400: "#fbbf24", - 500: "#f59e0b", - 600: "#d97706", - 700: "#b45309", - 800: "#92400e", - 900: "#78350f", - }, - red: { - 50: "#fef2f2", - 100: "#fee2e2", - 200: "#fecaca", - 300: "#fca5a5", - 400: "#f87171", - 500: "#ef4444", - 600: "#dc2626", - 700: "#b91c1c", - 800: "#991b1b", - 900: "#7f1d1d", - }, - gray: { - 50: "#f9fafb", - 100: "#f3f4f6", - 200: "#e5e7eb", - 300: "#d1d5db", - 400: "#9ca3af", - 500: "#6b7280", - 600: "#4b5563", - 700: "#374151", - 800: "#1f2937", - 900: "#111827", - }, -}; - -// Step #2: Transform that into an array (Array(2), Array(2), ...); -// Example: ["fuchsia", {…}] where {…} is {50: "#fdf4ff", ...} -const colorsArr = Object.entries(tailwind_colors); - -// Step #3: Transform into (Array(10), Array(10), ...) while reverting key-value; -// Example: {#fdf4ff: "fuchsia-50"} -var obj = Object.create({}); -const subArr = colorsArr.map((d) => { - return Object.entries(d[1]).flatMap((e) => { - obj[e[1]] = d[0] + "-" + e[0]; - return obj; - }); -}); - -// obj contains the result. -JSON.stringify(obj); diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 6090f9c0..00000000 --- a/jest.config.js +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - preset: "ts-jest", - testEnvironment: "node", - globals: { - "ts-jest": { - diagnostics: { - ignoreCodes: ["151001"], - }, - }, - }, -}; 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 f780c8e1..052c97ef 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,19 @@ { + "name": "figma-to-code", "private": true, + "packageManager": "pnpm@9.14.4", "scripts": { "build": "turbo run build", "build:watch": "turbo run build:watch", "dev": "turbo run dev --concurrency 20", "lint": "turbo run lint", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" + "format": "prettier --write \"**/*.{ts,tsx,css,md}\"" }, "devDependencies": { - "eslint": "^8.56.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", - "prettier": "^3.2.4", - "turbo": "^1.11.3" - }, - "packageManager": "pnpm@8.7.5" + "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 47d48bf4..d677c6bd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,21 +10,24 @@ "dist/**" ], "scripts": { - "lint": "eslint \"src/**/*.ts*\"", - "test": "jest" + "lint": "eslint \"src/**/*.ts*\"" }, "dependencies": { - "@figma/plugin-typings": "^1.84.0", - "react": "18.2.0", - "react-dom": "18.2.0" + "@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.2.48", - "@types/react-dom": "^18.2.18", - "eslint": "^8.56.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", - "tsup": "^8.0.1", - "typescript": "^5.3.3" + "tsup": "^8.5.1", + "typescript": "^6.0.3" } } diff --git a/packages/backend/src/altNodes/altConversion.ts b/packages/backend/src/altNodes/altConversion.ts deleted file mode 100644 index fe5602ec..00000000 --- a/packages/backend/src/altNodes/altConversion.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { convertNodesOnRectangle } from "./convertNodesOnRectangle"; - -type ParentType = (BaseNode & ChildrenMixin) | null; - -export let globalTextStyleSegments: Record = {}; - -export const cloneNode = (node: T): T => { - // 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') - for (const prop in node) { - if ( - prop !== "parent" && - prop !== "children" && - prop !== "horizontalPadding" && - prop !== "verticalPadding" && - prop !== "mainComponent" && - prop !== "masterComponent" && - prop !== "variantProperties" && - prop !== "componentPropertyDefinitions" && - prop !== "exposedInstances" && - prop !== "componentProperties" && - prop !== "componenPropertyReferences" - ) { - cloned[prop as keyof T] = node[prop as keyof T]; - } - } - - return cloned; -}; - -export const frameNodeTo = ( - node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, - parent: ParentType -): - | RectangleNode - | FrameNode - | InstanceNode - | ComponentNode - | GroupNode - | ComponentSetNode => { - if (node.children.length === 0) { - // if it has no children, convert frame to rectangle - return frameToRectangleNode(node, parent); - } - const clone = standardClone(node, parent); - - overrideReadonlyProperty( - clone, - "children", - convertIntoNodes(node.children, clone) - ); - return convertNodesOnRectangle(clone); -}; - -// auto convert Frame to Rectangle when Frame has no Children -const frameToRectangleNode = ( - node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, - parent: ParentType -): RectangleNode => { - const clonedNode = cloneNode(node); - if (parent) { - assignParent(clonedNode, parent); - } - overrideReadonlyProperty(clonedNode, "type", "RECTANGLE"); - - return clonedNode as unknown as RectangleNode; -}; - -export const overrideReadonlyProperty = ( - obj: T, - prop: K, - value: any -): void => { - Object.defineProperty(obj, prop, { - value: value, - writable: true, - configurable: true, - }); -}; - -const assignParent = (node: SceneNode, parent: ParentType) => { - if (parent) { - overrideReadonlyProperty(node, "parent", parent); - } -}; - -const standardClone = (node: T, parent: ParentType): T => { - const clonedNode = cloneNode(node); - if (parent !== null) { - assignParent(clonedNode, parent); - } - return clonedNode; -}; - -export const convertIntoNodes = ( - sceneNode: ReadonlyArray, - parent: ParentType = null -): Array => { - const mapped: Array = sceneNode.map((node: SceneNode) => { - switch (node.type) { - case "RECTANGLE": - case "ELLIPSE": - return standardClone(node, parent); - case "LINE": - return standardClone(node, parent); - case "FRAME": - case "INSTANCE": - case "COMPONENT": - case "COMPONENT_SET": - // TODO Fix asset export. Use the new API. - // const iconToRect = iconToRectangle(node, parent); - // if (iconToRect != null) { - // return iconToRect; - // } - return frameNodeTo(node, parent); - case "GROUP": - if (node.children.length === 1 && node.visible) { - // if Group is visible and has only one child, Group should disappear. - // there will be a single value anyway. - return convertIntoNodes(node.children, parent)[0]; - } - - // TODO see if necessary. - const iconToRect = iconToRectangle(node, parent); - if (iconToRect != null) { - return iconToRect; - } - - const clone = standardClone(node, parent); - - overrideReadonlyProperty( - clone, - "children", - convertIntoNodes(node.children, clone) - ); - - // try to find big rect and regardless of that result, also try to convert to autolayout. - // There is a big chance this will be returned as a Frame - // also, Group will always have at least 2 children. - return convertNodesOnRectangle(clone); - case "TEXT": - globalTextStyleSegments[node.id] = node.getStyledTextSegments([ - "fontName", - "fills", - "fontSize", - "fontWeight", - "hyperlink", - "indentation", - "letterSpacing", - "lineHeight", - "listOptions", - "textCase", - "textDecoration", - "textStyleId", - "fillStyleId", - ]); - return standardClone(node, parent); - case "STAR": - case "POLYGON": - case "VECTOR": - return standardClone(node, parent); - case "SECTION": - const sectionClone = standardClone(node, parent); - overrideReadonlyProperty( - sectionClone, - "children", - convertIntoNodes(node.children, sectionClone) - ); - return sectionClone; - case "BOOLEAN_OPERATION": - const clonedOperation = standardClone(node, parent); - overrideReadonlyProperty(clonedOperation, "type", "RECTANGLE"); - clonedOperation.fills = [ - { - type: "IMAGE", - scaleMode: "FILL", - imageHash: "0", - opacity: 1, - visible: true, - blendMode: "NORMAL", - imageTransform: [ - [1, 0, 0], - [0, 1, 0], - ], - }, - ]; - return clonedOperation; - default: - return null; - } - }); - - return mapped.filter(notEmpty); -}; - -const iconToRectangle = ( - node: FrameNode | InstanceNode | ComponentNode | GroupNode, - parent: ParentType -): RectangleNode | null => { - // TODO Fix this. - if (false && node.children.every((d) => d.type === "VECTOR")) { - // const node = new RectangleNode(); - // node.id = node.id; - // node.name = node.name; - // if (Parent) { - // node.parent = Parent; - // } - // convertBlend(Node, node); - // // width, x, y - // convertLayout(Node, node); - // // Vector support is still missing. Meanwhile, add placeholder. - // node.cornerRadius = 8; - // node.strokes = []; - // node.strokeWeight = 0; - // node.strokeMiterLimit = 0; - // node.strokeAlign = "CENTER"; - // node.strokeCap = "NONE"; - // node.strokeJoin = "BEVEL"; - // node.dashPattern = []; - // node.fillStyleId = ""; - // node.strokeStyleId = ""; - // node.fills = [ - // { - // type: "IMAGE", - // imageHash: "", - // scaleMode: "FIT", - // visible: true, - // opacity: 0.5, - // blendMode: "NORMAL", - // }, - // ]; - // return node; - } - return null; -}; - -export function notEmpty( - value: TValue | null | undefined -): value is TValue { - return value !== null && value !== undefined; -} - -const applyMatrixToPoint = (matrix: number[][], point: number[]): number[] => { - return [ - point[0] * matrix[0][0] + point[1] * matrix[0][1] + matrix[0][2], - point[0] * matrix[1][0] + point[1] * matrix[1][1] + matrix[1][2], - ]; -}; - -/** - * this function return a bounding rect for an nodes - */ -// x/y absolute coordinates -// height/width -// x2/y2 bottom right coordinates -export const getBoundingRect = ( - node: LayoutMixin -): { - x: number; - y: number; - // x2: number; - // y2: number; - // height: number; - // width: number; -} => { - const boundingRect = { - x: 0, - y: 0, - // x2: 0, - // y2: 0, - // height: 0, - // width: 0, - }; - - const halfHeight = node.height / 2; - const halfWidth = node.width / 2; - - const [[c0, s0, x], [s1, c1, y]] = node.absoluteTransform; - const matrix = [ - [c0, s0, x + halfWidth * c0 + halfHeight * s0], - [s1, c1, y + halfWidth * s1 + halfHeight * c1], - ]; - - // the coordinates of the corners of the rectangle - const XY: { - x: number[]; - y: number[]; - } = { - x: [1, -1, 1, -1], - y: [1, -1, -1, 1], - }; - - // fill in - for (let i = 0; i <= 3; i++) { - const a = applyMatrixToPoint(matrix, [ - XY.x[i] * halfWidth, - XY.y[i] * halfHeight, - ]); - XY.x[i] = a[0]; - XY.y[i] = a[1]; - } - - XY.x.sort((a, b) => a - b); - XY.y.sort((a, b) => a - b); - - return { - x: XY.x[0], - y: XY.y[0], - }; - - return boundingRect; -}; diff --git a/packages/backend/src/altNodes/altMixins2.ts b/packages/backend/src/altNodes/altMixins2.ts deleted file mode 100644 index 6ec4e5fc..00000000 --- a/packages/backend/src/altNodes/altMixins2.ts +++ /dev/null @@ -1,29 +0,0 @@ -// SCENENODE -export type SceneNode = - | FrameNode - | GroupNode - | RectangleNode - | EllipseNode - | TextNode; - -export class RectangleNode { - readonly type = "RECTANGLE"; -} -export class EllipseNode { - readonly type = "ELLIPSE"; -} -export class FrameNodeMock { - readonly type = "FRAME"; -} -export interface FrameNodeMock extends DefaultFrameMixin {} - -export class GroupNode { - readonly type = "GROUP"; -} -export class TextNode { - readonly type = "TEXT"; -} - -export interface TextNode2 extends TextNode { - testRawr: string; -} diff --git a/packages/backend/src/altNodes/altNodeUtils.ts b/packages/backend/src/altNodes/altNodeUtils.ts new file mode 100644 index 00000000..0cc6331f --- /dev/null +++ b/packages/backend/src/altNodes/altNodeUtils.ts @@ -0,0 +1,118 @@ +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 => + Object.defineProperty(obj, prop, { + value: value, + writable: true, + configurable: true, + }), +); + +export const assignParent = overrideReadonlyProperty("parent"); +export const assignChildren = overrideReadonlyProperty("children"); +export const assignType = overrideReadonlyProperty("type"); +export const assignRectangleType = assignType("RECTANGLE"); + +export function isNotEmpty( + value: TValue | null | undefined, +): value is TValue { + return value !== null && value !== undefined; +} + +export const isTypeOrGroupOfTypes = curry( + (matchTypes: NodeType[], node: SceneNode): boolean => { + // 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) { + // If any child is not of the specified types, return false + return false; + } + } + // All children are valid types + return node.children.length > 0; // Only return true if there are children + } + + // Not a container node and not a matching type + return false; + }, +); + +export const isSVGNode = (node: SceneNode) => { + const altNode = node as 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); + } + } + return node; +}; diff --git a/packages/backend/src/altNodes/convertGroupToFrame.ts b/packages/backend/src/altNodes/convertGroupToFrame.ts deleted file mode 100644 index 7f158edf..00000000 --- a/packages/backend/src/altNodes/convertGroupToFrame.ts +++ /dev/null @@ -1,69 +0,0 @@ -// import { cloneNode } from "./altConversion"; -// import { FrameNodeMock } from "./altMixins2"; - -// // TODO Bernardo see if this is still necessary. -// export const convertGroupToFrame = (node: GroupNode): FrameNode => { -// const newNode = cloneNode(node); - -// newNode.id = node.id; -// newNode.name = node.name; - -// newNode.x = node.x; -// newNode.y = node.y; -// newNode.width = node.width; -// newNode.height = node.height; -// newNode.rotation = node.rotation; - -// newNode.fills = []; -// newNode.strokes = []; -// newNode.effects = []; -// newNode.cornerRadius = 0; - -// newNode.layoutMode = "NONE"; -// newNode.counterAxisSizingMode = "AUTO"; -// newNode.primaryAxisSizingMode = "AUTO"; -// newNode.primaryAxisAlignItems = "CENTER"; -// newNode.primaryAxisAlignItems = "CENTER"; -// newNode.clipsContent = false; -// newNode.layoutGrids = []; -// newNode.gridStyleId = ""; -// newNode.guides = []; - -// newNode.parent = node.parent; - -// // update the children's x and y position. Modify the 'original' node, then pass them. -// updateChildrenXY(node); -// newNode.children = node.children; - -// newNode.children.forEach((d) => { -// // update the parent of each child -// d.parent = newNode; -// }); - -// // don't need to take care of newNode.parent.children because method is recursive. -// // .children =... calls convertGroupToFrame() which returns the correct node - -// return newNode; -// }; - -// /** -// * Update all children's X and Y value from a Group. -// * Group uses relative values, while Frame use absolute. So child.x - group.x = child.x on Frames. -// * This isn't recursive, because it is going to run from the inner-most to outer-most element. Therefore, it would calculate wrongly otherwise. -// * -// * This must be called with a Groupnode. Param accepts anything because of the recurison. -// * Result of a Group with x,y = (250, 250) and child at (260, 260) must be child at (10, 10) -// */ -// const updateChildrenXY = (node: SceneNode): SceneNode => { -// // the second condition is necessary, so it can convert the root -// if (node.type === "GROUP") { -// node.children.forEach((d) => { -// d.x = d.x - node.x; -// d.y = d.y - node.y; -// updateChildrenXY(d); -// }); -// return node; -// } else { -// return node; -// } -// }; diff --git a/packages/backend/src/altNodes/convertNodesOnRectangle.ts b/packages/backend/src/altNodes/convertNodesOnRectangle.ts deleted file mode 100644 index 12899df7..00000000 --- a/packages/backend/src/altNodes/convertNodesOnRectangle.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { FrameNodeMock } from "./altMixins2"; - -/** - * Identify all nodes that are inside Rectangles and transform those Rectangles into Frames containing those nodes. - */ -export const convertNodesOnRectangle = ( - node: FrameNode | GroupNode | InstanceNode | ComponentNode | ComponentSetNode -): FrameNode | GroupNode | InstanceNode | ComponentNode | ComponentSetNode => { - if (node.children.length < 2) { - return node; - } - if (!node.id) { - throw new Error( - "Node is missing an id! This error should only happen in tests." - ); - } - - // TODO Make a return? - // const colliding = retrieveCollidingItems(node.children); - - // const parentsKeys = Object.keys(colliding); - // // start with all children. This is going to be filtered. - // let updatedChildren: Array = [...node.children]; - - // console.log("colliding are", parentsKeys); - - // parentsKeys.forEach((key) => { - // // dangerous cast, but this is always true - // const parentNode = node.children.find((d) => d.id === key) as RectangleNode; - - // // retrieve the position. Key should always be at the left side, so even when other items are removed, the index is kept the same. - // // const indexPosition = updatedChildren.findIndex((d) => d.id === key); - - // // filter the children to remove those that are being modified - // updatedChildren = updatedChildren.filter( - // (d) => !colliding[key].map((dd) => dd.id).includes(d.id) && key !== d.id - // ); - - // console.log("updatedChildren is now ", updatedChildren); - // const frameNode = convertRectangleToFrame(parentNode); - - // // todo when the soon-to-be-parent is larger than its parent, things get weird. Happens, for example, when a large image is used in the background. Should this be handled or is this something user should never do? - // overrideReadonlyProperty(frameNode, "children", [...colliding[key]]); - - // colliding[key].forEach((d) => { - // overrideReadonlyProperty(d, "parent", frameNode); - // d.x = d.x - frameNode.x; - // d.y = d.y - frameNode.y; - // }); - // }); - - // if (updatedChildren.length > 0) { - // overrideReadonlyProperty(node, "children", updatedChildren); - // } - - return node; -}; - -const convertRectangleToFrame = (rect: RectangleNode) => { - // If a Rect with elements inside were identified, extract this Rect - // outer methods are going to use it. - - const frameNode = new FrameNodeMock(); - - Object.assign(frameNode, { - parent: rect.parent, - width: rect.width, - height: rect.height, - }); - frameNode.x = rect.x; - frameNode.y = rect.y; - frameNode.rotation = rect.rotation; - frameNode.layoutMode = "NONE"; - - // opacity should be ignored, else it will affect children - - // when invisible, add the layer but don't fill it; he designer might use invisible layers for alignment. - // visible can be undefined in tests - if (rect.visible) { - frameNode.fills = rect.fills; - frameNode.fillStyleId = rect.fillStyleId; - - frameNode.strokes = rect.strokes; - frameNode.strokeStyleId = rect.strokeStyleId; - - frameNode.effects = rect.effects; - frameNode.effectStyleId = rect.effectStyleId; - } - - // inner Rectangle shall get a FIXED size - frameNode.counterAxisAlignItems = "MIN"; - frameNode.counterAxisSizingMode = "FIXED"; - frameNode.primaryAxisAlignItems = "MIN"; - frameNode.primaryAxisSizingMode = "FIXED"; - - frameNode.strokeAlign = rect.strokeAlign; - frameNode.strokeCap = rect.strokeCap; - frameNode.strokeJoin = rect.strokeJoin; - frameNode.strokeMiterLimit = rect.strokeMiterLimit; - frameNode.strokeWeight = rect.strokeWeight; - - frameNode.cornerRadius = rect.cornerRadius; - frameNode.cornerSmoothing = rect.cornerSmoothing; - frameNode.topLeftRadius = rect.topLeftRadius; - frameNode.topRightRadius = rect.topRightRadius; - frameNode.bottomLeftRadius = rect.bottomLeftRadius; - frameNode.bottomRightRadius = rect.bottomRightRadius; - - Object.assign(frameNode, { id: rect.id }); - frameNode.name = rect.name; - - return frameNode; -}; - -/** - * Iterate over each Rectangle and check if it has any child on top. - * This is O(n^2), but is optimized to only do j=i+1 until length, and avoid repeated entries. - * A Node can only have a single parent. The order is defined by layer order. - */ -const retrieveCollidingItems = ( - children: ReadonlyArray -): Record> => { - const used: Record = {}; - const groups: Record> = {}; - - for (let i = 0; i < children.length - 1; i++) { - const item1 = children[i]; - - // ignore items that are not Rectangles - if (item1.type !== "RECTANGLE") { - continue; - } - - for (let j = i + 1; j < children.length; j++) { - const item2 = children[j]; - - if ( - !used[item2.id] && - item1.x <= item2.x && - item1.y <= item2.y && - item1.x + item1.width >= item2.x + item2.width && - item1.y + item1.height >= item2.y + item2.height - ) { - if (!groups[item1.id]) { - groups[item1.id] = [item2]; - } else { - groups[item1.id].push(item2); - } - used[item2.id] = true; - } - } - } - - return groups; -}; 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/oldAltConversion.ts b/packages/backend/src/altNodes/oldAltConversion.ts new file mode 100644 index 00000000..02a1244a --- /dev/null +++ b/packages/backend/src/altNodes/oldAltConversion.ts @@ -0,0 +1,184 @@ +import { StyledTextSegmentSubset, ParentNode, AltNode } from "types"; +import { + assignParent, + isNotEmpty, + assignRectangleType, + assignChildren, +} 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 = + {}; + +// List of types that can be flattened into SVG +const canBeFlattened = isTypeOrGroupOfTypes([ + "VECTOR", + "STAR", + "POLYGON", + "BOOLEAN_OPERATION", +]); + +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 + case "RECTANGLE": + case "ELLIPSE": + case "LINE": + case "STAR": + case "POLYGON": + case "VECTOR": + case "BOOLEAN_OPERATION": + return cloneNode(node, parent); + + // Group nodes + case "FRAME": + case "INSTANCE": + case "COMPONENT": + case "COMPONENT_SET": + // if the frame, instance etc. has no children, convert the frame to rectangle + if (node.children.length === 0) + return cloneAsRectangleNode(node, parent); + // goto SECTION + + case "GROUP": + // if a Group is visible and has only one child, the Group should be ungrouped. + if (type === "GROUP" && node.children.length === 1 && node.visible) + return convertNodeToAltNode(parent)(node.children[0]); + // goto SECTION + + case "SECTION": + const group = cloneNode(node, parent); + const groupChildren = oldConvertNodesToAltNodes(node.children, group); + return assignChildren(groupChildren, group); + + // Text Nodes + case "TEXT": + globalTextStyleSegments[node.id] = extractStyledTextSegments(node); + return cloneNode(node, parent); + + // Unsupported Nodes + case "SLICE": + throw new Error( + `Sorry, Slices are not supported. Type:${node.type} id:${node.id}`, + ); + default: + throw new Error( + `Sorry, an unsupported node type was selected. Type:${node.type} id:${node.id}`, + ); + } + }; + +export const oldConvertNodesToAltNodes = ( + sceneNode: ReadonlyArray, + parent: ParentNode | null, +): Array => + sceneNode.map(convertNodeToAltNode(parent)).filter(isNotEmpty); + +export const cloneNode = ( + node: T, + parent: ParentNode | null, +): T => { + // 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') + for (const prop in node) { + 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; +}; + +// auto convert Frame to Rectangle when Frame has no Children +const cloneAsRectangleNode = ( + node: T, + parent: ParentNode | null, +): RectangleNode => { + const clonedNode = cloneNode(node, parent); + + assignRectangleType(clonedNode); + + return clonedNode as unknown as RectangleNode; +}; + +const extractStyledTextSegments = (node: TextNode) => + node.getStyledTextSegments([ + "fontName", + "fills", + "fontSize", + "fontWeight", + "hyperlink", + "indentation", + "letterSpacing", + "lineHeight", + "listOptions", + "textCase", + "textDecoration", + "textStyleId", + "fillStyleId", + "openTypeFeatures", + ]); 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 89e7f556..9b4518fb 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -1,80 +1,174 @@ -import { convertIntoNodes } from "./altNodes/altConversion"; import { + retrieveGenericLinearGradients, retrieveGenericSolidUIColors, - retrieveGenericLinearGradients as retrieveGenericGradients, } from "./common/retrieveUI/retrieveColors"; -import { flutterMain } from "./flutter/flutterMain"; -import { htmlMain } from "./html/htmlMain"; -import { swiftuiMain } from "./swiftui/swiftuiMain"; -import { tailwindMain } from "./tailwind/tailwindMain"; +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 type FrameworkTypes = "Flutter" | "SwiftUI" | "HTML" | "Tailwind"; +export const run = async (settings: PluginSettings) => { + resetPerformanceCounters(); + clearWarnings(); -export type PluginSettings = { - framework: FrameworkTypes; - jsx: boolean; - inlineStyle: boolean; - optimizeLayout: boolean; - layerName: boolean; - responsiveRoot: boolean; - flutterGenerationMode: string; - swiftUIGenerationMode: string; - roundTailwind: boolean; -}; + const { framework, useOldPluginVersion2025 } = settings; + const selection = figma.currentPage.selection; -export const run = (settings: PluginSettings) => { - // ignore when nothing was selected - if (figma.currentPage.selection.length === 0) { - figma.ui.postMessage({ - type: "empty", + 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. + if (convertedSelection.length === 0) { + postEmptyMessage(); return; } - const convertedSelection = convertIntoNodes( - figma.currentPage.selection, - null + const convertToCodeStart = Date.now(); + const code = await convertToCode(convertedSelection, settings); + console.log( + `[benchmark] convertToCode: ${Date.now() - convertToCodeStart}ms`, ); - let result = ""; - switch (settings.framework) { - case "HTML": - result = htmlMain(convertedSelection, settings); - break; - case "Tailwind": - result = tailwindMain(convertedSelection, settings); - break; - case "Flutter": - result = flutterMain(convertedSelection, settings); - break; - case "SwiftUI": - result = swiftuiMain(convertedSelection, settings); - break; + + 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)`, + ); - figma.ui.postMessage({ - type: "code", - data: result, - settings: settings, - htmlPreview: - convertedSelection.length > 0 - ? { - size: convertedSelection.map((node) => ({ - width: node.width, - height: node.height, - }))[0], - content: htmlMain( - convertedSelection, - { - ...settings, - jsx: false, - }, - true - ), - } - : null, - colors: retrieveGenericSolidUIColors(settings.framework), - gradients: retrieveGenericGradients(settings.framework), - preferences: settings, - // text: retrieveTailwindText(convertedSelection), + postConversionComplete({ + code, + htmlPreview, + colors, + gradients, + settings, + warnings: [...warnings], }); }; diff --git a/packages/backend/src/common/color.ts b/packages/backend/src/common/color.ts index 8890e195..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,19 +23,67 @@ 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], - t2: [number, number, number] + t2: [number, number, number], ): { translation: [number, number]; rotation: 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 8c3bfa4c..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/commonConversionWarnings.ts b/packages/backend/src/common/commonConversionWarnings.ts new file mode 100644 index 00000000..ca342e91 --- /dev/null +++ b/packages/backend/src/common/commonConversionWarnings.ts @@ -0,0 +1,10 @@ +import { Warning } from "types"; + +export const warnings = new Set(); +export const addWarning = (warning: Warning) => { + if (warnings.has(warning) === false) { + console.warn(warning); + } + warnings.add(warning); +}; +export const clearWarnings = () => warnings.clear(); diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts new file mode 100644 index 00000000..3f858262 --- /dev/null +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -0,0 +1,30 @@ +import { lowercaseFirstLetter } from "./lowercaseFirstLetter"; + +export const getClassLabel = (isJSX: boolean = false) => + isJSX ? "className" : "class"; + +export const joinStyles = (styles: string[], isJSX: boolean) => + styles.map((s) => s.trim()).join(isJSX ? ", " : "; "); + +export const formatStyleAttribute = ( + styles: string[], + isJSX: boolean, +): string => { + const trimmedStyles = joinStyles(styles, isJSX); + + if (trimmedStyles === "") return ""; + + return ` style=${isJSX ? `{{${trimmedStyles}}}` : `"${trimmedStyles}"`}`; +}; + +export const formatDataAttribute = (label: string, value?: string) => + ` data-${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; + +export const formatTwigAttribute = (label: string, value?: string) => + ['.', '_'].includes(label.charAt(0)) ? '' : (` ${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`); + +export const formatClassAttribute = ( + classes: string[], + isJSX: boolean, +): string => + classes.length === 0 ? "" : ` ${getClassLabel(isJSX)}="${classes.join(" ")}"`; diff --git a/packages/backend/src/common/commonPadding.ts b/packages/backend/src/common/commonPadding.ts index 017f56ed..b0ef91f0 100644 --- a/packages/backend/src/common/commonPadding.ts +++ b/packages/backend/src/common/commonPadding.ts @@ -1,18 +1,7 @@ -type PaddingType = - | { all: number } - | { - horizontal: number; - vertical: number; - } - | { - left: number; - right: number; - top: number; - bottom: number; - }; +import { PaddingType } from "types"; export const commonPadding = ( - node: InferredAutoLayoutResult + node: InferredAutoLayoutResult, ): PaddingType | null => { if ("layoutMode" in node && node.layoutMode !== "NONE") { const paddingLeft = parseFloat((node.paddingLeft ?? 0).toFixed(2)); diff --git a/packages/backend/src/common/commonPosition.ts b/packages/backend/src/common/commonPosition.ts index 5d421530..2f1df00f 100644 --- a/packages/backend/src/common/commonPosition.ts +++ b/packages/backend/src/common/commonPosition.ts @@ -1,112 +1,21 @@ -import { parentCoordinates } from "./parentCoordinates"; - -type position = - | "" - | "Absolute" - | "TopStart" - | "TopCenter" - | "TopEnd" - | "CenterStart" - | "Center" - | "CenterEnd" - | "BottomStart" - | "BottomCenter" - | "BottomEnd"; - -export const commonPosition = ( - node: SceneNode & DimensionAndPositionMixin -): position => { - // 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"; - } +import { HTMLSettings, TailwindSettings } from "types"; - 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"; +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 "Absolute"; -}; + return { x: node.x, y: node.y }; + } -export const getCommonPositionValue = ( - node: SceneNode -): { x: number; y: number } => { if (node.parent && node.parent.type === "GROUP") { return { x: node.x - node.parent.x, @@ -120,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 3ac4114c..5f8070eb 100644 --- a/packages/backend/src/common/commonRadius.ts +++ b/packages/backend/src/common/commonRadius.ts @@ -1,13 +1,25 @@ -type RadiusType = - | { all: number } - | { - topLeft: number; - topRight: number; - bottomRight: number; - bottomLeft: number; +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, }; + } -export const getCommonRadius = (node: SceneNode): RadiusType => { if ( "cornerRadius" in node && node.cornerRadius !== figma.mixed && diff --git a/packages/backend/src/common/commonStroke.ts b/packages/backend/src/common/commonStroke.ts index cba99bfe..d82e1380 100644 --- a/packages/backend/src/common/commonStroke.ts +++ b/packages/backend/src/common/commonStroke.ts @@ -1,13 +1,9 @@ -type BorderSideType = - | { all: number } - | { - left: number; - top: number; - right: number; - bottom: number; - }; +import { BorderSide } from "types"; -export const commonStroke = (node: SceneNode, divideBy: number = 1): BorderSideType | null => { +export const commonStroke = ( + node: SceneNode, + divideBy: number = 1, +): BorderSide | null => { if (!("strokes" in node) || !node.strokes || node.strokes.length === 0) { return null; } diff --git a/packages/backend/src/common/commonTextHeightSpacing.ts b/packages/backend/src/common/commonTextHeightSpacing.ts index ed69edc0..aa3314ba 100644 --- a/packages/backend/src/common/commonTextHeightSpacing.ts +++ b/packages/backend/src/common/commonTextHeightSpacing.ts @@ -1,6 +1,6 @@ export const commonLineHeight = ( lineHeight: LineHeight, - fontSize: number + fontSize: number, ): number => { switch (lineHeight.unit) { case "AUTO": @@ -14,7 +14,7 @@ export const commonLineHeight = ( export const commonLetterSpacing = ( letterSpacing: LetterSpacing, - fontSize: number + fontSize: number, ): number => { switch (letterSpacing.unit) { case "PIXELS": diff --git a/packages/backend/src/common/convertFontWeight.ts b/packages/backend/src/common/convertFontWeight.ts index ee6ba04b..3231dd51 100644 --- a/packages/backend/src/common/convertFontWeight.ts +++ b/packages/backend/src/common/convertFontWeight.ts @@ -1,19 +1,9 @@ +import { FontWeightNumber } from "types"; + // Convert generic named weights to numbers, which is the way tailwind understands -export const convertFontWeight = ( - weight: number -): - | "100" - | "200" - | "300" - | "400" - | "500" - | "600" - | "700" - | "800" - | "900" - | null => { +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"; @@ -36,6 +26,6 @@ export const convertFontWeight = ( case "black": return "900"; default: - return null; + return "400"; } }; diff --git a/packages/backend/src/common/curry.ts b/packages/backend/src/common/curry.ts new file mode 100644 index 00000000..dd9045bb --- /dev/null +++ b/packages/backend/src/common/curry.ts @@ -0,0 +1,13 @@ +export function curry any>( + fn: T, + arity = fn.length, +): any { + return function curried(...args: any[]): any { + if (args.length >= arity) { + return fn(...args); + } + return function (...moreArgs: any[]) { + return curried(...args, ...moreArgs); + }; + }; +} 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/indentString.ts b/packages/backend/src/common/indentString.ts index 5a1ff999..db5cf188 100644 --- a/packages/backend/src/common/indentString.ts +++ b/packages/backend/src/common/indentString.ts @@ -11,7 +11,7 @@ export const indentString = (str: string, indentLevel: number = 2): string => { export const indentStringFlutter = ( str: string, - indentLevel: number = 2 + indentLevel: number = 2, ): string => { // const options = { // includeEmptyLines: false, 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 new file mode 100644 index 00000000..7e4eb6f4 --- /dev/null +++ b/packages/backend/src/common/nodeVisibility.ts @@ -0,0 +1,2 @@ +export const getVisibleNodes = (nodes: readonly SceneNode[]) => + nodes.filter((d) => d.visible ?? true); diff --git a/packages/backend/src/common/nodeWidthHeight.ts b/packages/backend/src/common/nodeWidthHeight.ts index 79ff2177..670a86b5 100644 --- a/packages/backend/src/common/nodeWidthHeight.ts +++ b/packages/backend/src/common/nodeWidthHeight.ts @@ -1,57 +1,23 @@ -type SizeResult = { - readonly width: "fill" | number | null; - readonly height: "fill" | number | null; -}; - -export const nodeSize = ( - node: SceneNode, - optimizeLayout: boolean -): SizeResult => { - const hasLayout = - "layoutAlign" in node && node.parent && "layoutMode" in node.parent; +import { Size } from "types"; - if (!hasLayout) { - return { width: node.width, height: node.height }; - } +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; - 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 cd305d49..d18f2f71 100644 --- a/packages/backend/src/common/numToAutoFixed.ts +++ b/packages/backend/src/common/numToAutoFixed.ts @@ -1,14 +1,18 @@ 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, - defaultProperty: any + defaultProperty: any, ): string => { if (propertyValue === defaultProperty) { return ""; @@ -18,7 +22,7 @@ export const printPropertyIfNotDefault = ( export const skipDefaultProperty = ( propertyValue: T, - defaultProperty: T + defaultProperty: T, ): T | string => { if (propertyValue === defaultProperty) { return ""; @@ -28,7 +32,7 @@ export const skipDefaultProperty = ( export const propertyIfNotDefault = ( propertyValue: any, - defaultProperty: any + defaultProperty: any, ): string => { if (propertyValue === defaultProperty) { return ""; @@ -36,28 +40,31 @@ export const propertyIfNotDefault = ( return propertyValue; }; -type PropertyValueType = number | string | string[]; - export const generateWidgetCode = ( className: string, - properties: Record, - positionedValues?: string[] + properties: Record, + positionedValues?: string[], ): string => { const propertiesArray = Object.entries(properties) - .filter(([, value]) => value !== "") + .filter(([, value]) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== ""; + }) .map(([key, value]) => { if (Array.isArray(value)) { 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(", "); @@ -80,7 +87,7 @@ function escapeRegExp(string: string) { export const replaceAllUtil = (str: string, find: string, replace: string) => str.replace(new RegExp(escapeRegExp(find), "g"), replace); -export function className(name: string): string { +export function stringToClassName(name: string): string { const words = name.split(/[^a-zA-Z0-9]+/); const camelCaseWords = words.map((word, index) => { if (index === 0) { diff --git a/packages/backend/src/common/parentCoordinates.ts b/packages/backend/src/common/parentCoordinates.ts index f0fa315e..209897db 100644 --- a/packages/backend/src/common/parentCoordinates.ts +++ b/packages/backend/src/common/parentCoordinates.ts @@ -6,7 +6,7 @@ * Input is expected to be node.parent. */ export const parentCoordinates = ( - node: DimensionAndPositionMixin + node: DimensionAndPositionMixin, ): [number, number] => { const parentX = "layoutMode" in node ? 0 : node.x; const parentY = "layoutMode" in node ? 0 : node.y; diff --git a/packages/backend/src/common/parseJSX.ts b/packages/backend/src/common/parseJSX.ts index 71528462..5445679d 100644 --- a/packages/backend/src/common/parseJSX.ts +++ b/packages/backend/src/common/parseJSX.ts @@ -1,9 +1,10 @@ -import { sliceNum } from "./numToAutoFixed"; +import { encode } from "html-entities"; +import { numberToFixedString } from "./numToAutoFixed"; export const formatWithJSX = ( property: string, isJsx: boolean, - value: number | string + value: number | string, ): string => { // convert font-size to fontSize. const jsx_property = property @@ -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}'`; @@ -26,7 +27,7 @@ export const formatWithJSX = ( export const formatMultipleJSXArray = ( styles: Record, - isJsx: boolean + isJsx: boolean, ): string[] => Object.entries(styles) .filter(([key, value]) => value !== "") @@ -34,9 +35,16 @@ export const formatMultipleJSXArray = ( export const formatMultipleJSX = ( styles: Record, - isJsx: boolean + isJsx: boolean, ): string => Object.entries(styles) .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 5da175ae..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"] + 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 new file mode 100644 index 00000000..e5c9c80f --- /dev/null +++ b/packages/backend/src/common/retrieveUI/convertToCode.ts @@ -0,0 +1,25 @@ +import { PluginSettings } from "types"; +import { composeMain } from "../../compose/composeMain"; +import { flutterMain } from "../../flutter/flutterMain"; +import { htmlMain } from "../../html/htmlMain"; +import { swiftuiMain } from "../../swiftui/swiftuiMain"; +import { tailwindMain } from "../../tailwind/tailwindMain"; + +export const convertToCode = async ( + nodes: SceneNode[], + settings: PluginSettings, +) => { + switch (settings.framework) { + case "Tailwind": + return await tailwindMain(nodes, settings); + case "Flutter": + return await flutterMain(nodes, settings); + case "SwiftUI": + return await swiftuiMain(nodes, settings); + case "Compose": + return composeMain(nodes, settings); + case "HTML": + default: + return (await htmlMain(nodes, settings)).html; + } +}; diff --git a/packages/backend/src/common/retrieveUI/retrieveColors.ts b/packages/backend/src/common/retrieveUI/retrieveColors.ts index 2fb9d7d2..d071fe04 100644 --- a/packages/backend/src/common/retrieveUI/retrieveColors.ts +++ b/packages/backend/src/common/retrieveUI/retrieveColors.ts @@ -4,116 +4,149 @@ import { swiftuiGradient, } from "../../swiftui/builderImpl/swiftuiColor"; import { - tailwindColors, + tailwindColor, tailwindGradient, - tailwindNearestColor, - tailwindSolidColor, } from "../../tailwind/builderImpl/tailwindColor"; import { flutterColor, flutterGradient, } from "../../flutter/builderImpl/flutterColor"; import { - htmlColor, + htmlColorFromFill, htmlGradientFromFills, } from "../../html/builderImpl/htmlColor"; import { calculateContrastRatio } from "./commonUI"; -import { FrameworkTypes } from "../../code"; - -export type ExportSolidColor = { - hex: string; - colorName: string; - exportValue: string; - contrastWhite: number; - contrastBlack: number; -}; +import { + LinearGradientConversion, + SolidColorConversion, + Framework, +} from "types"; +import { processColorVariables } from "../../altNodes/jsonNodeConversion"; -export const retrieveGenericSolidUIColors = ( - framework: FrameworkTypes -): Array => { +export const retrieveGenericSolidUIColors = async ( + framework: Framework, +): Promise> => { const selectionColors = figma.getSelectionColors(); if (!selectionColors || selectionColors.paints.length === 0) return []; - const colorStr: Array = []; - selectionColors.paints.forEach((paint) => { - const fill = convertSolidColor(paint, framework); - if (fill) { - colorStr.push(fill); - } - }); + const colors: Array = []; + + // 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 colorStr.sort((a, b) => a.hex.localeCompare(b.hex)); + return colors.sort((a, b) => a.hex.localeCompare(b.hex)); }; -const convertSolidColor = ( +const convertSolidColor = async ( fill: Paint, - framework: FrameworkTypes -): ExportSolidColor | null => { + framework: Framework, +): Promise => { const black = { r: 0, g: 0, b: 0 }; const white = { r: 1, g: 1, b: 1 }; if (fill.type !== "SOLID") return null; const opacity = fill.opacity ?? 1.0; - let exported = ""; - let colorName = ""; - let contrastBlack = calculateContrastRatio(fill.color, black); - let contrastWhite = calculateContrastRatio(fill.color, white); + const output = { + hex: rgbTo6hex(fill.color).toUpperCase(), + colorName: "", + exportValue: "", + contrastBlack: calculateContrastRatio(fill.color, black), + contrastWhite: calculateContrastRatio(fill.color, white), + }; if (framework === "Flutter") { - exported = flutterColor(fill.color, opacity); + output.exportValue = flutterColor(fill.color, opacity); } else if (framework === "HTML") { - exported = htmlColor(fill.color, opacity); + output.exportValue = htmlColorFromFill(fill as any); } else if (framework === "Tailwind") { - const kind = "solid"; - const hex = rgbTo6hex(fill.color); - const hexNearestColor = tailwindNearestColor(hex); - exported = tailwindSolidColor(fill.color, fill.opacity, kind); - colorName = tailwindColors[hexNearestColor]; + // Pass true to use CSS variable syntax for variables + output.exportValue = tailwindColor(fill as any, true).exportValue; } else if (framework === "SwiftUI") { - exported = swiftuiColor(fill.color, opacity); + output.exportValue = swiftuiColor(fill.color, opacity); } - return { - hex: rgbTo6hex(fill.color).toUpperCase(), - colorName, - exportValue: exported, - contrastBlack, - contrastWhite, - }; + return output; }; -type ExportLinearGradient = { cssPreview: string; exportValue: string }; - -export const retrieveGenericLinearGradients = ( - framework: FrameworkTypes -): Array => { +export const retrieveGenericLinearGradients = async ( + framework: Framework, +): Promise> => { const selectionColors = figma.getSelectionColors(); - const colorStr: Array = []; - - selectionColors?.paints.forEach((paint) => { - if (paint.type === "GRADIENT_LINEAR") { - let exported = ""; - switch (framework) { - case "Flutter": - exported = flutterGradient(paint); - break; - case "HTML": - exported = htmlGradientFromFills([paint]); - break; - case "Tailwind": - exported = tailwindGradient(paint); - break; - case "SwiftUI": - exported = swiftuiGradient(paint); - break; + const colorStr: Array = []; + + 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: exported, - }); - } - }); + }), + ); return colorStr; }; diff --git a/packages/backend/src/common/retrieveUI/retrieveTexts.ts b/packages/backend/src/common/retrieveUI/retrieveTexts.ts deleted file mode 100644 index cf960fda..00000000 --- a/packages/backend/src/common/retrieveUI/retrieveTexts.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { retrieveTopFill } from "../retrieveFill"; -import { swiftuiMain } from "../../swiftui/swiftuiMain"; -import { tailwindMain } from "../../tailwind/tailwindMain"; -import { htmlMain } from "../../html/htmlMain"; -import { flutterMain } from "../../flutter/flutterMain"; -import { calculateContrastRatio, deepFlatten } from "./commonUI"; - -type exportFramework = "flutter" | "swiftui" | "html" | "tailwind"; - -export const retrieveGenericUIText = ( - sceneNode: Array, - framework: exportFramework -): Array => { - // convert to Node and then flatten it. Conversion is necessary because of [tailwindText] - const selectedText = deepFlatten(sceneNode); - - const textStr: Array = []; - - selectedText.forEach((node) => { - if (node.type === "TEXT") { - let code = ""; - if (framework === "flutter") { - code = flutterMain([node]); - } else if (framework === "html") { - code = htmlMain([node]); - } else if (framework === "tailwind") { - code = tailwindMain([node]); - } else if (framework === "swiftui") { - code = swiftuiMain([node]); - } - - let style; - if (framework === "tailwind") { - const [builder] = htmlBuilder(node, false); - style = builder.build(); - } else { - const [builder] = htmlBuilder(node, false, true); - style = builder.build(); - } - - const black = { - r: 0, - g: 0, - b: 0, - }; - - let contrastBlack = 21; - - const fill = retrieveTopFill(node.fills); - - if (fill && fill.type === "SOLID") { - contrastBlack = calculateContrastRatio(fill.color, black); - } - - textStr.push({ - name: node.name, - style, - code, - contrastBlack, - }); - } - }); - - // retrieve only unique texts (attr + name) - // from https://stackoverflow.com/a/18923480/4418073 - const unique: Record = {}; - const distinct: Array = []; - textStr.forEach((x) => { - if (!unique[x.code + x.name]) { - distinct.push(x); - unique[x.code + x.name] = true; - } - }); - - return distinct; -}; - -type namedText = { - name: string; - code: string; - style: string; - contrastBlack: number; -}; 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 addc67bf..8e17f79a 100644 --- a/packages/backend/src/flutter/builderImpl/flutterAutoLayout.ts +++ b/packages/backend/src/flutter/builderImpl/flutterAutoLayout.ts @@ -1,7 +1,8 @@ export const getMainAxisAlignment = ( - node: InferredAutoLayoutResult + node: InferredAutoLayoutResult, ): string => { switch (node.primaryAxisAlignItems) { + case undefined: case "MIN": return "MainAxisAlignment.start"; case "CENTER": @@ -14,9 +15,10 @@ export const getMainAxisAlignment = ( }; export const getCrossAxisAlignment = ( - node: InferredAutoLayoutResult + node: InferredAutoLayoutResult, ): string => { switch (node.counterAxisAlignItems) { + case undefined: case "MIN": return "CrossAxisAlignment.start"; case "CENTER": @@ -28,9 +30,43 @@ 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 + autoLayout: InferredAutoLayoutResult, ): string => node.parent && "layoutMode" in node.parent && diff --git a/packages/backend/src/flutter/builderImpl/flutterBlend.ts b/packages/backend/src/flutter/builderImpl/flutterBlend.ts index 0deb5db9..faa8b7d1 100644 --- a/packages/backend/src/flutter/builderImpl/flutterBlend.ts +++ b/packages/backend/src/flutter/builderImpl/flutterBlend.ts @@ -1,15 +1,19 @@ -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 */ export const flutterOpacity = ( node: MinimalBlendMixin, - child: string + child: string, ): 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 3371a76d..f3e22b1d 100644 --- a/packages/backend/src/flutter/builderImpl/flutterBorder.ts +++ b/packages/backend/src/flutter/builderImpl/flutterBorder.ts @@ -17,13 +17,13 @@ export const flutterBorder = (node: SceneNode): string => { } const color = skipDefaultProperty( - flutterColorFromFills(node.strokes), - "Colors.black" + flutterColorFromFills(node, "strokes"), + "Colors.black", ); const strokeAlign = skipDefaultProperty( getStrokeAlign(node, 2), - "BorderSide.strokeAlignInside" + "BorderSide.strokeAlignInside", ); if ("all" in stroke) { @@ -49,7 +49,7 @@ export const flutterBorder = (node: SceneNode): string => { const generateBorderSideCode = ( width: number, strokeAlign: string, - color: string + color: string, ): string => { return generateWidgetCode("BorderSide", { width: skipDefaultProperty(width, 0), diff --git a/packages/backend/src/flutter/builderImpl/flutterColor.ts b/packages/backend/src/flutter/builderImpl/flutterColor.ts index 01cd4827..a4d12377 100644 --- a/packages/backend/src/flutter/builderImpl/flutterColor.ts +++ b/packages/backend/src/flutter/builderImpl/flutterColor.ts @@ -1,18 +1,44 @@ -import { rgbTo8hex, gradientAngle } from "../../common/color"; -import { generateWidgetCode, sliceNum } from "../../common/numToAutoFixed"; +import { StarNode } from "./../../api_types"; +import { rgbTo8hex } from "../../common/color"; +import { addWarning } from "../../common/commonConversionWarnings"; +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" || @@ -20,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" || @@ -50,10 +89,9 @@ 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), }); }; @@ -61,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"; } @@ -82,106 +120,131 @@ export const flutterGradient = (fill: GradientPaint): string => { case "GRADIENT_ANGULAR": return flutterAngularGradient(fill); default: - // Diamond gradient is unsupported. + addWarning("Diamond dradients are not supported in Flutter"); return ""; } }; -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 b4aa25e9..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(0)" + `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 71fa028f..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. @@ -17,13 +20,15 @@ export const flutterShadow = (node: SceneNode): string => { boxShadow += generateWidgetCode("BoxShadow", { color: `Color(0x${rgbTo8hex( effect.color, - effect.color.a + effect.color.a, ).toUpperCase()})`, - blurRadius: sliceNum(effect.radius), - offset: `Offset(${sliceNum(effect.offset.x)}, ${sliceNum( - effect.offset.y + 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 ba6c2dc5..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 066126bb..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,22 +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) { - 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.width = skipDefaultProperty(width, "0"); + properties.height = skipDefaultProperty(height, "0"); + properties.padding = propPadding; + properties.clipBehavior = clipBehavior; + + const parsedDecoration = skipDefaultProperty( + propBoxDecoration, + "BoxDecoration()", + ); + 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", { @@ -66,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", { @@ -76,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") { @@ -94,7 +130,7 @@ const getDecoration = (node: SceneNode): string => { } else if ("strokeWeight" in node && node.strokeWeight !== figma.mixed) { shapeDecorationBorder = skipDefaultProperty( generateRoundedRectangleBorder(node), - "RoundedRectangleBorder()" + "RoundedRectangleBorder()", ); } @@ -115,7 +151,7 @@ const getDecoration = (node: SceneNode): string => { }; const generateRoundedRectangleBorder = ( - node: SceneNode & MinimalStrokesMixin + node: SceneNode & MinimalStrokesMixin, ): string => { return generateWidgetCode("RoundedRectangleBorder", { side: generateBorderSideCode(node), @@ -124,7 +160,7 @@ const generateRoundedRectangleBorder = ( }; const generateBorderSideCode = ( - node: SceneNode & MinimalStrokesMixin + node: SceneNode & MinimalStrokesMixin, ): string => { const strokeWidth = getSingleStrokeWidth(node); @@ -133,14 +169,14 @@ const generateBorderSideCode = ( width: skipDefaultProperty(strokeWidth, 0), strokeAlign: skipDefaultProperty( getStrokeAlign(node, strokeWidth), - "BorderSide.strokeAlignInside" + "BorderSide.strokeAlignInside", ), color: skipDefaultProperty( - flutterColorFromFills(node.strokes), - "Colors.black" + flutterColorFromFills(node, "strokes"), + "Colors.black", ), }), - "BorderSide()" + "BorderSide()", ); }; @@ -177,18 +213,18 @@ 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), }); }; export const getStrokeAlign = ( node: MinimalStrokesMixin, - strokeWeight: number + strokeWeight: number, ): string => { if (strokeWeight === 0) { return ""; @@ -216,7 +252,7 @@ const generatePolygonBorder = (node: PolygonNode): string => { return generateWidgetCode("StarBorder.polygon", { side: generateBorderSideCode(node), - sides: sliceNum(points), + sides: numberToFixedString(points), borderRadius: generateBorderRadius(node), }); }; @@ -227,25 +263,25 @@ 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(0)" + `Radius.circular(${numberToFixedString(radius.topLeft)})`, + "Radius.circular(0)", ), topRight: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.topRight)})`, - "Radius.circular(0)" + `Radius.circular(${numberToFixedString(radius.topRight)})`, + "Radius.circular(0)", ), bottomLeft: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.bottomLeft)})`, - "Radius.circular(0)" + `Radius.circular(${numberToFixedString(radius.bottomLeft)})`, + "Radius.circular(0)", ), bottomRight: skipDefaultProperty( - `Radius.circular(${sliceNum(radius.bottomRight)})`, - "Radius.circular(0)" + `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 0d471e63..26a472bd 100644 --- a/packages/backend/src/flutter/flutterMain.ts +++ b/packages/backend/src/flutter/flutterMain.ts @@ -1,14 +1,21 @@ -import { className, generateWidgetCode } from "../common/numToAutoFixed"; +import { + stringToClassName, + generateWidgetCode, +} from "../common/numToAutoFixed"; import { retrieveTopFill } from "../common/retrieveFill"; import { FlutterDefaultBuilder } from "./flutterDefaultBuilder"; import { FlutterTextBuilder } from "./flutterTextBuilder"; import { indentString } from "../common/indentString"; -import { PluginSettings } from "../code"; + 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[]; @@ -56,7 +63,7 @@ const getStatelessTemplate = (name: string, injectCode: string): string => export const flutterMain = ( sceneNode: ReadonlyArray, - settings: PluginSettings + settings: PluginSettings, ): string => { localSettings = settings; previousExecutionCache = []; @@ -66,27 +73,30 @@ export const flutterMain = ( case "snippet": return result; case "stateless": - result = generateWidgetCode("Column", { children: [result] }); - return getStatelessTemplate(className(sceneNode[0].name), result); + if (!result.startsWith("Column")) { + result = generateWidgetCode("Column", { children: [result] }); + } + return getStatelessTemplate(stringToClassName(sceneNode[0].name), result); case "fullApp": - result = generateWidgetCode("Column", { children: [result] }); - return getFullAppTemplate(className(sceneNode[0].name), result); + if (!result.startsWith("Column")) { + result = generateWidgetCode("Column", { children: [result] }); + } + return getFullAppTemplate(stringToClassName(sceneNode[0].name), result); } return result; }; const flutterWidgetGenerator = ( - sceneNode: ReadonlyArray + sceneNode: ReadonlyArray, ): string => { 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": @@ -101,6 +111,7 @@ const flutterWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(flutterFrame(node)); break; case "SECTION": @@ -109,38 +120,33 @@ const flutterWidgetGenerator = ( case "TEXT": comp.push(flutterText(node)); break; + 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"); }; const flutterGroup = (node: GroupNode): string => { + const widget = flutterWidgetGenerator(node.children); return flutterContainer( node, generateWidgetCode("Stack", { - children: [flutterWidgetGenerator(node.children)], - }) + children: widget ? [widget] : [], + }), ); }; const flutterContainer = (node: SceneNode, child: string): string => { let propChild = ""; - let image = ""; if ("fills" in node && retrieveTopFill(node.fills)?.type === "IMAGE") { - image = `Image.network("https://via.placeholder.com/${node.width.toFixed( - 0 - )}x${node.height.toFixed(0)}")`; + addWarning("Image fills are replaced with placeholders"); } if (child.length > 0) { @@ -148,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; }; @@ -159,75 +165,101 @@ 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 + 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", { - children: [children], - }) + children: children !== "" ? [children] : [], + }), ); } }; -const makeRowColumn = ( +const makeRowColumnWrap = ( autoLayout: InferredAutoLayoutResult, - children: string + 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 - : null ?? 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 dc4ec83c..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", @@ -40,7 +46,7 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { ...basicTextStyle, style: segments[0].style, }, - [`'${segments[0].text}'`] + [`'${segments[0].text}'`], ); } else { this.child = generateWidgetCode("Text.rich", basicTextStyle, [ @@ -49,7 +55,7 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { generateWidgetCode("TextSpan", { text: `'${segment.text}'`, style: segment.style, - }) + }), ), }), ]); @@ -58,26 +64,31 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { return this; } - getTextSegments(id: string): { style: string; text: string }[] { - const segments = globalTextStyleSegments[id]; + getTextSegments(node: TextNode): { + style: string; + text: string; + openTypeFeatures: { [key: string]: boolean }; + }[] { + 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}`; const lineHeight = this.lineHeight(segment.lineHeight, segment.fontSize); const letterSpacing = this.letterSpacing( segment.letterSpacing, - segment.fontSize + segment.fontSize, ); - const style = generateWidgetCode("TextStyle", { + const styleProperties: { [key: string]: string } = { color: color, fontSize: fontSize, fontStyle: fontStyle, @@ -85,12 +96,30 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { fontWeight: fontWeight, textDecoration: skipDefaultProperty( this.getFlutterTextDecoration(segment.textDecoration), - "TextDecoration.none" + "TextDecoration.none", ), // textTransform: textTransform, - height: lineHeight/fontSize, + height: lineHeight, letterSpacing: letterSpacing, - }); + }; + + if ( + (segment.openTypeFeatures as unknown as { SUBS: boolean }).SUBS === true + ) { + styleProperties.fontFeatures = `[FontFeature.enable("subs")]`; + } else if ( + (segment.openTypeFeatures as unknown as { SUPS: boolean }).SUPS === true + ) { + 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; if (segment.textCase === "LOWER") { @@ -102,6 +131,7 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { return { style: style, text: parseTextAsCode(text).replace(/\$/g, "\\$"), + openTypeFeatures: segment.openTypeFeatures, }; }); } @@ -122,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; } @@ -148,39 +227,57 @@ export class FlutterTextBuilder extends FlutterDefaultBuilder { } return ""; }; -} -export const wrapTextAutoResize = (node: TextNode, child: string): string => { - const { width, height, isExpanded } = flutterSize(node); - 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, +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; - case "NONE": - case "TRUNCATE": - result = generateWidgetCode("SizedBox", { - width: width, - height: height, - 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 afdb522d..2fb246c3 100644 --- a/packages/backend/src/html/builderImpl/htmlAutoLayout.ts +++ b/packages/backend/src/html/builderImpl/htmlAutoLayout.ts @@ -1,3 +1,4 @@ +import { HTMLSettings } from "types"; import { formatMultipleJSXArray } from "../../common/parseJSX"; const getFlexDirection = (node: InferredAutoLayoutResult): string => @@ -5,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": @@ -18,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": @@ -34,9 +37,30 @@ 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 + autoLayout: InferredAutoLayoutResult, ): string => node.parent && "layoutMode" in node.parent && @@ -45,17 +69,18 @@ const getFlex = ( : "inline-flex"; export const htmlAutoLayoutProps = ( - node: SceneNode, - autoLayout: InferredAutoLayoutResult, - isJsx: boolean + 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), }, - isJsx + settings.htmlGenerationMode === "jsx", ); diff --git a/packages/backend/src/html/builderImpl/htmlBlend.ts b/packages/backend/src/html/builderImpl/htmlBlend.ts index 09de5d7f..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/ @@ -9,21 +10,24 @@ import { formatWithJSX } from "../../common/parseJSX"; */ export const htmlOpacity = ( node: MinimalBlendMixin, - isJsx: boolean + isJsx: boolean, ): string => { // [when testing] node.opacity can be undefined 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 ""; }; -export const htmlBlendMode = (node: MinimalBlendMixin, isJsx: boolean): string => { +export const htmlBlendMode = ( + node: MinimalBlendMixin, + isJsx: boolean, +): string => { if (node.blendMode !== "NORMAL" && node.blendMode !== "PASS_THROUGH") { let blendMode = ""; switch (node.blendMode) { @@ -87,7 +91,7 @@ export const htmlBlendMode = (node: MinimalBlendMixin, isJsx: boolean): string = */ export const htmlVisibility = ( node: SceneNodeMixin, - isJsx: boolean + isJsx: boolean, ): string => { // [when testing] node.visible can be undefined @@ -105,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 33184f87..3ba1094d 100644 --- a/packages/backend/src/html/builderImpl/htmlBorderRadius.ts +++ b/packages/backend/src/html/builderImpl/htmlBorderRadius.ts @@ -2,78 +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 - ) { - // if ( - // node.children.some( - // (child) => - // "layoutPositioning" in child && node.layoutPositioning === "AUTO" - // ) - // ) { - // if (singleCorner) { - // comp.push( - // formatWithJSX( - // "clip-path", - // isJsx, - // `inset(0px round ${singleCorner}px)` - // ) - // ); - // } else if (cornerValues.filter((d) => d > 0).length > 0) { - // const insetValues = cornerValues.map((value) => `${value}px`).join(" "); - // comp.push( - // formatWithJSX("clip-path", isJsx, `inset(0px round ${insetValues})`) - // ); - // } - // } else { - 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 8416443c..a941b7bc 100644 --- a/packages/backend/src/html/builderImpl/htmlColor.ts +++ b/packages/backend/src/html/builderImpl/htmlColor.ts @@ -1,166 +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"] -): 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/htmlPadding.ts b/packages/backend/src/html/builderImpl/htmlPadding.ts index 6d2defd7..d05d133e 100644 --- a/packages/backend/src/html/builderImpl/htmlPadding.ts +++ b/packages/backend/src/html/builderImpl/htmlPadding.ts @@ -3,7 +3,7 @@ import { formatWithJSX } from "../../common/parseJSX"; export const htmlPadding = ( node: InferredAutoLayoutResult, - isJsx: boolean + isJsx: boolean, ): string[] => { const padding = commonPadding(node); if (padding === null) { diff --git a/packages/backend/src/html/builderImpl/htmlShadow.ts b/packages/backend/src/html/builderImpl/htmlShadow.ts index d5ae6cc0..9f829bdd 100644 --- a/packages/backend/src/html/builderImpl/htmlShadow.ts +++ b/packages/backend/src/html/builderImpl/htmlShadow.ts @@ -12,33 +12,38 @@ export const htmlShadow = (node: BlendMixin): string => { (d.type === "DROP_SHADOW" || d.type === "INNER_SHADOW" || d.type === "LAYER_BLUR") && - d.visible + d.visible, ); // 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 32bea81a..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 39475821..e2ff6a3c 100644 --- a/packages/backend/src/html/htmlDefaultBuilder.ts +++ b/packages/backend/src/html/htmlDefaultBuilder.ts @@ -7,9 +7,8 @@ import { htmlBlendMode, } from "./builderImpl/htmlBlend"; import { - htmlColor, + buildBackgroundValues, htmlColorFromFills, - htmlGradientFromFills, } from "./builderImpl/htmlColor"; import { htmlPadding } from "./builderImpl/htmlPadding"; import { htmlSizePartial } from "./builderImpl/htmlSize"; @@ -18,43 +17,125 @@ import { commonIsAbsolutePosition, getCommonPositionValue, } from "../common/commonPosition"; -import { className, sliceNum } from "../common/numToAutoFixed"; +import { + numberToFixedString, + stringToClassName, +} from "../common/numToAutoFixed"; import { commonStroke } from "../common/commonStroke"; +import { + formatClassAttribute, + formatDataAttribute, + formatStyleAttribute, +} from "../common/commonFormatAttributes"; +import { HTMLSettings } from "types"; +import { + cssCollection, + generateUniqueClassName, + stylesToCSS, + getComponentName, +} from "./htmlMain"; export class HtmlDefaultBuilder { styles: Array; - isJSX: boolean; - visible: boolean; - name: string = ""; + 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.htmlGenerationMode === "jsx"; + } + + get exportCSS() { + return this.settings.htmlGenerationMode === "svelte"; + } - constructor(node: SceneNode, showLayerName: boolean, optIsJSX: boolean) { - this.isJSX = optIsJSX; + 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) { + this.node = node; + this.settings = settings; this.styles = []; - this.visible = node.visible; - if (showLayerName) { - this.name = className(node.name); + 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( - node: SceneNode & LayoutMixin & MinimalBlendMixin, - optimizeLayout: boolean - ): this { - this.size(node, optimizeLayout); - this.autoLayoutPadding(node, optimizeLayout); - this.position(node, optimizeLayout); - this.blend(node); + commonPositionStyles(): this { + this.size(); + this.autoLayoutPadding(); + this.position(); + this.blend(); return this; } - commonShapeStyles(node: GeometryMixin & SceneNode): this { - this.applyFillsToStyle( - node.fills, - node.type === "TEXT" ? "text" : "background" - ); - this.shadow(node); - this.border(node); - this.blur(node); + commonShapeStyles(): this { + if ("fills" in this.node) { + this.applyFillsToStyle( + this.node.fills, + this.node.type === "TEXT" ? "text" : "background", + ); + } + this.shadow(); + this.border(this.settings); + this.blur(); return this; } @@ -62,17 +143,19 @@ export class HtmlDefaultBuilder { this.styles.push(...newStyles.filter((style) => style)); }; - blend(node: SceneNode & LayoutMixin & MinimalBlendMixin): this { + blend(): this { + const { node, isJSX } = this; this.addStyles( - htmlVisibility(node, this.isJSX), - ...htmlRotation(node, this.isJSX), - htmlOpacity(node, this.isJSX), - htmlBlendMode(node, this.isJSX) + htmlVisibility(node, isJSX), + ...htmlRotation(node as LayoutMixin, isJSX), + htmlOpacity(node as MinimalBlendMixin, isJSX), + htmlBlendMode(node as MinimalBlendMixin, isJSX), ); return this; } - border(node: GeometryMixin & SceneNode): this { + border(settings: HTMLSettings): this { + const { node } = this; this.addStyles(...htmlBorderRadius(node, this.isJSX)); const commonBorder = commonStroke(node); @@ -80,28 +163,70 @@ export class HtmlDefaultBuilder { return this; } - const color = htmlColorFromFills(node.strokes); - const borderStyle = node.dashPattern.length > 0 ? "dotted" : "solid"; + const strokes = ("strokes" in node && node.strokes) || undefined; + 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( "border-left", this.isJSX, - consolidateBorders(commonBorder.left) - ) + consolidateBorders(commonBorder.left), + ), ); } if (commonBorder.top !== 0) { @@ -109,8 +234,8 @@ export class HtmlDefaultBuilder { formatWithJSX( "border-top", this.isJSX, - consolidateBorders(commonBorder.top) - ) + consolidateBorders(commonBorder.top), + ), ); } if (commonBorder.right !== 0) { @@ -118,8 +243,8 @@ export class HtmlDefaultBuilder { formatWithJSX( "border-right", this.isJSX, - consolidateBorders(commonBorder.right) - ) + consolidateBorders(commonBorder.right), + ), ); } if (commonBorder.bottom !== 0) { @@ -127,31 +252,28 @@ export class HtmlDefaultBuilder { formatWithJSX( "border-bottom", this.isJSX, - consolidateBorders(commonBorder.bottom) - ) + consolidateBorders(commonBorder.bottom), + ), ); } } return this; } - position(node: SceneNode, optimizeLayout: boolean): this { - if (commonIsAbsolutePosition(node, optimizeLayout)) { - const { x, y } = getCommonPositionValue(node); + position(): this { + const { node, isJSX } = this; + const isAbsolutePosition = commonIsAbsolutePosition(node); + if (isAbsolutePosition) { + const { x, y } = getCommonPositionValue(node, this.settings); this.addStyles( - formatWithJSX("left", this.isJSX, x), - formatWithJSX("top", this.isJSX, y), - formatWithJSX("position", this.isJSX, "absolute") + formatWithJSX("left", isJSX, x), + formatWithJSX("top", isJSX, y), + formatWithJSX("position", isJSX, "absolute"), ); } else { - if ( - node.type === "GROUP" || - ("layoutMode" in node && - ((optimizeLayout ? node.inferredAutoLayout : null) ?? node) - ?.layoutMode === "NONE") - ) { - this.addStyles(formatWithJSX("position", this.isJSX, "relative")); + if (node.type === "GROUP" || (node as any).isRelative) { + this.addStyles(formatWithJSX("position", isJSX, "relative")); } } @@ -160,68 +282,76 @@ export class HtmlDefaultBuilder { applyFillsToStyle( paintArray: ReadonlyArray | PluginAPI["mixed"], - property: "text" | "background" + property: "text" | "background", ): 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(node: SceneNode): this { + shadow(): this { + const { node, isJSX } = this; if ("effects" in node) { const shadow = htmlShadow(node); if (shadow) { - this.addStyles( - formatWithJSX("box-shadow", this.isJSX, htmlShadow(node)) - ); + this.addStyles(formatWithJSX("box-shadow", isJSX, htmlShadow(node))); } } return this; } - size(node: SceneNode, optimize: boolean): this { - const { width, height } = htmlSizePartial(node, this.isJSX, optimize); + size(): this { + const { node, settings } = this; + const { width, height, constraints } = htmlSizePartial( + node, + settings.htmlGenerationMode === "jsx", + ); if (node.type === "TEXT") { switch (node.textAutoResize) { @@ -239,68 +369,170 @@ export class HtmlDefaultBuilder { this.addStyles(width, height); } + // Add constraints as separate styles + if (constraints.length > 0) { + this.addStyles(...constraints); + } + return this; } - autoLayoutPadding(node: SceneNode, optimizeLayout: boolean): this { + autoLayoutPadding(): this { + const { node, isJSX } = this; if ("paddingLeft" in node) { - this.addStyles( - ...htmlPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node, - this.isJSX - ) - ); + this.addStyles(...htmlPadding(node, isJSX)); } return this; } - blur(node: SceneNode) { + blur() { + const { node } = this; if ("effects" in node && node.effects.length > 0) { const blur = node.effects.find( - (e) => e.type === "LAYER_BLUR" && e.visible + (e) => e.type === "LAYER_BLUR" && e.visible, ); if (blur) { this.addStyles( formatWithJSX( "filter", this.isJSX, - `blur(${sliceNum(blur.radius)}px)` - ) + `blur(${numberToFixedString(blur.radius / 2)}px)`, + ), ); } const backgroundBlur = node.effects.find( - (e) => e.type === "BACKGROUND_BLUR" && e.visible + (e) => e.type === "BACKGROUND_BLUR" && e.visible, ); if (backgroundBlur) { this.addStyles( formatWithJSX( "backdrop-filter", this.isJSX, - `blur(${sliceNum(backgroundBlur.radius)}px)` - ) + `blur(${numberToFixedString(backgroundBlur.radius / 2)}px)`, + ), ); } } } + addData(label: string, value?: string): this { + const attribute = formatDataAttribute(label, value); + this.data.push(attribute); + return this; + } + build(additionalStyle: Array = []): string { this.addStyles(...additionalStyle); - const formattedStyles = this.styles.map((s) => s.trim()); - let formattedStyle = ""; - if (this.styles.length > 0) { - if (this.isJSX) { - formattedStyle = ` style={{${formattedStyles.join(", ")}}}`; - } else { - formattedStyle = ` style="${formattedStyles.join("; ")}"`; + // 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.trim()); + + if (mode !== "svelte" && mode !== "styled-components") { + const layerNameClass = stringToClassName(this.name.trim()); + if (layerNameClass !== "") { + classNames.push(layerNameClass); + } } } - if (this.name.length > 0) { - const classOrClassName = this.isJSX ? "className" : "class"; - return ` ${classOrClassName}="${this.name}"${formattedStyle}`; - } else { - return formattedStyle; + + 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 92522bfd..d91d82c7 100644 --- a/packages/backend/src/html/htmlMain.ts +++ b/packages/backend/src/html/htmlMain.ts @@ -1,86 +1,453 @@ import { indentString } from "../common/indentString"; -import { retrieveTopFill } from "../common/retrieveFill"; import { HtmlTextBuilder } from "./htmlTextBuilder"; import { HtmlDefaultBuilder } from "./htmlDefaultBuilder"; -import { PluginSettings } from "../code"; import { htmlAutoLayoutProps } from "./builderImpl/htmlAutoLayout"; import { formatWithJSX } from "../common/parseJSX"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; - -let showLayerName = false; +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"]; export let isPreviewGlobal = false; -let localSettings: PluginSettings; let previousExecutionCache: { style: string; text: string }[]; -export const htmlMain = ( +// 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 -): string => { - showLayerName = settings.layerName; + isPreview: boolean = false, +): Promise => { isPreviewGlobal = isPreview; previousExecutionCache = []; - localSettings = settings; + cssCollection = {}; + resetClassNameCounters(); // Reset counters for each new generation - let result = htmlWidgetGenerator(sceneNode, settings.jsx); + 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, +): Promise => { + let result = await htmlMain( + nodes, + { + ...settings, + htmlGenerationMode: "html", + }, + nodes.length > 1 ? false : true, + ); + + if (nodes.length > 1) { + result.html = `
${result.html}
`; + } + + return { + size: { + width: Math.max(...nodes.map((node) => node.width)), + height: nodes.reduce((sum, node) => sum + node.height, 0), + }, + content: result.html, + }; }; -// todo lint idea: replace BorderRadius.only(topleft: 8, topRight: 8) with BorderRadius.horizontal(8) -const htmlWidgetGenerator = ( +const htmlWidgetGenerator = async ( sceneNode: ReadonlyArray, - isJsx: boolean -): string => { - let comp = ""; + settings: HTMLSettings, +): Promise => { // filter non visible nodes. This is necessary at this step because conversion already happened. - const visibleSceneNode = sceneNode.filter((d) => d.visible); - visibleSceneNode.forEach((node, index) => { - // if (node.isAsset || ("isMask" in node && node.isMask === true)) { - // comp += htmlAsset(node, isJsx); - // } - - switch (node.type) { - case "RECTANGLE": - case "ELLIPSE": - comp += htmlContainer(node, "", [], isJsx); - break; - case "GROUP": - comp += htmlGroup(node, isJsx); - break; - case "FRAME": - case "COMPONENT": - case "INSTANCE": - case "COMPONENT_SET": - comp += htmlFrame(node, isJsx); - break; - case "SECTION": - comp += htmlSection(node, isJsx); - break; - case "TEXT": - comp += htmlText(node, isJsx); - break; - case "LINE": - comp += htmlLine(node, isJsx); - break; - case "VECTOR": - comp += htmlAsset(node, isJsx); + const promiseOfConvertedCode = getVisibleNodes(sceneNode).map( + convertNode(settings), + ); + const code = (await Promise.all(promiseOfConvertedCode)).join(""); + return code; +}; + +const convertNode = (settings: HTMLSettings) => async (node: SceneNode) => { + if (settings.embedVectors && (node as any).canBeFlattened) { + const altNode = await renderAndAttachSVG(node); + if (altNode.svg) { + return htmlWrapSVG(altNode, settings); } - }); + } - return comp; + switch ((node as any).type) { + case "RECTANGLE": + case "ELLIPSE": + return await htmlContainer(node, "", [], settings); + case "GROUP": + return await htmlGroup(node, settings); + case "FRAME": + case "COMPONENT": + case "INSTANCE": + case "COMPONENT_SET": + case "SLOT": + return await htmlFrame(node, settings); + case "SECTION": + return await htmlSection(node, settings); + case "TEXT": + return htmlText(node, settings); + case "LINE": + return htmlLine(node, settings); + case "VECTOR": + 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 ""; + } }; -const htmlGroup = (node: GroupNode, isJsx: boolean = false): string => { +const htmlWrapSVG = ( + node: AltNode, + settings: HTMLSettings, +): string => { + if (node.svg === "") return ""; + + const builder = new HtmlDefaultBuilder(node, settings) + .addData("svg-wrapper") + .position(); + + // The SVG content already has the var() references, so we don't need + // to add inline CSS variables in most cases. The browser will use the fallbacks + // if the variables aren't defined in the CSS. + + return `\n\n${indentString(node.svg ?? "")}
`; +}; + +const htmlGroup = async ( + node: GroupNode, + settings: HTMLSettings, +): 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 @@ -89,108 +456,124 @@ const htmlGroup = (node: GroupNode, isJsx: boolean = false): string => { return ""; } - // const vectorIfExists = tailwindVector(node, isJsx); - // if (vectorIfExists) return vectorIfExists; - // this needs to be called after CustomNode because widthHeight depends on it - const builder = new HtmlDefaultBuilder( - node, - showLayerName, - isJsx - ).commonPositionStyles(node, localSettings.optimizeLayout); + const builder = new HtmlDefaultBuilder(node, settings).commonPositionStyles(); if (builder.styles) { const attr = builder.build(); - - const generator = htmlWidgetGenerator(node.children, isJsx); - + const generator = await htmlWidgetGenerator(node.children, settings); return `\n${indentString(generator)}\n
`; } - - return htmlWidgetGenerator(node.children, isJsx); + return await htmlWidgetGenerator(node.children, settings); }; -// this was split from htmlText to help the UI part, where the style is needed (without

). -export const htmlText = (node: TextNode, isJsx: boolean): string => { - let layoutBuilder = new HtmlTextBuilder(node, showLayerName, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .textAlign(node); +// 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() + .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 = + styledHtml[0].openTypeFeatures.SUBS === true + ? "sub" + : styledHtml[0].openTypeFeatures.SUPS === true + ? "sup" + : ""; + + 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) => `${style.text}`) + .map((style) => { + // Always use span for multi-segment text in Svelte mode + const tag = + style.openTypeFeatures.SUBS === true + ? "sub" + : style.openTypeFeatures.SUPS === true + ? "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}
`; }; -const htmlFrame = ( +const htmlFrame = async ( node: SceneNode & BaseFrameMixin, - isJsx: boolean = false -): string => { - const childrenStr = htmlWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout( - node, - localSettings.optimizeLayout - ), - isJsx - ); + settings: HTMLSettings, +): Promise => { + const childrenStr = await htmlWidgetGenerator(node.children, settings); if (node.layoutMode !== "NONE") { - const rowColumn = htmlAutoLayoutProps(node, node, isJsx); - return htmlContainer(node, childrenStr, rowColumn, isJsx); - } else { - if (localSettings.optimizeLayout && node.inferredAutoLayout !== null) { - const rowColumn = htmlAutoLayoutProps( - node, - node.inferredAutoLayout, - isJsx - ); - return htmlContainer(node, childrenStr, rowColumn, isJsx); - } - - // node.layoutMode === "NONE" && node.children.length > 1 - // children needs to be absolute - return htmlContainer(node, childrenStr, [], isJsx); - } -}; - -export const htmlAsset = (node: SceneNode, isJsx: boolean = false): string => { - if (!("opacity" in node) || !("layoutAlign" in node) || !("fills" in node)) { - return ""; + const rowColumn = htmlAutoLayoutProps(node, settings); + return await htmlContainer(node, childrenStr, rowColumn, settings); } - const builder = new HtmlDefaultBuilder(node, showLayerName, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .commonShapeStyles(node); - - let tag = "div"; - let src = ""; - if (retrieveTopFill(node.fills)?.type === "IMAGE") { - tag = "img"; - src = ` src="https://via.placeholder.com/${node.width.toFixed( - 0 - )}x${node.height.toFixed(0)}"`; - } - - if (tag === "div") { - return `\n
`; - } - - 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 "," -export const htmlContainer = ( +const htmlContainer = async ( node: SceneNode & SceneNodeMixin & BlendMixin & @@ -199,46 +582,73 @@ export const htmlContainer = ( MinimalBlendMixin, children: string, additionalStyles: string[] = [], - isJsx: boolean -): string => { + settings: HTMLSettings, +): 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; } - const builder = new HtmlDefaultBuilder(node, showLayerName, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .commonShapeStyles(node); + const builder = new HtmlDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); if (builder.styles || additionalStyles) { let tag = "div"; let src = ""; - if (retrieveTopFill(node.fills)?.type === "IMAGE") { - 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", - isJsx, - `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) || isJsx) { + } else if ( + selfClosingTags.includes(tag) || + settings.htmlGenerationMode === "jsx" + ) { return `\n<${tag}${build}${src} />`; } else { return `\n<${tag}${build}${src}>`; @@ -248,14 +658,14 @@ export const htmlContainer = ( return children; }; -export const htmlSection = ( +const htmlSection = async ( node: SectionNode, - isJsx: boolean = false -): string => { - const childrenStr = htmlWidgetGenerator(node.children, isJsx); - const builder = new HtmlDefaultBuilder(node, showLayerName, isJsx) - .size(node, localSettings.optimizeLayout) - .position(node, localSettings.optimizeLayout) + settings: HTMLSettings, +): Promise => { + const childrenStr = await htmlWidgetGenerator(node.children, settings); + const builder = new HtmlDefaultBuilder(node, settings) + .size() + .position() .applyFillsToStyle(node.fills, "background"); if (childrenStr) { @@ -265,19 +675,19 @@ export const htmlSection = ( } }; -export const htmlLine = (node: LineNode, isJsx: boolean): string => { - const builder = new HtmlDefaultBuilder(node, showLayerName, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .commonShapeStyles(node); +const htmlLine = (node: LineNode, settings: HTMLSettings): string => { + const builder = new HtmlDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); return `\n
`; }; -export const htmlCodeGenTextStyles = (isJsx: boolean) => { +export const htmlCodeGenTextStyles = (settings: HTMLSettings) => { const result = previousExecutionCache .map( (style) => - `// ${style.text}\n${style.style.split(isJsx ? "," : ";").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 6b78da76..ebb62794 100644 --- a/packages/backend/src/html/htmlTextBuilder.ts +++ b/packages/backend/src/html/htmlTextBuilder.ts @@ -1,27 +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, StyledTextSegmentSubset } from "types"; +import { + cssCollection, + generateUniqueClassName, + stylesToCSS, + getComponentName, +} from "./htmlMain"; export class HtmlTextBuilder extends HtmlDefaultBuilder { - constructor(node: TextNode, showLayerName: boolean, optIsJSX: boolean) { - super(node, showLayerName, optIsJSX); + constructor(node: TextNode, settings: HTMLSettings) { + super(node, settings); + } + + // Override htmlElement to ensure text nodes use paragraph elements + get htmlElement(): string { + return "p"; } - getTextSegments(id: string): { style: string; text: string }[] { - const segments = globalTextStyleSegments[id]; + getTextSegments(node: TextNode): { + style: string; + text: string; + openTypeFeatures: { [key: string]: boolean }; + className?: string; + componentName?: string; + }[] { + 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), @@ -31,16 +61,69 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { "line-height": this.lineHeight(segment.lineHeight, segment.fontSize), "letter-spacing": this.letterSpacing( segment.letterSpacing, - segment.fontSize + segment.fontSize, ), // "text-indent": segment.indentation, "word-wrap": "break-word", + ...additionalStyles, }, - this.isJSX + this.isJSX, ); - const charsWithLineBreak = segment.characters.split("\n").join("
"); - return { style: styleAttributes, text: charsWithLineBreak }; + 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; }); } @@ -52,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": @@ -107,7 +200,8 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { return ""; } - textAlign(node: TextNode): this { + textAlignHorizontal(): this { + const node = this.node as TextNode; // if alignHorizontal is LEFT, don't do anything because that is native // only undefined in testing @@ -129,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 93d962ac..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 type { PluginSettings } from "./code"; -export { convertIntoNodes } from "./altNodes/altConversion"; +export * from "./messaging"; diff --git a/packages/backend/src/messaging.ts b/packages/backend/src/messaging.ts new file mode 100644 index 00000000..3476ed3b --- /dev/null +++ b/packages/backend/src/messaging.ts @@ -0,0 +1,38 @@ +import { + ConversionMessage, + ConversionStartMessage, + EmptyMessage, + ErrorMessage, + PluginSettings, + SettingsChangedMessage, +} from "types"; + +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" }); + +export const postError = (error: string) => + postBackendMessage({ type: "error", error } as ErrorMessage); + +export const postSettingsChanged = (settings: PluginSettings) => + postBackendMessage({ + type: "pluginSettingsChanged", + settings, + } as SettingsChangedMessage); diff --git a/packages/backend/src/nearest-color/nearestColor.ts b/packages/backend/src/nearest-color/nearestColor.ts index 0e98eaca..e4b4f0e1 100644 --- a/packages/backend/src/nearest-color/nearestColor.ts +++ b/packages/backend/src/nearest-color/nearestColor.ts @@ -1,32 +1,8 @@ // https://github.com/dtao/nearest-color converted to ESM and Typescript // It was sligtly modified to support Typescript better. // It was also slighly simplified because many parts weren't being used. -/** - * Defines an available color. - * - * @typedef {Object} ColorSpec - * @property {string=} name A name for the color, e.g., 'red' - * @property {string} source The hex-based color string, e.g., '#FF0' - * @property {RGB} rgb The {@link RGB} color values - */ -/** - * Describes a matched color. - * - * @typedef {Object} ColorMatch - * @property {string} name The name of the matched color, e.g., 'red' - * @property {string} value The hex-based color string, e.g., '#FF0' - * @property {RGB} rgb The {@link RGB} color values. - */ - -/** - * Provides the RGB breakdown of a color. - * - * @typedef {Object} RGB - * @property {number} r The red component, from 0 to 255 - * @property {number} g The green component, from 0 to 255 - * @property {number} b The blue component, from 0 to 255 - */ +import { ColorSpec, RGB } from "types"; /** * Gets the nearest color, from the given list of {@link ColorSpec} objects @@ -148,7 +124,7 @@ function mapColors(colors: Array): Array { * nearestColor.from(invalidColors); // => throws */ export const nearestColorFrom = ( - availableColors: Array + availableColors: Array, ): ((hex: string | RGB) => string) => { const colors = mapColors(availableColors); return (hex: string | RGB) => nearestColor(hex, colors); @@ -207,32 +183,6 @@ function parseColor(source: RGB | string): RGB { throw Error(`"${source}" is not a valid color`); } -type RGB = { - r: number; - g: number; - b: number; -}; - -// type ColorMatch = { -// name: string; -// value: string; -// rgb: RGB; -// distance: number; -// }; - -type ColorSpec = { - source: string; - rgb: RGB; -}; - -// export function createColorSpec(input: string | RGB, name: string): ColorSpec; - -// // it can actually return a ColorMatch, but let's ignore that for simplicity -// // in this app, it is never going to return ColorMatch because the input is hex instead of red -// export function from( -// availableColors: Array | Object -// ): (attr: string) => string; - /** * Creates a {@link ColorSpec} from either a string or an {@link RGB}. * diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts b/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts index ce28a66c..41650b54 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiBlend.ts @@ -1,12 +1,15 @@ -import { sliceNum } from "../../common/numToAutoFixed"; -import { Modifier } from "./swiftuiParser"; +import { SwiftUIModifier } from "types"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { AltNode } from "../../alt_api_types"; /** * https://developer.apple.com/documentation/swiftui/view/opacity(_:) */ -export const swiftuiOpacity = (node: MinimalBlendMixin): Modifier | null => { +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; }; @@ -14,7 +17,9 @@ export const swiftuiOpacity = (node: MinimalBlendMixin): Modifier | null => { /** * https://developer.apple.com/documentation/swiftui/view/hidden() */ -export const swiftuiVisibility = (node: SceneNodeMixin): Modifier | null => { +export const swiftuiVisibility = ( + node: SceneNodeMixin, +): SwiftUIModifier | null => { // [when testing] node.visible can be undefined if (node.visible !== undefined && !node.visible) { return ["hidden", ""]; @@ -25,9 +30,10 @@ export const swiftuiVisibility = (node: SceneNodeMixin): Modifier | null => { /** * https://developer.apple.com/documentation/swiftui/modifiedcontent/rotationeffect(_:anchor:) */ -export const swiftuiRotation = (node: LayoutMixin): Modifier | 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; }; @@ -35,7 +41,9 @@ export const swiftuiRotation = (node: LayoutMixin): Modifier | null => { /** * https://developer.apple.com/documentation/swiftui/blendmode */ -export const swiftuiBlendMode = (node: MinimalBlendMixin): Modifier | null => { +export const swiftuiBlendMode = ( + node: MinimalBlendMixin, +): SwiftUIModifier | null => { const fromBlendEnum = blendModeEnum(node); if (fromBlendEnum) { return ["blendMode", fromBlendEnum]; diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts b/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts index da0a6ae1..404f41e4 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiBorder.ts @@ -1,8 +1,9 @@ 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 { Modifier, SwiftUIElement } from "./swiftuiParser"; +import { SwiftUIElement } from "./swiftuiParser"; +import { SwiftUIModifier } from "types"; const swiftUIStroke = (node: SceneNode): number => { if (!("strokes" in node) || !node.strokes || node.strokes.length === 0) { @@ -47,9 +48,9 @@ export const swiftuiBorder = (node: SceneNode): string[] | null => { .map((stroke) => { const strokeColor = swiftUISolidColor(stroke); - const strokeModifier: Modifier = [ + const strokeModifier: SwiftUIModifier = [ "stroke", - `${strokeColor}, lineWidth: ${sliceNum(width)}`, + `${strokeColor}, lineWidth: ${numberToFixedString(width)}`, ]; if (strokeColor) { @@ -79,13 +80,13 @@ const getViewType = (node: SceneNode): string => { const strokeInset = ( node: MinimalStrokesMixin, - width: number + width: number, ): [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]; } @@ -103,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 ""; } @@ -114,11 +115,11 @@ export const swiftuiCornerRadius = (node: SceneNode): string => { radius.topLeft, radius.topRight, radius.bottomLeft, - radius.bottomRight + radius.bottomRight, ); 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 52c1fd3e..c871a5af 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiColor.ts @@ -1,8 +1,12 @@ import { retrieveTopFill } from "../../common/retrieveFill"; import { gradientAngle } from "../../common/color"; import { nearestValue } from "../../tailwind/conversionTables"; -import { sliceNum } from "../../common/numToAutoFixed"; +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); @@ -20,8 +24,31 @@ 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 = ( - fills: ReadonlyArray | PluginAPI["mixed"] + 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); @@ -38,41 +65,28 @@ export const swiftuiSolidColor = ( g: 0.23, b: 0.27, }, - 0.5 + 0.5, ); } 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") { - 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})`; @@ -114,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 8ea1dc2c..4165e48f 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiEffects.ts @@ -1,13 +1,13 @@ -import { sliceNum } from "../../common/numToAutoFixed"; -import { Modifier } from "./swiftuiParser"; +import { numberToFixedString } from "../../common/numToAutoFixed"; +import { SwiftUIModifier } from "types"; -export const swiftuiShadow = (node: SceneNode): Modifier | null => { +export const swiftuiShadow = (node: SceneNode): SwiftUIModifier | null => { if (!("effects" in node) || node.effects.length === 0) { return null; } const dropShadow: Array = node.effects.filter( - (d): d is DropShadowEffect => d.type === "DROP_SHADOW" && d.visible + (d): d is DropShadowEffect => d.type === "DROP_SHADOW" && d.visible, ); if (dropShadow.length === 0) { @@ -20,15 +20,17 @@ export const swiftuiShadow = (node: SceneNode): Modifier | 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) { @@ -44,13 +46,13 @@ export const swiftuiShadow = (node: SceneNode): Modifier | null => { return ["shadow", comp.join(", ")]; }; -export const swiftuiBlur = (node: SceneNode): Modifier | null => { +export const swiftuiBlur = (node: SceneNode): SwiftUIModifier | null => { if (!("effects" in node) || node.effects.length === 0) { return null; } const layerBlur: Array = node.effects.filter( - (d): d is BlurEffect => d.type === "LAYER_BLUR" && d.visible + (d): d is BlurEffect => d.type === "LAYER_BLUR" && d.visible, ); if (layerBlur.length === 0) { @@ -59,5 +61,5 @@ export const swiftuiBlur = (node: SceneNode): Modifier | 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 2d23767e..35eedc74 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiPadding.ts @@ -1,10 +1,10 @@ -import { sliceNum } from "../../common/numToAutoFixed"; +import { numberToFixedString } from "../../common/numToAutoFixed"; import { commonPadding } from "../../common/commonPadding"; -import { Modifier } from "./swiftuiParser"; +import { SwiftUIModifier } from "types"; export const swiftuiPadding = ( - node: InferredAutoLayoutResult -): Modifier | null => { + node: InferredAutoLayoutResult, +): SwiftUIModifier | null => { if (!("layoutMode" in node)) { return null; } @@ -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/swiftuiParser.ts b/packages/backend/src/swiftui/builderImpl/swiftuiParser.ts index 8a7f7d4d..4864d7f2 100644 --- a/packages/backend/src/swiftui/builderImpl/swiftuiParser.ts +++ b/packages/backend/src/swiftui/builderImpl/swiftuiParser.ts @@ -1,34 +1,38 @@ import { indentString } from "../../common/indentString"; - -export type Modifier = [string, string | Modifier | Modifier[]]; +import { SwiftUIModifier } from "types"; export class SwiftUIElement { private readonly element: string; - private readonly modifiers: Modifier[]; + private readonly modifiers: SwiftUIModifier[]; - constructor(element: string = "", modifiers: Modifier[] = []) { + constructor(element: string = "", modifiers: SwiftUIModifier[] = []) { this.element = element; this.modifiers = modifiers; } addModifierMixed( property: string, - value: string | Modifier | Modifier[] + value: string | SwiftUIModifier | SwiftUIModifier[], ): this { this.modifiers.push([property, value]); return this; } - addModifier(modifier: Modifier | [string | null, string | null]): this { + addModifier( + modifier: SwiftUIModifier | [string | null, string | null], + ): this { if (modifier && modifier[0] !== null && modifier[1] !== null) { this.modifiers.push([modifier[0], modifier[1]]); } return this; } - addChildElement(element: string, ...modifiers: Modifier[]): SwiftUIElement { + addChildElement( + element: string, + ...modifiers: SwiftUIModifier[] + ): SwiftUIElement { const childModifiers = modifiers.length === 1 ? modifiers[0] : modifiers; - return this.addModifierMixed(element, childModifiers as Modifier); + return this.addModifierMixed(element, childModifiers as SwiftUIModifier); } private buildModifierLines(indentLevel: number): string { @@ -38,16 +42,16 @@ export class SwiftUIElement { Array.isArray(value) ? `${indent}.${property}(${new SwiftUIElement( property, - value as Modifier[] + value as SwiftUIModifier[], ) .toString() .trim()})` : value.length > 60 - ? `${indent}.${property}(\n${indentString( - value, - indentLevel + 2 - )}\n${indent})` - : `${indent}.${property}(${value})` + ? `${indent}.${property}(\n${indentString( + value, + indentLevel + 2, + )}\n${indent})` + : `${indent}.${property}(${value})`, ) .join("\n"); } diff --git a/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts b/packages/backend/src/swiftui/builderImpl/swiftuiSize.ts index 32bd798d..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 -): { 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 95a5c04e..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"; @@ -18,7 +17,9 @@ import { commonIsAbsolutePosition, getCommonPositionValue, } from "../common/commonPosition"; -import { Modifier, SwiftUIElement } from "./builderImpl/swiftuiParser"; +import { SwiftUIElement } from "./builderImpl/swiftuiParser"; +import { SwiftUIModifier } from "types"; +import { swiftuiSolidColor } from "./builderImpl/swiftuiColor"; export class SwiftuiDefaultBuilder { element: SwiftUIElement; @@ -27,7 +28,7 @@ export class SwiftuiDefaultBuilder { this.element = new SwiftUIElement(kind); } - pushModifier(...args: (Modifier | null)[]): void { + pushModifier(...args: (SwiftUIModifier | null)[]): void { args.forEach((modifier) => { if (modifier) { this.element.addModifier(modifier); @@ -35,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); } @@ -48,7 +49,7 @@ export class SwiftuiDefaultBuilder { swiftuiVisibility(node), swiftuiRotation(node), swiftuiOpacity(node), - swiftuiBlendMode(node) + swiftuiBlendMode(node), ); return this; @@ -58,7 +59,7 @@ export class SwiftuiDefaultBuilder { x: number, y: number, node: SceneNode, - parent: (BaseNode & ChildrenMixin) | null + parent: (BaseNode & ChildrenMixin) | null, ): { centerX: number; centerY: number } { if (!parent || !("width" in parent)) { return { centerX: 0, centerY: 0 }; @@ -74,19 +75,19 @@ 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, y, node, - node.parent + node.parent, ); this.pushModifier([ `offset`, - `x: ${sliceNum(centerX)}, y: ${sliceNum(centerY)}`, + `x: ${numberToFixedString(centerX)}, y: ${numberToFixedString(centerY)}`, ]); } return this; @@ -104,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]); } @@ -137,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 1ede1727..b864df34 100644 --- a/packages/backend/src/swiftui/swiftuiMain.ts +++ b/packages/backend/src/swiftui/swiftuiMain.ts @@ -1,9 +1,13 @@ import { indentString } from "../common/indentString"; -import { className, sliceNum } from "../common/numToAutoFixed"; +import { + stringToClassName, + numberToFixedString, +} from "../common/numToAutoFixed"; import { SwiftuiTextBuilder } from "./swiftuiTextBuilder"; import { SwiftuiDefaultBuilder } from "./swiftuiDefaultBuilder"; -import { PluginSettings } from "../code"; -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[]; @@ -32,7 +36,7 @@ struct ContentView_Previews: PreviewProvider { export const swiftuiMain = ( sceneNode: Array, - settings: PluginSettings + settings: PluginSettings, ): string => { localSettings = settings; previousExecutionCache = []; @@ -43,10 +47,10 @@ export const swiftuiMain = ( return result; case "struct": // result = generateWidgetCode("Column", { children: [result] }); - return getStructTemplate(className(sceneNode[0].name), result); + return getStructTemplate(stringToClassName(sceneNode[0].name), result); case "preview": // result = generateWidgetCode("Column", { children: [result] }); - return getPreviewTemplate(className(sceneNode[0].name), result); + return getPreviewTemplate(stringToClassName(sceneNode[0].name), result); } // remove the initial \n that is made in Container. @@ -59,14 +63,14 @@ export const swiftuiMain = ( const swiftuiWidgetGenerator = ( sceneNode: ReadonlyArray, - indentLevel: number + 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": @@ -80,11 +84,16 @@ const swiftuiWidgetGenerator = ( case "INSTANCE": case "COMPONENT": case "COMPONENT_SET": + case "SLOT": comp.push(swiftuiFrame(node, indentLevel)); break; case "TEXT": comp.push(swiftuiText(node)); break; + case "VECTOR": + addWarning("VectorNodes are not supported in SwiftUI"); + break; + case "SLICE": default: break; } @@ -97,7 +106,7 @@ const swiftuiWidgetGenerator = ( // sometimes a property might not exist, so it doesn't add "," export const swiftuiContainer = ( node: SceneNode, - stack: string = "" + stack: string = "", ): string => { // ignore the view when size is zero or less // while technically it shouldn't get less than 0, due to rounding errors, @@ -117,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); @@ -131,12 +140,12 @@ export const swiftuiContainer = ( const swiftuiGroup = ( node: GroupNode | SectionNode, - indentLevel: number + indentLevel: number, ): string => { const children = widgetGeneratorWithLimits(node, indentLevel); return swiftuiContainer( node, - children ? generateSwiftViewCode("ZStack", {}, children) : `ZStack() { }` + children ? generateSwiftViewCode("ZStack", {}, children) : `ZStack() { }`, ); }; @@ -144,32 +153,25 @@ 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 = ( node: SceneNode & BaseFrameMixin, - indentLevel: number + indentLevel: number, ): string => { const children = widgetGeneratorWithLimits( node, - node.children.length > 1 ? indentLevel + 1 : indentLevel + 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); }; const createDirectionalStack = ( children: string, - inferredAutoLayout: InferredAutoLayoutResult + inferredAutoLayout: InferredAutoLayoutResult, ): string => { if (inferredAutoLayout.layoutMode !== "NONE") { return generateSwiftViewCode( @@ -178,7 +180,7 @@ const createDirectionalStack = ( alignment: getLayoutAlignment(inferredAutoLayout), spacing: getSpacing(inferredAutoLayout), }, - children + children, ); } else { return generateSwiftViewCode("ZStack", {}, children); @@ -186,7 +188,7 @@ const createDirectionalStack = ( }; const getLayoutAlignment = ( - inferredAutoLayout: InferredAutoLayoutResult + inferredAutoLayout: InferredAutoLayoutResult, ): string => { switch (inferredAutoLayout.counterAxisAlignItems) { case "MIN": @@ -212,52 +214,43 @@ const getSpacing = (inferredAutoLayout: InferredAutoLayoutResult): number => { export const generateSwiftViewCode = ( className: string, properties: Record, - children: string + children: string, ): string => { const propertiesArray = Object.entries(properties) .filter(([, value]) => value !== "") .map( ([key, value]) => - `${key}: ${typeof value === "number" ? sliceNum(value) : value}` + `${key}: ${typeof value === "number" ? numberToFixedString(value) : value}`, ); const compactPropertiesArray = propertiesArray.join(", "); if (compactPropertiesArray.length > 60) { const formattedProperties = propertiesArray.join(",\n"); return `${className}(\n${formattedProperties}\n) {${indentString( - children + children, )}\n}`; } return `${className}(${compactPropertiesArray}) {\n${indentString( - children + children, )}\n}`; }; // todo should the plugin manually Group items? Ideally, it would detect the similarities and allow a ForEach. const widgetGeneratorWithLimits = ( node: SceneNode & ChildrenMixin, - indentLevel: number + indentLevel: number, ) => { 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 25271c7d..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,13 +112,13 @@ 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); const letterSpacing = this.letterSpacing( segment.letterSpacing, - segment.fontSize + segment.fontSize, ); let updatedText = parseTextAsCode(characters); //segment.characters); swiftUI only supports a single text. @@ -126,7 +129,7 @@ export class SwiftuiTextBuilder extends SwiftuiDefaultBuilder { } const element = new SwiftUIElement( - `Text(${parseTextAsCode(`"${characters}"`)})` + `Text(${parseTextAsCode(`"${characters}"`)})`, ) .addModifier([ "font", @@ -140,17 +143,27 @@ 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; // }); } letterSpacing = ( letterSpacing: LetterSpacing, - fontSize: number + fontSize: number, ): 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 13c5c68a..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,9 +36,30 @@ 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 + autoLayout: InferredAutoLayoutResult, ): string => node.parent && "layoutMode" in node.parent && @@ -46,14 +69,17 @@ 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(" "); + autoLayout: InferredAutoLayoutResult, +): 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 f4a51106..158bd976 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindColor.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindColor.ts @@ -1,19 +1,139 @@ -import { nearestColorFrom } from "../../nearest-color/nearestColor"; -import { retrieveTopFill } from "../../common/retrieveFill"; import { gradientAngle } from "../../common/color"; -import { nearestOpacity, nearestValue } from "../conversionTables"; +import { + getColorInfo, + nearestOpacity, + nearestValue, +} from "../conversionTables"; +import { TailwindColorType } from "types"; +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, useVarSyntax: boolean = false) { + const { hex, colorType, colorName, meta } = getColorInfo(fill); + const exportValue = tailwindSolidColor(fill, "bg", useVarSyntax); + return { + exportValue, + colorName, + colorType, + hex, + meta, + }; +} + +/** + * 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 + * + * @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, + useVarSyntax: boolean = false, +): string => { + // 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})]`; + } + + // 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}`; + } + + const effectiveOpacity = calculateEffectiveOpacity(fill); + const opacity = + effectiveOpacity !== 1.0 ? `/${nearestOpacity(effectiveOpacity)}` : ""; + + 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"], - kind: string + fills: ReadonlyArray, + kind: TailwindColorType, ): string => { - // kind can be text, bg, border... // [when testing] fills can be undefined const fill = retrieveTopFill(fills); if (fill && fill.type === "SOLID") { - return tailwindSolidColor(fill.color, fill.opacity, kind); + return tailwindSolidColor(fill, kind); } else if ( fill && (fill.type === "GRADIENT_LINEAR" || @@ -22,364 +142,365 @@ export const tailwindColorFromFills = ( fill.type === "GRADIENT_DIAMOND") ) { if (fill.gradientStops.length > 0) { - return tailwindSolidColor( - fill.gradientStops[0].color, - fill.opacity, - kind - ); + return tailwindSolidColor(fill.gradientStops[0], kind); } } return ""; }; -export const tailwindSolidColor = ( - color: RGB, - opacity: number | undefined, - kind: string -): string => { - // example: text-opacity-50 - // ignore the 100. If opacity was changed, let it be visible. - const opacityProp = - opacity !== 1.0 - ? `${kind}-opacity-${nearestOpacity(opacity ?? 1.0)}` - : null; - - // example: text-red-500 - const colorProp = `${kind}-${getTailwindFromFigmaRGB(color)}`; - - // if fill isn't visible, it shouldn't be painted. - return [colorProp, opacityProp].filter((d) => d).join(" "); -}; - -/** - * 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); } + // 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 ""; }; -export const tailwindGradient = (fill: GradientPaint): string => { - const direction = gradientDirection(gradientAngle(fill)); +/** + * 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}]`; +}; - if (fill.gradientStops.length === 1) { - const fromColor = getTailwindFromFigmaRGB(fill.gradientStops[0].color); +/** + * 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", +}; - return `${direction} from-${fromColor}`; - } else if (fill.gradientStops.length === 2) { - const fromColor = getTailwindFromFigmaRGB(fill.gradientStops[0].color); - const toColor = getTailwindFromFigmaRGB(fill.gradientStops[1].color); +function getGradientDirectionClass( + angle: number, + useTailwind4: boolean, +): string { + const angleValues = [0, 45, 90, 135, 180, -45, -90, -135, -180]; - return `${direction} from-${fromColor} to-${toColor}`; - } else { - const fromColor = getTailwindFromFigmaRGB(fill.gradientStops[0].color); + // For non-standard angles in Tailwind 4, use exact angle + if (useTailwind4) { + const roundedAngle = Math.round(angle); + if (angleValues.includes(roundedAngle)) { + return directionMap[roundedAngle]; + } - // middle (second color) - const viaColor = getTailwindFromFigmaRGB(fill.gradientStops[1].color); + const exactAngle = Math.round(((angle % 360) + 360) % 360); + return `bg-linear-${exactAngle}`; + } - // last - const toColor = getTailwindFromFigmaRGB( - fill.gradientStops[fill.gradientStops.length - 1].color - ); + let snappedAngle = nearestValue(angle, angleValues); + if (snappedAngle === -180) snappedAngle = 180; - return `${direction} from-${fromColor} via-${viaColor} to-${toColor}`; + // 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; }; -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"; +/** + * 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 ""; }; -// AutoGenerated for Tailwind 2 via [convert_tailwind_colors.js] -export const tailwindColors: Record = { - "#000": "black", - "#fff": "white", - "#f8fafc": "slate-50", - "#f1f5f9": "slate-100", - "#e2e8f0": "slate-200", - "#cbd5e1": "slate-300", - "#94a3b8": "slate-400", - "#64748b": "slate-500", - "#475569": "slate-600", - "#334155": "slate-700", - "#1e293b": "slate-800", - "#0f172a": "slate-900", - "#020617": "slate-950", - "#f9fafb": "gray-50", - "#f3f4f6": "gray-100", - "#e5e7eb": "gray-200", - "#d1d5db": "gray-300", - "#9ca3af": "gray-400", - "#6b7280": "gray-500", - "#4b5563": "gray-600", - "#374151": "gray-700", - "#1f2937": "gray-800", - "#111827": "gray-900", - "#030712": "gray-950", - "#fafafa": "neutral-50", - "#f4f4f5": "zinc-100", - "#e4e4e7": "zinc-200", - "#d4d4d8": "zinc-300", - "#a1a1aa": "zinc-400", - "#71717a": "zinc-500", - "#52525b": "zinc-600", - "#3f3f46": "zinc-700", - "#27272a": "zinc-800", - "#18181b": "zinc-900", - "#09090b": "zinc-950", - "#f5f5f5": "neutral-100", - "#e5e5e5": "neutral-200", - "#d4d4d4": "neutral-300", - "#a3a3a3": "neutral-400", - "#737373": "neutral-500", - "#525252": "neutral-600", - "#404040": "neutral-700", - "#262626": "neutral-800", - "#171717": "neutral-900", - "#0a0a0a": "neutral-950", - "#fafaf9": "stone-50", - "#f5f5f4": "stone-100", - "#e7e5e4": "stone-200", - "#d6d3d1": "stone-300", - "#a8a29e": "stone-400", - "#78716c": "stone-500", - "#57534e": "stone-600", - "#44403c": "stone-700", - "#292524": "stone-800", - "#1c1917": "stone-900", - "#0c0a09": "stone-950", - "#fef2f2": "red-50", - "#fee2e2": "red-100", - "#fecaca": "red-200", - "#fca5a5": "red-300", - "#f87171": "red-400", - "#ef4444": "red-500", - "#dc2626": "red-600", - "#b91c1c": "red-700", - "#991b1b": "red-800", - "#7f1d1d": "red-900", - "#450a0a": "red-950", - "#fff7ed": "orange-50", - "#ffedd5": "orange-100", - "#fed7aa": "orange-200", - "#fdba74": "orange-300", - "#fb923c": "orange-400", - "#f97316": "orange-500", - "#ea580c": "orange-600", - "#c2410c": "orange-700", - "#9a3412": "orange-800", - "#7c2d12": "orange-900", - "#431407": "orange-950", - "#fffbeb": "amber-50", - "#fef3c7": "amber-100", - "#fde68a": "amber-200", - "#fcd34d": "amber-300", - "#fbbf24": "amber-400", - "#f59e0b": "amber-500", - "#d97706": "amber-600", - "#b45309": "amber-700", - "#92400e": "amber-800", - "#78350f": "amber-900", - "#451a03": "amber-950", - "#fefce8": "yellow-50", - "#fef9c3": "yellow-100", - "#fef08a": "yellow-200", - "#fde047": "yellow-300", - "#facc15": "yellow-400", - "#eab308": "yellow-500", - "#ca8a04": "yellow-600", - "#a16207": "yellow-700", - "#854d0e": "yellow-800", - "#713f12": "yellow-900", - "#422006": "yellow-950", - "#f7fee7": "lime-50", - "#ecfccb": "lime-100", - "#d9f99d": "lime-200", - "#bef264": "lime-300", - "#a3e635": "lime-400", - "#84cc16": "lime-500", - "#65a30d": "lime-600", - "#4d7c0f": "lime-700", - "#3f6212": "lime-800", - "#365314": "lime-900", - "#1a2e05": "lime-950", - "#f0fdf4": "green-50", - "#dcfce7": "green-100", - "#bbf7d0": "green-200", - "#86efac": "green-300", - "#4ade80": "green-400", - "#22c55e": "green-500", - "#16a34a": "green-600", - "#15803d": "green-700", - "#166534": "green-800", - "#14532d": "green-900", - "#052e16": "green-950", - "#ecfdf5": "emerald-50", - "#d1fae5": "emerald-100", - "#a7f3d0": "emerald-200", - "#6ee7b7": "emerald-300", - "#34d399": "emerald-400", - "#10b981": "emerald-500", - "#059669": "emerald-600", - "#047857": "emerald-700", - "#065f46": "emerald-800", - "#064e3b": "emerald-900", - "#022c22": "emerald-950", - "#f0fdfa": "teal-50", - "#ccfbf1": "teal-100", - "#99f6e4": "teal-200", - "#5eead4": "teal-300", - "#2dd4bf": "teal-400", - "#14b8a6": "teal-500", - "#0d9488": "teal-600", - "#0f766e": "teal-700", - "#115e59": "teal-800", - "#134e4a": "teal-900", - "#042f2e": "teal-950", - "#ecfeff": "cyan-50", - "#cffafe": "cyan-100", - "#a5f3fc": "cyan-200", - "#67e8f9": "cyan-300", - "#22d3ee": "cyan-400", - "#06b6d4": "cyan-500", - "#0891b2": "cyan-600", - "#0e7490": "cyan-700", - "#155e75": "cyan-800", - "#164e63": "cyan-900", - "#083344": "cyan-950", - "#f0f9ff": "sky-50", - "#e0f2fe": "sky-100", - "#bae6fd": "sky-200", - "#7dd3fc": "sky-300", - "#38bdf8": "sky-400", - "#0ea5e9": "sky-500", - "#0284c7": "sky-600", - "#0369a1": "sky-700", - "#075985": "sky-800", - "#0c4a6e": "sky-900", - "#082f49": "sky-950", - "#eff6ff": "blue-50", - "#dbeafe": "blue-100", - "#bfdbfe": "blue-200", - "#93c5fd": "blue-300", - "#60a5fa": "blue-400", - "#3b82f6": "blue-500", - "#2563eb": "blue-600", - "#1d4ed8": "blue-700", - "#1e40af": "blue-800", - "#1e3a8a": "blue-900", - "#172554": "blue-950", - "#eef2ff": "indigo-50", - "#e0e7ff": "indigo-100", - "#c7d2fe": "indigo-200", - "#a5b4fc": "indigo-300", - "#818cf8": "indigo-400", - "#6366f1": "indigo-500", - "#4f46e5": "indigo-600", - "#4338ca": "indigo-700", - "#3730a3": "indigo-800", - "#312e81": "indigo-900", - "#1e1b4b": "indigo-950", - "#f5f3ff": "violet-50", - "#ede9fe": "violet-100", - "#ddd6fe": "violet-200", - "#c4b5fd": "violet-300", - "#a78bfa": "violet-400", - "#8b5cf6": "violet-500", - "#7c3aed": "violet-600", - "#6d28d9": "violet-700", - "#5b21b6": "violet-800", - "#4c1d95": "violet-900", - "#2e1065": "violet-950", - "#faf5ff": "purple-50", - "#f3e8ff": "purple-100", - "#e9d5ff": "purple-200", - "#d8b4fe": "purple-300", - "#c084fc": "purple-400", - "#a855f7": "purple-500", - "#9333ea": "purple-600", - "#7e22ce": "purple-700", - "#6b21a8": "purple-800", - "#581c87": "purple-900", - "#3b0764": "purple-950", - "#fdf4ff": "fuchsia-50", - "#fae8ff": "fuchsia-100", - "#f5d0fe": "fuchsia-200", - "#f0abfc": "fuchsia-300", - "#e879f9": "fuchsia-400", - "#d946ef": "fuchsia-500", - "#c026d3": "fuchsia-600", - "#a21caf": "fuchsia-700", - "#86198f": "fuchsia-800", - "#701a75": "fuchsia-900", - "#4a044e": "fuchsia-950", - "#fdf2f8": "pink-50", - "#fce7f3": "pink-100", - "#fbcfe8": "pink-200", - "#f9a8d4": "pink-300", - "#f472b6": "pink-400", - "#ec4899": "pink-500", - "#db2777": "pink-600", - "#be185d": "pink-700", - "#9d174d": "pink-800", - "#831843": "pink-900", - "#500724": "pink-950", - "#fff1f2": "rose-50", - "#ffe4e6": "rose-100", - "#fecdd3": "rose-200", - "#fda4af": "rose-300", - "#fb7185": "rose-400", - "#f43f5e": "rose-500", - "#e11d48": "rose-600", - "#be123c": "rose-700", - "#9f1239": "rose-800", - "#881337": "rose-900", - "#4c0519": "rose-950", -}; +/** + * 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}`; -export const tailwindNearestColor = nearestColorFrom( - Object.keys(tailwindColors) -); + if (!localTailwindSettings.useTailwind4) { + return colorPart; + } -// figma uses r,g,b in [0, 1], while nearestColor uses it in [0, 255] -export const getTailwindFromFigmaRGB = (color: RGB): string => { - const colorMultiplied = { - r: color.r * 255, - g: color.g * 255, - b: color.b * 255, - }; + // 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 globalOpacity = fill.opacity ?? 1.0; + const direction = getGradientDirectionClass( + gradientAngle(fill), + localTailwindSettings.useTailwind4, + ); + + if (fill.gradientStops.length === 1) { + const fromStop = generateGradientStop( + "from", + fill.gradientStops[0], + globalOpacity, + 0, + ); + return [direction, 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 [direction, 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, firstStop, viaStop, lastStop].filter(Boolean).join(" "); + } +}; + +/** + * 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"; - return tailwindColors[tailwindNearestColor(colorMultiplied)]; + 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 [baseClass, firstStop, viaStop, lastStop].filter(Boolean).join(" "); + } }; -export const getTailwindColor = (color: string | RGB): string => { - return tailwindColors[tailwindNearestColor(color)]; +/** + * 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/tailwindPadding.ts b/packages/backend/src/tailwind/builderImpl/tailwindPadding.ts index b6558c8f..a2e812f0 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindPadding.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindPadding.ts @@ -40,7 +40,7 @@ export const tailwindPadding = (node: InferredAutoLayoutResult): string[] => { comp.push( ...(left && right && pxToLayoutSize(left) === pxToLayoutSize(right) ? [`px-${pxToLayoutSize(left)}`] - : [pl, pr]) + : [pl, pr]), ); } @@ -50,7 +50,7 @@ export const tailwindPadding = (node: InferredAutoLayoutResult): string[] => { comp.push( ...(top && bottom && pxToLayoutSize(top) === pxToLayoutSize(bottom) ? [`py-${pxToLayoutSize(top)}`] - : [pt, pb]) + : [pt, pb]), ); } diff --git a/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts b/packages/backend/src/tailwind/builderImpl/tailwindShadow.ts index 79f87a34..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 @@ -5,24 +7,123 @@ export const tailwindShadow = (node: BlendMixin): string[] => { // [when testing] node.effects can be undefined if (node.effects && node.effects.length > 0) { - const dropShadow = node.effects.filter( - (d) => d.type === "DROP_SHADOW" && d.visible - ); - let boxShadow = ""; - // simple shadow from tailwind - if (dropShadow.length > 0) { - boxShadow = "shadow"; - } + const EPSILON = 0.0001; // Small tolerance for floating-point comparison - const innerShadow = - node.effects.filter((d) => d.type === "INNER_SHADOW").length > 0 - ? "shadow-inner" - : ""; + const dropShadow = node.effects.map((d) => { + if (d.type === "DROP_SHADOW") { + if ( + d.offset?.x === 0 && + d.offset?.y === 1 && + d.radius === 2 && + d.spread === 0 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.05) < EPSILON + ) { + // shadow-sm → shadow-xs in v4 + return localTailwindSettings.useTailwind4 ? "shadow-xs" : "shadow-sm"; + } else if ( + d.offset?.x === 0 && + d.offset?.y === 1 && + d.radius === 3 && + d.spread === 0 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.1) < EPSILON + ) { + // shadow → shadow-sm in v4 + return localTailwindSettings.useTailwind4 ? "shadow-sm" : "shadow"; + } else if ( + d.offset?.x === 0 && + d.offset?.y === 4 && + d.radius === 6 && + d.spread === -1 && + d.color?.r === 0 && + d.color?.g === 0 && + 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 && + d.offset?.y === 10 && + d.radius === 15 && + d.spread === -3 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.1) < EPSILON + ) { + return "shadow-lg"; + } else if ( + d.offset?.x === 0 && + d.offset?.y === 20 && + d.radius === 25 && + d.spread === -5 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.1) < EPSILON + ) { + return "shadow-xl"; + } else if ( + d.offset?.x === 0 && + d.offset?.y === 25 && + d.radius === 50 && + d.spread === -12 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.25) < EPSILON + ) { + return "shadow-2xl"; + } else { + const offsetX = d.offset?.x || 0; + const offsetY = d.offset?.y || 0; + const radius = d.radius || 0; + const spread = d.spread || 0; + const r = Math.round((d.color?.r || 0) * 255); + const g = Math.round((d.color?.g || 0) * 255); + const b = Math.round((d.color?.b || 0) * 255); + const a = (d.color?.a || 0).toFixed(2); // Limit alpha to 2 decimal, otherwise we will get values like 0.12356587999 + return `shadow-[${offsetX}px_${offsetY}px_${radius}px_${spread}px_rgba(${r},${g},${b},${a})]`; + } + } + return ""; + }).filter(Boolean); - return [boxShadow, innerShadow]; + const innerShadow = node.effects.map((d) => { + if (d.type === "INNER_SHADOW") { + if ( + d.offset?.x === 0 && + d.offset?.y === 2 && + d.radius === 4 && + d.spread === 0 && + d.color?.r === 0 && + d.color?.g === 0 && + d.color?.b === 0 && + Math.abs(d.color?.a - 0.05) < EPSILON + ) { + return "shadow-inner"; + } else { + const offsetX = d.offset?.x || 0; + const offsetY = d.offset?.y || 0; + const radius = d.radius || 0; + const spread = d.spread || 0; + const r = Math.round((d.color?.r || 0) * 255); + const g = Math.round((d.color?.g || 0) * 255); + const b = Math.round((d.color?.b || 0) * 255); + const a = (d.color?.a || 0).toFixed(2); // Limit alpha to 2 decimal, otherwise we will get values like 0.12356587999 + return `shadow-[inset_${offsetX}px_${offsetY}px_${radius}px_${spread}px_rgba(${r},${g},${b},${a})]`; + } + } + return ""; + }).filter(Boolean); - // todo customize the shadow - // TODO layer blur, shadow-outline + return [...dropShadow, ...innerShadow]; } return []; }; diff --git a/packages/backend/src/tailwind/builderImpl/tailwindSize.ts b/packages/backend/src/tailwind/builderImpl/tailwindSize.ts index 609508e5..1273e3e2 100644 --- a/packages/backend/src/tailwind/builderImpl/tailwindSize.ts +++ b/packages/backend/src/tailwind/builderImpl/tailwindSize.ts @@ -1,58 +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") { - w = `w-${pxToLayoutSize(size.width)}`; + 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 ff6727b9..4532d608 100644 --- a/packages/backend/src/tailwind/conversionTables.ts +++ b/packages/backend/src/tailwind/conversionTables.ts @@ -1,5 +1,8 @@ -import { sliceNum } from "../common/numToAutoFixed"; +import { nearestColorFrom } from "../nearest-color/nearestColor"; +import { numberToFixedString } from "../common/numToAutoFixed"; import { localTailwindSettings } from "./tailwindMain"; +import { config } from "./tailwindConfig"; +import { rgbTo6hex } from "../common/color"; export const nearestValue = (goal: number, array: Array): number => { return array.reduce((prev, curr) => { @@ -7,9 +10,25 @@ 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 + array: Array, ): number | null => { for (let i = 0; i < array.length; i++) { const diff = Math.abs(goal - array[i]); @@ -25,168 +44,199 @@ 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 = ( value: number, - conversionMap: Record + 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.roundTailwind) { - return conversionMap[nearestValue(value / 16, keys)]; + } else if (localTailwindSettings.roundTailwindValues) { + // 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 = ( value: number, - conversionMap: Record + conversionMap: Record, ): string | null => { const keys = Object.keys(conversionMap).map((d) => +d); const convertedValue = exactValue(value, keys); if (convertedValue) { return conversionMap[convertedValue]; - } else if (localTailwindSettings.roundTailwind) { - return conversionMap[nearestValue(value, keys)]; + } else if (localTailwindSettings.roundTailwindValues) { + // 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]`; -}; - -const mapFontSize: Record = { - 0.75: "xs", - 0.875: "sm", - 1: "base", - 1.125: "lg", - 1.25: "xl", - 1.5: "2xl", - 1.875: "3xl", - 2.25: "4xl", - 3: "5xl", - 3.75: "6xl", - 4.5: "7xl", - 6: "8xl", - 8: "9xl", -}; - -const mapBorderRadius: Record = { - // 0: "none", - 0.125: "sm", - 0.25: "", - 0.375: "md", - 0.5: "lg", - 0.75: "xl", - 1.0: "2xl", - 1.5: "3xl", - 10: "full", -}; - -// This uses pixels. -const mapBlur: Record = { - 0: "none", - 4: "sm", - 8: "", - 12: "md", - 16: "lg", - 24: "xl", - 40: "2xl", - 64: "3xl", -}; - -const mapWidthHeightSize: Record = { - // '0: 0', - 1: "px", - 2: "0.5", - 4: "1", - 6: "1.5", - 8: "2", - 10: "2.5", - 12: "3", - 14: "3.5", - 16: "4", - 20: "5", - 24: "6", - 28: "7", - 32: "8", - 36: "9", - 40: "10", - 44: "11", - 48: "12", - 56: "14", - 64: "16", - 80: "20", - 96: "24", - 112: "28", - 128: "32", - 144: "36", - 160: "40", - 176: "44", - 192: "48", - 208: "52", - 224: "56", - 240: "60", - 256: "64", - 288: "72", - 320: "80", - 384: "96", -}; - -export const opacityValues = [ - 0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, -]; - -export const nearestOpacity = (nodeOpacity: number): number => - nearestValue(nodeOpacity * 100, opacityValues); - -const mapLetterSpacing: Record = { - "-0.05": "tighter", - "-0.025": "tight", - // 0: "normal", - 0.025: "wide", - 0.05: "wider", - 0.1: "widest", -}; - -export const pxToLetterSpacing = (value: number): string => - pxToRemToTailwind(value, mapLetterSpacing); - -const mapLineHeight: Record = { - 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", -}; - -export const pxToLineHeight = (value: number): string => - pxToRemToTailwind(value, mapLineHeight); - -export const pxToFontSize = (value: number): string => - pxToRemToTailwind(value, mapFontSize); - -export const pxToBorderRadius = (value: number): string => - pxToRemToTailwind(value, mapBorderRadius); - -export const pxToBlur = (value: number): string | null => - pxToTailwind(value, mapBlur); + return `[${numberToFixedString(value)}px]`; +}; + +export const pxToLetterSpacing = (value: number): string => { + return pxToRemToTailwind(value, config.letterSpacing); +}; + +export const pxToLineHeight = (value: number): string => { + return pxToRemToTailwind(value, config.lineHeight); +}; + +export const pxToFontSize = (value: number): string => { + return pxToRemToTailwind(value, config.fontSize); +}; + +export const pxToBorderRadius = (value: number): string => { + 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 => { + const conversionMap = localTailwindSettings.useTailwind4 + ? config.blurV4 + : config.blur; + return pxToTailwind(value, conversionMap); +}; export const pxToLayoutSize = (value: number): string => { - const tailwindValue = pxToTailwind(value, mapWidthHeightSize); - if (tailwindValue) { - return tailwindValue; - } + // 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 => { + return nearestValue(nodeOpacity * 100, config.opacity); +}; + +export const nearestColor = nearestColorFrom(Object.keys(config.color)); + +/** + * @return Tailwind color name and Hex value with leading # + */ +export const nearestColorFromRgb = (color: RGB) => { + // figma uses r,g,b in [0, 1], while nearestColor uses it in [0, 255] + const colorMultiplied = { + r: color.r * 255, + g: color.g * 255, + b: color.b * 255, + }; + const value = nearestColor(colorMultiplied); + const name = config.color[value]; + return { name, value }; +}; - return `[${sliceNum(value)}px]`; +export const variableToColorName = async (id: string) => { + return ( + (await figma.variables.getVariableByIdAsync(id))?.name + .replaceAll("/", "-") + .replaceAll(" ", "-") || id.toLowerCase().replaceAll(":", "-") + ); }; + +/** + * Get color information based on given color and user settings + * + * Returns type, name, hex and meta values + */ +export function getColorInfo(fill: SolidPaint | ColorStop) { + // variables + let colorName: string; + let colorType: "arbitrary" | "tailwind" | "variable"; + 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: "", + }; + } else if (fill.color.r === 1 && fill.color.g === 1 && fill.color.b === 1) { + return { + colorType: "tailwind", + colorName: "white", + hex: "#ffffff", + meta: "", + }; + } else { + // get tailwind color as comparison + const { name, value } = nearestColorFromRgb(fill.color); + + // round color + if (localTailwindSettings.roundTailwindColors || hex === value) { + colorName = name; + colorType = "tailwind"; + if (hex !== value) { + meta = "rounded"; + } + + // must come last, as previous comparison + hex = value; + } + + // exact color + else { + colorName = `[${hex}]`; + colorType = "arbitrary"; + } + } + + return { + colorType, + colorName, + hex, + meta, + }; +} diff --git a/packages/backend/src/tailwind/retrieveUI/retrieveTexts.ts b/packages/backend/src/tailwind/retrieveUI/retrieveTexts.ts deleted file mode 100644 index d094d85d..00000000 --- a/packages/backend/src/tailwind/retrieveUI/retrieveTexts.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { tailwindNearestColor } from "../builderImpl/tailwindColor"; -import { TailwindTextBuilder } from "../tailwindTextBuilder"; -import { rgbTo6hex } from "../../common/color"; -import { retrieveTopFill } from "../../common/retrieveFill"; -import { convertFontWeight } from "../../common/convertFontWeight"; - -export const retrieveTailwindText = ( - sceneNode: Array -): Array => { - // convert to Node and then flatten it. Conversion is necessary because of [tailwindText] - const selectedText = deepFlatten(sceneNode); - - const textStr: Array = []; - - selectedText.forEach((node) => { - if (node.type === "TEXT") { - let layoutBuilder = new TailwindTextBuilder(node, false, false) - .commonPositionStyles(node, false) - .textAlign(node); - - const styledHtml = layoutBuilder.getTextSegments(node.id); - - let content = ""; - if (styledHtml.length === 1) { - layoutBuilder.addAttributes(styledHtml[0].style); - content = styledHtml[0].text; - } else { - content = styledHtml - .map((style) => `${style.text}`) - .join(""); - } - - // return `\n${content}
`; - - const attr = new TailwindTextBuilder(node, false, false) - .blend(node) - .position(node, true); - - const splittedChars = node.characters.split("\n"); - const charsWithLineBreak = - splittedChars.length > 1 - ? node.characters.split("\n").join("
") - : node.characters; - - const black = { - r: 0, - g: 0, - b: 0, - }; - - let contrastBlack = 21; - - const fill = retrieveTopFill(node.fills); - - if (fill && fill.type === "SOLID") { - contrastBlack = calculateContrastRatio(fill.color, black); - } - - textStr.push({ - name: node.name, - attr: attr.attributes.join(" "), - full: `${charsWithLineBreak}`, - style: style(node), - contrastBlack, - }); - } - }); - - // retrieve only unique texts (attr + name) - // from https://stackoverflow.com/a/18923480/4418073 - const unique: Record = {}; - const distinct: Array = []; - textStr.forEach((x) => { - if (!unique[x.attr + x.name]) { - distinct.push(x); - unique[x.attr + x.name] = true; - } - }); - - return distinct; -}; - -type namedText = { - name: string; - attr: string; - full: string; - style: string; - contrastBlack: number; -}; - -const style = (node: TextNode): string => { - let comp = ""; - - if (node.fontName !== figma.mixed) { - const lowercaseStyle = node.fontName.style.toLowerCase(); - - if (lowercaseStyle.match("italic")) { - comp += "font-style: italic; "; - } - - const value = node.fontName.style - .replace("italic", "") - .replace(" ", "") - .toLowerCase(); - - const weight = convertFontWeight(value); - if (weight) { - comp += `font-weight: ${weight};`; - } - } - - if (node.fontSize !== figma.mixed) { - comp += `font-size: ${Math.min(node.fontSize, 24)};`; - } - - const color = convertColor(node.fills); - if (color) { - comp += `color: ${color};`; - } - - return comp; -}; - -function deepFlatten(arr: Array): Array { - let result: Array = []; - - arr.forEach((d) => { - if ("children" in d) { - result = result.concat(deepFlatten([...d.children])); - } else if (d.type === "TEXT") { - result.push(d); - } - }); - - return result; -} - -const convertColor = ( - fills: ReadonlyArray | PluginAPI["mixed"] -): string | undefined => { - // kind can be text, bg, border... - // [when testing] fills can be undefined - - const fill = retrieveTopFill(fills); - - if (fill && fill.type === "SOLID") { - return tailwindNearestColor(rgbTo6hex(fill.color)); - } - - return undefined; -}; - -// from https://dev.to/alvaromontoro/building-your-own-color-contrast-checker-4j7o -function calculateContrastRatio(color1: RGB, color2: RGB) { - const color1luminance = luminance(color1); - const color2luminance = luminance(color2); - - const contrast = - color1luminance > color2luminance - ? (color2luminance + 0.05) / (color1luminance + 0.05) - : (color1luminance + 0.05) / (color2luminance + 0.05); - - return 1 / contrast; -} - -function luminance(color: RGB) { - const a = [color.r * 255, color.g * 255, color.b * 255].map((v) => { - v /= 255; - return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; - }); - return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; -} diff --git a/packages/backend/src/tailwind/tailwindConfig.ts b/packages/backend/src/tailwind/tailwindConfig.ts new file mode 100644 index 00000000..cb08676d --- /dev/null +++ b/packages/backend/src/tailwind/tailwindConfig.ts @@ -0,0 +1,457 @@ +const layoutSize = { + "0": "0", + 1: "px", + 2: "0.5", + 4: "1", + 6: "1.5", + 8: "2", + 10: "2.5", + 12: "3", + 14: "3.5", + 16: "4", + 20: "5", + 24: "6", + 28: "7", + 32: "8", + 36: "9", + 40: "10", + 44: "11", + 48: "12", + 56: "14", + 64: "16", + 80: "20", + 96: "24", + 112: "28", + 128: "32", + 144: "36", + 160: "40", + 176: "44", + 192: "48", + 208: "52", + 224: "56", + 240: "60", + 256: "64", + 288: "72", + 320: "80", + 384: "96", +}; + +const borderRadius = { + 0: "none", + 0.125: "sm", + 0.25: "", + 0.375: "md", + 0.5: "lg", + 0.75: "xl", + 1.0: "2xl", + 1.5: "3xl", + 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", + 1: "base", + 1.125: "lg", + 1.25: "xl", + 1.5: "2xl", + 1.875: "3xl", + 2.25: "4xl", + 3: "5xl", + 3.75: "6xl", + 4.5: "7xl", + 6: "8xl", + 8: "9xl", +}; + +const lineHeight = { + 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 = { + "-0.05": "tighter", + "-0.025": "tight", + // 0: "normal", + 0.025: "wide", + 0.05: "wider", + 0.1: "widest", +}; + +// This uses pixels. +const blur = { + 0: "none", + 4: "sm", + 8: "blur", // This is not the official Tailwind class name suffix, but currently needed for the blurValue variable to work. + 12: "md", + 16: "lg", + 24: "xl", + 40: "2xl", + 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] +const color: Record = { + "#000000": "black", + "#ffffff": "white", + "#f8fafc": "slate-50", + "#f1f5f9": "slate-100", + "#e2e8f0": "slate-200", + "#cbd5e1": "slate-300", + "#94a3b8": "slate-400", + "#64748b": "slate-500", + "#475569": "slate-600", + "#334155": "slate-700", + "#1e293b": "slate-800", + "#0f172a": "slate-900", + "#020617": "slate-950", + "#f9fafb": "gray-50", + "#f3f4f6": "gray-100", + "#e5e7eb": "gray-200", + "#d1d5db": "gray-300", + "#9ca3af": "gray-400", + "#6b7280": "gray-500", + "#4b5563": "gray-600", + "#374151": "gray-700", + "#1f2937": "gray-800", + "#111827": "gray-900", + "#030712": "gray-950", + "#f4f4f5": "zinc-100", + "#e4e4e7": "zinc-200", + "#d4d4d8": "zinc-300", + "#a1a1aa": "zinc-400", + "#71717a": "zinc-500", + "#52525b": "zinc-600", + "#3f3f46": "zinc-700", + "#27272a": "zinc-800", + "#18181b": "zinc-900", + "#09090b": "zinc-950", + "#fafafa": "neutral-50", + "#f5f5f5": "neutral-100", + "#e5e5e5": "neutral-200", + "#d4d4d4": "neutral-300", + "#a3a3a3": "neutral-400", + "#737373": "neutral-500", + "#525252": "neutral-600", + "#404040": "neutral-700", + "#262626": "neutral-800", + "#171717": "neutral-900", + "#0a0a0a": "neutral-950", + "#fafaf9": "stone-50", + "#f5f5f4": "stone-100", + "#e7e5e4": "stone-200", + "#d6d3d1": "stone-300", + "#a8a29e": "stone-400", + "#78716c": "stone-500", + "#57534e": "stone-600", + "#44403c": "stone-700", + "#292524": "stone-800", + "#1c1917": "stone-900", + "#0c0a09": "stone-950", + "#fef2f2": "red-50", + "#fee2e2": "red-100", + "#fecaca": "red-200", + "#fca5a5": "red-300", + "#f87171": "red-400", + "#ef4444": "red-500", + "#dc2626": "red-600", + "#b91c1c": "red-700", + "#991b1b": "red-800", + "#7f1d1d": "red-900", + "#450a0a": "red-950", + "#fff7ed": "orange-50", + "#ffedd5": "orange-100", + "#fed7aa": "orange-200", + "#fdba74": "orange-300", + "#fb923c": "orange-400", + "#f97316": "orange-500", + "#ea580c": "orange-600", + "#c2410c": "orange-700", + "#9a3412": "orange-800", + "#7c2d12": "orange-900", + "#431407": "orange-950", + "#fffbeb": "amber-50", + "#fef3c7": "amber-100", + "#fde68a": "amber-200", + "#fcd34d": "amber-300", + "#fbbf24": "amber-400", + "#f59e0b": "amber-500", + "#d97706": "amber-600", + "#b45309": "amber-700", + "#92400e": "amber-800", + "#78350f": "amber-900", + "#451a03": "amber-950", + "#fefce8": "yellow-50", + "#fef9c3": "yellow-100", + "#fef08a": "yellow-200", + "#fde047": "yellow-300", + "#facc15": "yellow-400", + "#eab308": "yellow-500", + "#ca8a04": "yellow-600", + "#a16207": "yellow-700", + "#854d0e": "yellow-800", + "#713f12": "yellow-900", + "#422006": "yellow-950", + "#f7fee7": "lime-50", + "#ecfccb": "lime-100", + "#d9f99d": "lime-200", + "#bef264": "lime-300", + "#a3e635": "lime-400", + "#84cc16": "lime-500", + "#65a30d": "lime-600", + "#4d7c0f": "lime-700", + "#3f6212": "lime-800", + "#365314": "lime-900", + "#1a2e05": "lime-950", + "#f0fdf4": "green-50", + "#dcfce7": "green-100", + "#bbf7d0": "green-200", + "#86efac": "green-300", + "#4ade80": "green-400", + "#22c55e": "green-500", + "#16a34a": "green-600", + "#15803d": "green-700", + "#166534": "green-800", + "#14532d": "green-900", + "#052e16": "green-950", + "#ecfdf5": "emerald-50", + "#d1fae5": "emerald-100", + "#a7f3d0": "emerald-200", + "#6ee7b7": "emerald-300", + "#34d399": "emerald-400", + "#10b981": "emerald-500", + "#059669": "emerald-600", + "#047857": "emerald-700", + "#065f46": "emerald-800", + "#064e3b": "emerald-900", + "#022c22": "emerald-950", + "#f0fdfa": "teal-50", + "#ccfbf1": "teal-100", + "#99f6e4": "teal-200", + "#5eead4": "teal-300", + "#2dd4bf": "teal-400", + "#14b8a6": "teal-500", + "#0d9488": "teal-600", + "#0f766e": "teal-700", + "#115e59": "teal-800", + "#134e4a": "teal-900", + "#042f2e": "teal-950", + "#ecfeff": "cyan-50", + "#cffafe": "cyan-100", + "#a5f3fc": "cyan-200", + "#67e8f9": "cyan-300", + "#22d3ee": "cyan-400", + "#06b6d4": "cyan-500", + "#0891b2": "cyan-600", + "#0e7490": "cyan-700", + "#155e75": "cyan-800", + "#164e63": "cyan-900", + "#083344": "cyan-950", + "#f0f9ff": "sky-50", + "#e0f2fe": "sky-100", + "#bae6fd": "sky-200", + "#7dd3fc": "sky-300", + "#38bdf8": "sky-400", + "#0ea5e9": "sky-500", + "#0284c7": "sky-600", + "#0369a1": "sky-700", + "#075985": "sky-800", + "#0c4a6e": "sky-900", + "#082f49": "sky-950", + "#eff6ff": "blue-50", + "#dbeafe": "blue-100", + "#bfdbfe": "blue-200", + "#93c5fd": "blue-300", + "#60a5fa": "blue-400", + "#3b82f6": "blue-500", + "#2563eb": "blue-600", + "#1d4ed8": "blue-700", + "#1e40af": "blue-800", + "#1e3a8a": "blue-900", + "#172554": "blue-950", + "#eef2ff": "indigo-50", + "#e0e7ff": "indigo-100", + "#c7d2fe": "indigo-200", + "#a5b4fc": "indigo-300", + "#818cf8": "indigo-400", + "#6366f1": "indigo-500", + "#4f46e5": "indigo-600", + "#4338ca": "indigo-700", + "#3730a3": "indigo-800", + "#312e81": "indigo-900", + "#1e1b4b": "indigo-950", + "#f5f3ff": "violet-50", + "#ede9fe": "violet-100", + "#ddd6fe": "violet-200", + "#c4b5fd": "violet-300", + "#a78bfa": "violet-400", + "#8b5cf6": "violet-500", + "#7c3aed": "violet-600", + "#6d28d9": "violet-700", + "#5b21b6": "violet-800", + "#4c1d95": "violet-900", + "#2e1065": "violet-950", + "#faf5ff": "purple-50", + "#f3e8ff": "purple-100", + "#e9d5ff": "purple-200", + "#d8b4fe": "purple-300", + "#c084fc": "purple-400", + "#a855f7": "purple-500", + "#9333ea": "purple-600", + "#7e22ce": "purple-700", + "#6b21a8": "purple-800", + "#581c87": "purple-900", + "#3b0764": "purple-950", + "#fdf4ff": "fuchsia-50", + "#fae8ff": "fuchsia-100", + "#f5d0fe": "fuchsia-200", + "#f0abfc": "fuchsia-300", + "#e879f9": "fuchsia-400", + "#d946ef": "fuchsia-500", + "#c026d3": "fuchsia-600", + "#a21caf": "fuchsia-700", + "#86198f": "fuchsia-800", + "#701a75": "fuchsia-900", + "#4a044e": "fuchsia-950", + "#fdf2f8": "pink-50", + "#fce7f3": "pink-100", + "#fbcfe8": "pink-200", + "#f9a8d4": "pink-300", + "#f472b6": "pink-400", + "#ec4899": "pink-500", + "#db2777": "pink-600", + "#be185d": "pink-700", + "#9d174d": "pink-800", + "#831843": "pink-900", + "#500724": "pink-950", + "#fff1f2": "rose-50", + "#ffe4e6": "rose-100", + "#fecdd3": "rose-200", + "#fda4af": "rose-300", + "#fb7185": "rose-400", + "#f43f5e": "rose-500", + "#e11d48": "rose-600", + "#be123c": "rose-700", + "#9f1239": "rose-800", + "#881337": "rose-900", + "#4c0519": "rose-950", +}; + +const fontWeight: Record = { + 100: "thin", + 200: "extralight", + 300: "light", + 400: "normal", + 500: "medium", + 600: "semibold", + 700: "bold", + 800: "extrabold", + 900: "black", +}; + +const fontFamily = { + sans: [ + "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", + ], + mono: [ + "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 bbf4d5ed..eae314c6 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -1,10 +1,14 @@ -import { className, 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, @@ -21,91 +25,120 @@ import { getCommonPositionValue, } from "../common/commonPosition"; 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 !== "" && s !== null && s !== undefined; +const dropEmptyStrings = (strings: string[]) => strings.filter(isNotEmpty); export class TailwindDefaultBuilder { attributes: string[] = []; style: string; + data: string[]; styleSeparator: string = ""; - isJSX: boolean; - visible: boolean; - name: string = ""; + node: SceneNode; + settings: TailwindSettings; + + get name() { + return this.settings.showLayerNames ? this.node.name : ""; + } + get visible() { + return this.node.visible ?? true; + } + get isJSX() { + return this.settings.tailwindGenerationMode === "jsx"; + } - constructor(node: SceneNode, showLayerName: boolean, optIsJSX: boolean) { - this.isJSX = optIsJSX; + get needsJSXTextEscaping() { + return this.isJSX; + } + + get isTwigComponent() { + return this.settings.tailwindGenerationMode === "twig" && this.node.type === "INSTANCE" + } + + constructor(node: SceneNode, settings: TailwindSettings) { + this.node = node; + this.settings = settings; this.styleSeparator = this.isJSX ? "," : ";"; this.style = ""; - this.visible = node.visible; - - if (showLayerName) { - this.attributes.push(className(node.name)); - } + this.data = []; } addAttributes = (...newStyles: string[]) => { - this.attributes.push(...newStyles.filter((style) => style !== "")); + // Filter out empty strings and trim any extra spaces + const cleanedStyles = dropEmptyStrings(newStyles).map((s) => s.trim()); + this.attributes.push(...cleanedStyles); + }; + + prependAttributes = (...newStyles: string[]) => { + // Filter out empty strings and trim any extra spaces + const cleanedStyles = dropEmptyStrings(newStyles).map((s) => s.trim()); + this.attributes.unshift(...cleanedStyles); }; - blend( - node: SceneNode & SceneNodeMixin & MinimalBlendMixin & LayoutMixin - ): this { + blend(): this { this.addAttributes( - tailwindVisibility(node), - tailwindRotation(node), - tailwindOpacity(node), - tailwindBlendMode(node) + tailwindVisibility(this.node), + tailwindRotation(this.node as LayoutMixin), + tailwindOpacity(this.node as MinimalBlendMixin), + tailwindBlendMode(this.node as MinimalBlendMixin), ); return this; } - commonPositionStyles( - node: SceneNode & - SceneNodeMixin & - BlendMixin & - LayoutMixin & - MinimalBlendMixin, - optimizeLayout: boolean - ): this { - this.size(node, optimizeLayout); - this.autoLayoutPadding(node, optimizeLayout); - this.position(node, optimizeLayout); - this.blend(node); + commonPositionStyles(): this { + this.size(); + this.autoLayoutPadding(); + this.position(); + this.blend(); return this; } - commonShapeStyles(node: GeometryMixin & BlendMixin & SceneNode): this { - this.customColor(node.fills, "bg"); - this.radius(node); - this.shadow(node); - this.border(node); - this.blur(node); + commonShapeStyles(): this { + this.customColor((this.node as MinimalFillsTrait).fills, "bg"); + this.radius(); + this.shadow(); + this.border(); + this.blur(); return this; } - radius(node: SceneNode): this { - if (node.type === "ELLIPSE") { + radius(): this { + if (this.node.type === "ELLIPSE") { this.addAttributes("rounded-full"); } else { - this.addAttributes(tailwindBorderRadius(node)); + this.addAttributes(tailwindBorderRadius(this.node)); } return this; } - border(node: SceneNode): this { - if ("strokes" in node) { - this.addAttributes(tailwindBorderWidth(node)); - this.customColor(node.strokes, "border"); + border(): this { + if ("strokes" in this.node) { + const { isOutline, property } = tailwindBorderWidth(this.node); + this.addAttributes(property); + this.customColor( + this.node.strokes as MinimalStrokesTrait, + isOutline ? "outline" : "border", + ); } return this; } - position(node: SceneNode, optimizeLayout: boolean): this { - if (commonIsAbsolutePosition(node, optimizeLayout)) { - const { x, y } = getCommonPositionValue(node); + position(): this { + 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 { @@ -118,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; @@ -135,15 +163,17 @@ export class TailwindDefaultBuilder { * example: text-opacity-25 * example: bg-blue-500 */ - customColor( - paint: ReadonlyArray | PluginAPI["mixed"], - kind: string - ): 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); @@ -158,14 +188,15 @@ export class TailwindDefaultBuilder { * https://tailwindcss.com/docs/box-shadow/ * example: shadow */ - shadow(node: BlendMixin): this { - this.addAttributes(...tailwindShadow(node)); + shadow(): this { + this.addAttributes(...tailwindShadow(this.node as BlendMixin)); return this; } // must be called before Position, because of the hasFixedSize attribute. - size(node: SceneNode, optimizeLayout: boolean): this { - const { width, height } = tailwindSizePartial(node, optimizeLayout); + size(): this { + const { node, settings } = this; + const { width, height, constraints } = tailwindSizePartial(node, settings); if (node.type === "TEXT") { switch (node.textAutoResize) { @@ -183,66 +214,104 @@ export class TailwindDefaultBuilder { this.addAttributes(width, height); } + // Add any min/max constraints + if (constraints) { + this.addAttributes(constraints); + } + return this; } - autoLayoutPadding(node: SceneNode, optimizeLayout: boolean): this { - if ("paddingLeft" in node) { - this.addAttributes( - ...tailwindPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node - ) - ); + autoLayoutPadding(): this { + if ("paddingLeft" in this.node) { + this.addAttributes(...tailwindPadding(this.node)); } return this; } - blur(node: SceneNode) { + 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(`blur${blurValue ? `-${blurValue}` : ""}`); + this.addAttributes( + blurValue === "blur" ? "blur" : `blur-${blurValue}`, + ); // If blur value is 8, it will be "blur". Otherwise, it will be "blur-sm", "blur-md", etc. or "blur-[Xpx]" } } 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${ backgroundBlurValue ? `-${backgroundBlurValue}` : "" - }` + }`, ); } } } } + addData(label: string, value?: string): this { + const attribute = formatDataAttribute(label, value); + this.data.push(attribute); + return this; + } + build(additionalAttr = ""): string { - // this.attributes.unshift(this.name + additionalAttr); - this.addAttributes(additionalAttr); + if (additionalAttr) { + this.addAttributes(additionalAttr); + } - if (this.style.length > 0) { - this.style = ` style="${this.style}"`; + if (this.name !== "") { + this.prependAttributes(stringToClassName(this.name)); } - if (!this.attributes.length && !this.style) { - return ""; + if (this.name) { + this.addData("layer", this.name.trim()); } - const classOrClassName = this.isJSX ? "className" : "class"; - if (this.attributes.length === 0) { - return ""; + + 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)); } - return ` ${classOrClassName}="${this.attributes.join(" ")}"${this.style}`; + const classLabel = getClassLabel(this.isJSX); + const classNames = + this.attributes.length > 0 + ? ` ${classLabel}="${this.attributes.filter(Boolean).join(" ")}"` + : ""; + const styles = this.style.length > 0 ? ` style="${this.style}"` : ""; + const dataAttributes = this.data.join(""); + + return `${dataAttributes}${classNames}${styles}`; } reset(): void { this.attributes = []; + this.data = []; this.style = ""; } } diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index ed1ef67c..ea4243c3 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -1,168 +1,254 @@ import { retrieveTopFill } from "../common/retrieveFill"; import { indentString } from "../common/indentString"; -import { tailwindVector } from "./vector"; +import { addWarning } from "../common/commonConversionWarnings"; +import { getVisibleNodes } from "../common/nodeVisibility"; +import { getPlaceholderImage } from "../common/images"; import { TailwindTextBuilder } from "./tailwindTextBuilder"; import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; -import { PluginSettings } from "../code"; import { tailwindAutoLayoutProps } from "./builderImpl/tailwindAutoLayout"; -import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; +import { renderAndAttachSVG } from "../altNodes/altNodeUtils"; +import { AltNode, PluginSettings, TailwindSettings } from "types"; export let localTailwindSettings: PluginSettings; - -let previousExecutionCache: { style: string; text: string }[]; - -const selfClosingTags = ["img"]; - -export const tailwindMain = ( +let previousExecutionCache: { + style: string; + text: string; + openTypeFeatures: Record; +}[] = []; +const SELF_CLOSING_TAGS = ["img"]; + +export const tailwindMain = async ( sceneNode: Array, - settings: PluginSettings -): string => { + settings: PluginSettings, +): Promise => { localTailwindSettings = settings; previousExecutionCache = []; - let result = tailwindWidgetGenerator(sceneNode, localTailwindSettings.jsx); + 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 = ( +const tailwindWidgetGenerator = async ( sceneNode: ReadonlyArray, - isJsx: boolean -): string => { - let comp = ""; + settings: TailwindSettings, +): Promise => { + const visibleNodes = getVisibleNodes(sceneNode); + const promiseOfConvertedCode = visibleNodes.map(convertNode(settings)); + const code = (await Promise.all(promiseOfConvertedCode)).join(""); + return code; +}; - // filter non visible nodes. This is necessary at this step because conversion already happened. - const visibleSceneNode = sceneNode.filter((d) => d.visible); - visibleSceneNode.forEach((node) => { - switch (node.type) { +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": - comp += tailwindContainer(node, "", "", isJsx); - break; + return tailwindContainer(node, "", "", settings); case "GROUP": - comp += tailwindGroup(node, isJsx); - break; + return tailwindGroup(node, settings); case "FRAME": case "COMPONENT": case "INSTANCE": case "COMPONENT_SET": - comp += tailwindFrame(node, isJsx); - break; + case "SLOT": + return tailwindFrame(node, settings); case "TEXT": - comp += tailwindText(node, isJsx); - break; + return tailwindText(node, settings); case "LINE": - comp += tailwindLine(node, isJsx); - break; + return tailwindLine(node, settings); case "SECTION": - comp += tailwindSection(node, isJsx); - break; - // case "VECTOR": - // comp += htmlAsset(node, isJsx); + 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 ""; + }; - return comp; +const tailwindWrapSVG = ( + node: AltNode, + settings: TailwindSettings, +): string => { + if (!node.svg) return ""; + + const builder = new TailwindDefaultBuilder(node, settings) + .addData("svg-wrapper") + .position(); + + return `\n\n${indentString(node.svg ?? "")}
`; }; -const tailwindGroup = (node: GroupNode, isJsx: boolean = false): 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 - // 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 ""; } - const vectorIfExists = tailwindVector( - node, - localTailwindSettings.layerName, - "", - isJsx - ); - if (vectorIfExists) return vectorIfExists; - - // this needs to be called after CustomNode because widthHeight depends on it - const builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.layerName, - isJsx - ) - .blend(node) - .size(node, localTailwindSettings.optimizeLayout) - .position(node, localTailwindSettings.optimizeLayout); + const builder = new TailwindDefaultBuilder(node, settings) + .blend() + .size() + .position(); if (builder.attributes || builder.style) { const attr = builder.build(""); - - const generator = tailwindWidgetGenerator(node.children, isJsx); - + const generator = await tailwindWidgetGenerator(node.children, settings); return `\n${indentString(generator)}\n
`; } - return tailwindWidgetGenerator(node.children, isJsx); + return await tailwindWidgetGenerator(node.children, settings); }; -export const tailwindText = (node: TextNode, isJsx: boolean): string => { - let layoutBuilder = new TailwindTextBuilder( - node, - localTailwindSettings.layerName, - isJsx - ) - .commonPositionStyles(node, localTailwindSettings.optimizeLayout) - .textAlign(node); +export const tailwindText = ( + node: TextNode, + settings: TailwindSettings, +): string => { + const layoutBuilder = new TailwindTextBuilder(node, settings) + .commonPositionStyles() + .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 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) => `${style.text}`) + .map((style) => { + const tag = + style.openTypeFeatures.SUBS === true + ? "sub" + : style.openTypeFeatures.SUPS === true + ? "sup" + : "span"; + + return `<${tag} class="${style.style}">${style.text}`; + }) .join(""); } return `\n${content}`; }; -const tailwindFrame = ( +const tailwindFrame = async ( node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, - isJsx: boolean -): string => { - const childrenStr = tailwindWidgetGenerator( - commonSortChildrenWhenInferredAutoLayout( - node, - localTailwindSettings.optimizeLayout - ), - isJsx - ); + settings: TailwindSettings, +): Promise => { + // Check if this is an instance and should be rendered as a Twig component + if (node.type === "INSTANCE" && isTwigComponentNode(node)) { + return tailwindTwigComponentInstance(node, settings); + } + + const childrenStr = await tailwindWidgetGenerator(node.children, settings); + + const clipsContentClass = + 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, isJsx); + 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, isJsx); - } + // 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, "", isJsx); +// 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 & @@ -172,90 +258,86 @@ export const tailwindContainer = ( MinimalBlendMixin, children: string, additionalAttr: string, - isJsx: boolean + 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, localTailwindSettings.layerName, isJsx) - .commonPositionStyles(node, localTailwindSettings.optimizeLayout) - .commonShapeStyles(node); - - 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") { - 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)})]` - ); - } - } + const builder = new TailwindDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); + + if (!builder.attributes && !additionalAttr) { + return children; + } - if (children) { - return `\n<${tag}${build}${src}>${indentString(children)}\n`; - } else if (selfClosingTags.includes(tag) || isJsx) { - return `\n<${tag}${build}${src} />`; + const build = builder.build(additionalAttr); + + // 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 = (node: LineNode, isJsx: boolean): string => { - const builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.layerName, - isJsx - ) - .commonPositionStyles(node, localTailwindSettings.optimizeLayout) - .commonShapeStyles(node); +export const tailwindLine = ( + node: LineNode, + settings: TailwindSettings, +): string => { + const builder = new TailwindDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); return `\n`; }; -export const tailwindSection = (node: SectionNode, isJsx: boolean): string => { - const childrenStr = tailwindWidgetGenerator(node.children, isJsx); - const builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.layerName, - isJsx - ) - .size(node, localTailwindSettings.optimizeLayout) - .position(node, localTailwindSettings.optimizeLayout) +export const tailwindSection = async ( + node: SectionNode, + settings: TailwindSettings, +): Promise => { + const childrenStr = await tailwindWidgetGenerator(node.children, settings); + const builder = new TailwindDefaultBuilder(node, settings) + .size() + .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 de35c885..750ba8f8 100644 --- a/packages/backend/src/tailwind/tailwindTextBuilder.ts +++ b/packages/backend/src/tailwind/tailwindTextBuilder.ts @@ -1,19 +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): { style: string; text: string }[] { - const segments = globalTextStyleSegments[id]; + getTextSegments(node: TextNode): { + style: string; + text: string; + openTypeFeatures: { [key: string]: boolean }; + }[] { + const segments = (node as any) + .styledTextSegments as StyledTextSegmentSubset[]; + if (!segments) { return []; } @@ -24,13 +34,15 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { const textTransform = this.textTransform(segment.textCase); const lineHeightStyle = this.lineHeight( segment.lineHeight, - segment.fontSize + segment.fontSize, ); const letterSpacingStyle = this.letterSpacing( segment.letterSpacing, - segment.fontSize + segment.fontSize, ); // const textIndentStyle = this.indentStyle(segment.indentation); + const blurStyle = this.layerBlur(); + const shadowStyle = this.textShadow(); const styleClasses = [ color, @@ -42,17 +54,41 @@ 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("
"); - return { style: styleClasses, text: charsWithLineBreak }; + let chars = segment.characters; + if (this.needsJSXTextEscaping) { + chars = escapeJSXText(chars); + } + const charsWithLineBreak = chars.split("\n").join("
"); + return { + style: styleClasses, + text: charsWithLineBreak, + openTypeFeatures: segment.openTypeFeatures, + }; }); } + 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"] + fills: ReadonlyArray | PluginAPI["mixed"], ) => { // Implement a function to convert fills to the appropriate Tailwind CSS color classes. // This can be based on your project's configuration and color palette. @@ -65,28 +101,8 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { }; fontWeight = (fontWeight: number): string => { - switch (fontWeight) { - case 100: - return "font-thin"; - case 200: - return "font-extralight"; - case 300: - return "font-light"; - case 400: - return "font-normal"; - case 500: - return "font-medium"; - case 600: - return "font-semibold"; - case 700: - return "font-bold"; - case 800: - return "font-extrabold"; - case 900: - return "font-black"; - default: - return ""; - } + const weight = config.fontWeight[fontWeight]; + return weight ? `font-${weight}` : ""; }; indentStyle = (indentation: number) => { @@ -97,7 +113,39 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { }; fontFamily = (fontName: FontName): string => { - return "font-['" + fontName.family + "']"; + // 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 ""; + } + + const fontFamilyCustomConfig = localTailwindSettings.fontFamilyCustomConfig; + + if (fontFamilyCustomConfig) { + // Check if current font is part of custom tailwind config + for (const family in fontFamilyCustomConfig) { + if (fontFamilyCustomConfig[family].includes(fontName.family)) { + return `font-${family}` + } + } + } else { + // Check if the font is in one of the Tailwind default font stacks + if (config.fontFamily.sans.includes(fontName.family)) { + return "font-sans"; + } + if (config.fontFamily.serif.includes(fontName.family)) { + return "font-serif"; + } + if (config.fontFamily.mono.includes(fontName.family)) { + return "font-mono"; + } + } + + const underscoreFontName = fontName.family.replace(/\s/g, "_"); + + return "font-['" + underscoreFontName + "']"; }; /** @@ -130,8 +178,8 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { } const value = node.fontName.style - .replace("italic", "") - .replace(" ", "") + .replaceAll("italic", "") + .replaceAll(" ", "") .toLowerCase(); this.addAttributes(`font-${value}`); @@ -171,9 +219,9 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { * https://tailwindcss.com/docs/text-align/ * example: text-justify */ - textAlign(node: TextNode): 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 if (node.textAlignHorizontal && node.textAlignHorizontal !== "LEFT") { // todo when node.textAutoResize === "WIDTH_AND_HEIGHT" and there is no \n in the text, this can be ignored. @@ -195,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 @@ -230,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/backend/src/tailwind/vector.ts b/packages/backend/src/tailwind/vector.ts deleted file mode 100644 index 073cb815..00000000 --- a/packages/backend/src/tailwind/vector.ts +++ /dev/null @@ -1,124 +0,0 @@ -// import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; - -export const tailwindVector = ( - node: FrameNode | GroupNode, - showLayerName: boolean, - parentId: string, - isJsx: boolean -): string => { - // TODO VECTOR - return ""; -}; - -// import { -// FrameMixin, -// FrameNode, -// GroupNode, -// } from "./../common/Mixins"; -// import { rgbTo6hex } from "./colors"; - -// // todo improve this, positioning is wrong -// // todo support for ungroup vectors. This was reused because 80% of people are going -// export const tailwindVector = ( -// group: FrameNode | GroupNode, -// isJsx: Boolean -// ) => { -// // to use Vectors in groups (like icons) - -// // if every children is a VECTOR, no children have a child -// if ( -// group.children.length === 0 || -// !group.children.every((d) => d.type === "VECTOR") -// ) { -// return ""; -// } - -// const node = group.children[0] as VectorNode; - -// const strokeOpacity = vectorOpacity(node.strokes); -// const strokeOpacityAttr = -// strokeOpacity < 1 -// ? `${isJsx ? "strokeOpacity" : "stroke-opacity"}=${ -// isJsx ? `{${strokeOpacity}}` : `"${strokeOpacity}"` -// }\n` -// : ""; - -// const strokeWidthAttr = `${isJsx ? "strokeWidth" : "stroke-width"}=${ -// isJsx ? `{${node.strokeWeight}}` : `"${node.strokeWeight}"` -// }\n`; - -// const strokeLineCapAttr = -// node.strokeCap === "ROUND" -// ? `${isJsx ? "strokeLinecap" : "stroke-linecap"}="round"\n` -// : ""; - -// const strokeLineJoinAttr = -// node.strokeJoin !== "MITER" -// ? `${ -// isJsx ? "strokeLinejoin" : "stroke-linejoin" -// }="${node.strokeJoin.toString().toLowerCase()}"\n` -// : ""; - -// const strokeAttr = -// node.strokes.length > 0 ? `stroke="#${vectorColor(node.strokes)}"\n` : ""; - -// const sizeAttr = isJsx -// ? `height={${node.height}} width={${node.width}}` -// : `height="${node.height}" width="${node.width}"`; - -// // reduce everything into a single string -// const paths = group.children.reduce( -// (acc, n) => -// acc + -// (n as VectorNode).vectorPaths.reduce((acc, d) => { -// const fillRuleAttr = -// d.windingRule !== "NONE" -// ? `${isJsx ? "fillRule" : "fill-rule"}="${d.windingRule}"\n` -// : ""; - -// return ( -// acc + -// `\n` -// ); -// }, ""), -// "" -// ); - -// return ` -// ${paths} -// `; - -// // return `
`; -// // return ` -// // -// // `; -// }; - -// const vectorColor = ( -// fills: ReadonlyArray | PluginAPI["mixed"] -// ): string => { -// // kind can be text, bg, border... -// if (fills !== figma.mixed && fills.length > 0) { -// let fill = fills[0]; -// if (fill.type === "SOLID") { -// const hex = rgbTo6hex(fill.color); -// return fill.visible ? `${hex}` : ""; -// } -// } - -// return ""; -// }; - -// const vectorOpacity = ( -// fills: ReadonlyArray | PluginAPI["mixed"] -// ): number => { -// // kind can be text, bg, border... -// if (fills !== figma.mixed && fills.length > 0) { -// let fill = fills[0]; -// if (fill.opacity !== undefined) { -// return fill.opacity; -// } -// } - -// return 1; -// }; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index b9db1890..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.1.0", - "eslint-config-prettier": "^9.1.0", - "eslint-config-turbo": "^1.11.3", - "eslint-plugin-react": "7.33.2" + "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 22b33b2e..a2de4ef2 100644 --- a/packages/plugin-ui/package.json +++ b/packages/plugin-ui/package.json @@ -7,22 +7,27 @@ "main": "./src/index.tsx", "types": "./src/index.tsx", "scripts": { - "lint": "eslint \"src/**/*.ts*\"", - "test": "jest" + "lint": "eslint \"src/**/*.ts*\"" }, "dependencies": { - "copy-to-clipboard": "^3.3.3", - "react": "^18.2.0", - "react-syntax-highlighter": "^15.5.0", - "tailwindcss": "3.4.1" + "@base-ui/react": "^1.4.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "15.5.13", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "copy-to-clipboard": "^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": { - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@types/react-syntax-highlighter": "15.5.11", - "eslint": "^8.56.0", + "eslint": "^10.3.0", "eslint-config-custom": "workspace:*", "tsconfig": "workspace:*", - "typescript": "^5.3.3" + "types": "workspace:*", + "typescript": "^6.0.3" } } diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index c011f09f..abbfa24f 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -1,691 +1,224 @@ -import { 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"; - -export type FrameworkTypes = "HTML" | "Tailwind" | "Flutter" | "SwiftUI"; - -// This must be kept in sync with the backend. -export type PluginSettings = { - framework: FrameworkTypes; - jsx: boolean; - inlineStyle: boolean; - optimizeLayout: boolean; - layerName: boolean; - responsiveRoot: boolean; - flutterGenerationMode: string; - swiftUIGenerationMode: string; - roundTailwind: boolean; -}; +import Preview from "./components/Preview"; +import GradientsPanel from "./components/GradientsPanel"; +import ColorsPanel from "./components/ColorsPanel"; +import CodePanel from "./components/CodePanel"; +import EmptyState from "./components/EmptyState"; +import About from "./components/About"; +import WarningsPanel from "./components/WarningsPanel"; +import { + Framework, + HTMLPreview, + LinearGradientConversion, + PluginSettings, + SolidColorConversion, + Warning, +} from "types"; +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; - htmlPreview: { - size: { width: number; height: number }; - content: string; - } | null; - emptySelection: boolean; - selectedFramework: FrameworkTypes; - setSelectedFramework: (framework: FrameworkTypes) => void; - preferences: PluginSettings | null; - onPreferenceChange: (key: string, value: boolean | string) => void; - colors: { - hex: string; - colorName: string; - exportValue: string; - contrastWhite: number; - contrastBlack: number; - }[]; - gradients: { cssPreview: string; exportValue: string }[]; + htmlPreview: HTMLPreview; + warnings: Warning[]; + selectedFramework: Framework; + setSelectedFramework: (framework: Framework) => void; + settings: PluginSettings | null; + onPreferenceChanged: ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => void; + colors: SolidColorConversion[]; + gradients: LinearGradientConversion[]; + isLoading: boolean; }; -export const PluginUI = (props: PluginUIProps) => { - const [isResponsiveExpanded, setIsResponsiveExpanded] = useState(false); - - return ( -
-
- {["HTML", "Tailwind", "Flutter", "SwiftUI"].map((tab) => ( - - ))} -
-
-
-
- {/*
- -
*/} - - {props.htmlPreview && ( - - )} - {/* */} - {/*
-
- -
*/} - - - {props.colors.length > 0 && ( - { - copy(value); - }} - /> - )} +const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; +const LOADING_INDICATOR_DELAY_MS = 250; - {props.gradients.length > 0 && ( - { - copy(value); - }} - /> - )} -
-
-
- ); +type FrameworkTabsProps = { + frameworks: Framework[]; + selectedFramework: Framework; + setSelectedFramework: (framework: Framework) => void; + showAbout: boolean; + setShowAbout: (show: boolean) => void; }; -export const ResponsiveGrade = () => { +const FrameworkTabs = ({ + frameworks, + selectedFramework, + setSelectedFramework, + showAbout, + setShowAbout, +}: FrameworkTabsProps) => { return ( -
- 80% responsive -
- - -
-
- ); -}; - -type LocalCodegenPreference = - // | { - // itemType: "alternative-unit"; - // defaultScaleFactor: number; - // scaledUnit: string; - // default?: boolean; - // includedLanguages?: FrameworkTypes[]; - // } - // | { - // itemType: "select"; - // propertyName: Exclude; - // label: string; - // options: { label: string; value: string; isDefault?: boolean }[]; - // includedLanguages?: FrameworkTypes[]; - // } - // | { - // itemType: "action"; - // propertyName: string; - // label: string; - // includedLanguages?: FrameworkTypes[]; - // } - // | - { - itemType: "individual_select"; - propertyName: Exclude< - keyof PluginSettings, - "framework" | "flutterGenerationMode" | "swiftUIGenerationMode" - >; - label: string; - value?: boolean; - isDefault?: boolean; - includedLanguages?: FrameworkTypes[]; - }; - -export const preferenceOptions: LocalCodegenPreference[] = [ - { - itemType: "individual_select", - propertyName: "jsx", - label: "React (JSX)", - isDefault: false, - includedLanguages: ["HTML", "Tailwind"], - }, - // { - // itemType: "individual_select", - // propertyName: "inlineStyle", - // label: "Inline Style", - // isDefault: true, - // includedLanguages: ["HTML"], - // }, - // { - // itemType: "individual_select", - // propertyName: "responsiveRoot", - // label: "Responsive Root", - // isDefault: false, - // includedLanguages: ["Tailwind"], - // }, - { - itemType: "individual_select", - propertyName: "optimizeLayout", - label: "Optimize Layout", - isDefault: true, - includedLanguages: ["HTML", "Tailwind", "Flutter", "SwiftUI"], - }, - { - itemType: "individual_select", - propertyName: "layerName", - label: "Layer Names", - isDefault: false, - includedLanguages: ["HTML", "Tailwind"], - }, - { - itemType: "individual_select", - propertyName: "roundTailwind", - label: "Round to Tailwind Values", - isDefault: false, - includedLanguages: ["Tailwind"], - }, - // Add your preferences data here -]; - -const selectPreferenceOptions: { - itemType: "select"; - propertyName: Exclude; - label: string; - options: { label: string; value: string; isDefault?: boolean }[]; - includedLanguages?: FrameworkTypes[]; -}[] = [ - { - itemType: "select", - propertyName: "flutterGenerationMode", - label: "Mode", - options: [ - { label: "Full App", value: "fullApp" }, - { label: "Widget", value: "stateless" }, - { label: "Snippet", value: "snippet" }, - ], - includedLanguages: ["Flutter"], - }, - { - itemType: "select", - propertyName: "swiftUIGenerationMode", - label: "Mode", - options: [ - { label: "Preview", value: "preview" }, - { label: "Struct", value: "struct" }, - { label: "Snippet", value: "snippet" }, - ], - includedLanguages: ["SwiftUI"], - }, - // { - // itemType: "select", - // propertyName: "htmlGenerationMode", - // label: "Mode", - // options: [ - // { label: "Component", value: "component" }, - // { label: "Snippet", value: "snippet" }, - // ], - // includedLanguages: ["HTML"], - // }, -]; - -export const CodePanel = (props: { - code: string; - selectedFramework: FrameworkTypes; - preferences: PluginSettings | null; - onPreferenceChange: (key: string, value: boolean | string) => void; -}) => { - const emptySelection = false; - const [isPressed, setIsPressed] = useState(false); - const [syntaxHovered, setSyntaxHovered] = useState(false); - - // Add your clipboard function here or any other actions - const handleButtonClick = () => { - setIsPressed(true); - setTimeout(() => setIsPressed(false), 250); - copy(props.code); - }; - - const handleButtonHover = () => setSyntaxHovered(true); - const handleButtonLeave = () => setSyntaxHovered(false); - - if (emptySelection) { - return ( -
-

Nothing is selected

-

Try selecting a layer, any layer

-
- ); - } else { - const selectablePreferencesFiltered = selectPreferenceOptions.filter( - (preference) => - preference.includedLanguages?.includes(props.selectedFramework) - ); - - return ( -
-
-

- Code -

- -
- -
-
- {/* Settings */} - - {preferenceOptions - .filter((preference) => - preference.includedLanguages?.includes(props.selectedFramework) - ) - .map((preference) => ( - { - props.onPreferenceChange(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" - /> - ))} -
- {selectablePreferencesFiltered.length > 0 && ( - <> -
- -
- {selectablePreferencesFiltered.map((preference) => ( - <> - {/* - {preference.label} - */} - {preference.options.map((option) => ( - { - props.onPreferenceChange( - 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" - /> - ))} - - ))} -
- - )} -
- -
+ {frameworks.map((tab) => ( +
-
- ); - } -}; - -export const ColorsPanel = (props: { - colors: { - hex: string; - colorName: string; - exportValue: string; - contrastWhite: number; - contrastBlack: number; - }[]; - onColorClick: (color: string) => void; -}) => { - const [isPressed, setIsPressed] = useState(-1); - - const handleButtonClick = (value: string, idx: number) => { - setIsPressed(idx); - setTimeout(() => setIsPressed(-1), 250); - props.onColorClick(value); - }; - - return ( -
-

- Colors -

-
- {props.colors.map((color, idx) => ( - - ))} -
+ {tab} + + ))}
); }; -export const GradientsPanel = (props: { - gradients: { - cssPreview: string; - exportValue: string; - }[]; - onColorClick: (color: string) => void; -}) => { - const [isPressed, setIsPressed] = useState(-1); - - const handleButtonClick = (value: string, idx: number) => { - setIsPressed(idx); - setTimeout(() => setIsPressed(-1), 250); - props.onColorClick(value); - }; - - return ( -
-

- Gradients -

-
- {props.gradients.map((gradient, idx) => ( - - ))} -
-
+export const PluginUI = (props: PluginUIProps) => { + const [showAbout, setShowAbout] = useState(false); + const [showLoading, setShowLoading] = useState(false); + const [hasHandledInitialLoad, setHasHandledInitialLoad] = useState(false); + + const [previewExpanded, setPreviewExpanded] = useState(false); + const [previewViewMode, setPreviewViewMode] = useState< + "desktop" | "mobile" | "precision" + >("precision"); + const [previewBgColor, setPreviewBgColor] = useState<"white" | "black">( + "white", ); -}; -// export const PrevColorsPanel = (props: { -// colors: { -// hex: string; -// colorName: string; -// exportValue: string; -// contrastWhite: number; -// contrastBlack: number; -// }[]; -// // onColorClick: (color: string) => void; -// }) => { -// return ( -//
-//
-//
-//
-//

Text

-// {["Button1", "Button2", "Button3"].map((button, idx) => ( -// -// ))} -//
-//
-//

Colors

-//
-// {["red-500", "yellow-500", "blue-500"].map((color, idx) => ( -// -// ))} -//
-//
-//
-//
-//
-// ); -// }; + useEffect(() => { + if (!props.isLoading) { + setShowLoading(false); + setHasHandledInitialLoad(true); + return; + } -type SelectableToggleProps = { - onSelect: (isSelected: boolean) => void; - isSelected?: boolean; - title: string; - buttonClass: string; - checkClass: string; -}; + if (hasHandledInitialLoad) { + setShowLoading(true); + return; + } -const SelectableToggle = ({ - onSelect, - isSelected = false, - title, - buttonClass, - checkClass, -}: SelectableToggleProps) => { - const handleClick = () => { - onSelect(!isSelected); - }; + // 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 ( - - ); -}; + return () => window.clearTimeout(timer); + }, [props.isLoading]); + + if (props.isLoading) return showLoading ? : null; -export const Preview: React.FC<{ - htmlPreview: { - size: { width: number; height: number }; - content: string; - }; - isResponsiveExpanded: boolean; - setIsResponsiveExpanded: (value: boolean) => void; -}> = (props) => { - const previewWidths = [45, 80, 140]; - const labels = ["sm", "md", "lg"]; + const isEmpty = props.code === ""; + const warnings = props.warnings ?? []; return ( -
-
- Responsive Preview - -
-
- {previewWidths.map((targetWidth, index) => { - const targetHeight = props.isResponsiveExpanded ? 260 : 120; - const scaleFactor = Math.min( - targetWidth / props.htmlPreview.size.width, - targetHeight / props.htmlPreview.size.height - ); - return ( -
+
+
+
+ + +
+
+
+ + {showAbout ? ( + + ) : isEmpty ? ( +
+ +
+ ) : ( +
+ {props.htmlPreview && ( + -
- - {labels[index]} - + )} + + {warnings.length > 0 && } + + + + {props.colors.length > 0 && ( +
+ { + copy(value); + }} + /> +
+ )} + + {props.gradients.length > 0 && ( +
+ { + copy(value); + }} + /> +
+ )}
- ); - })} + )} +
-
+ ); }; - -export const viewDocumentationWebsite = () => { - return ( -
-

- Documentation -

-

- Learn how to use our Figma plugin and explore its features in detail by - visiting our documentation website. -

- - Visit Documentation Website → - -
- ); -}; - -const ExpandIcon = (props: { size: number }) => ( - - - -); diff --git a/packages/plugin-ui/src/codegenPreferenceOptions.ts b/packages/plugin-ui/src/codegenPreferenceOptions.ts new file mode 100644 index 00000000..fed6d2d9 --- /dev/null +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -0,0 +1,123 @@ +import { LocalCodegenPreferenceOptions, SelectPreferenceOptions } from "types"; + +export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ + { + itemType: "individual_select", + propertyName: "useTailwind4", + label: "Tailwind 4", + description: "Enable Tailwind CSS version 4 features and syntax.", + isDefault: true, + includedLanguages: ["Tailwind"], + }, + { + itemType: "individual_select", + propertyName: "showLayerNames", + label: "Layer names", + description: "Include Figma layer names in classes.", + isDefault: false, + includedLanguages: ["HTML", "Tailwind"], + }, + { + itemType: "individual_select", + propertyName: "roundTailwindValues", + label: "Round values", + description: + "Round pixel values to nearest Tailwind sizes (within a 15% range).", + isDefault: false, + includedLanguages: ["Tailwind"], + }, + { + itemType: "individual_select", + propertyName: "roundTailwindColors", + label: "Round colors", + description: "Round Figma color values to nearest Tailwind colors.", + isDefault: false, + includedLanguages: ["Tailwind"], + }, + { + itemType: "individual_select", + 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: ["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"], + }, +]; + +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", + label: "Mode", + options: [ + { label: "Full App", value: "fullApp" }, + { label: "Widget", value: "stateless" }, + { label: "Snippet", value: "snippet" }, + ], + includedLanguages: ["Flutter"], + }, + { + itemType: "select", + propertyName: "swiftUIGenerationMode", + label: "Mode", + options: [ + { label: "Preview", value: "preview" }, + { label: "Struct", value: "struct" }, + { label: "Snippet", value: "snippet" }, + ], + 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 new file mode 100644 index 00000000..9ed0a577 --- /dev/null +++ b/packages/plugin-ui/src/components/CodePanel.tsx @@ -0,0 +1,279 @@ +import { + Framework, + LocalCodegenPreferenceOptions, + PluginSettings, + SelectPreferenceOptions, +} from "types"; +import { useMemo, useState } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coldarkDark as theme } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { CopyButton } from "./CopyButton"; +import EmptyState from "./EmptyState"; +import SettingsGroup from "./SettingsGroup"; +import FrameworkTabs from "./FrameworkTabs"; +import { TailwindSettings } from "./TailwindSettings"; + +interface CodePanelProps { + code: string; + selectedFramework: Framework; + settings: PluginSettings | null; + preferenceOptions: LocalCodegenPreferenceOptions[]; + selectPreferenceOptions: SelectPreferenceOptions[]; + onPreferenceChanged: ( + key: keyof PluginSettings, + value: PluginSettings[keyof PluginSettings], + ) => void; +} + +const CodePanel = (props: CodePanelProps) => { + const [syntaxHovered, setSyntaxHovered] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const initialLinesToShow = 25; + const { + code, + preferenceOptions, + selectPreferenceOptions, + selectedFramework, + settings, + onPreferenceChanged, + } = props; + const isCodeEmpty = 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); + + // 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 +

+ {!isCodeEmpty && ( + + )} +
+ + {!isCodeEmpty && ( +
+ {/* Essential settings always shown */} + + + {/* Framework-specific options */} + {selectableSettingsFiltered.length > 0 && ( +
+

+ {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" && ( + + )} + +
+ )} +
+ )} + +
+ {isCodeEmpty ? ( + + ) : ( + <> + {showCodeCopyButton && ( +
+ +
+ )} + + {displayedCode} + + {showMoreButton && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default CodePanel; diff --git a/packages/plugin-ui/src/components/ColorsPanel.tsx b/packages/plugin-ui/src/components/ColorsPanel.tsx new file mode 100644 index 00000000..eec35ffd --- /dev/null +++ b/packages/plugin-ui/src/components/ColorsPanel.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { SolidColorConversion } from "types"; + +const ColorsPanel = (props: { + colors: SolidColorConversion[]; + onColorClick: (color: string) => void; +}) => { + const [isPressed, setIsPressed] = useState(-1); + + const handleButtonClick = (value: string, idx: number) => { + setIsPressed(idx); + setTimeout(() => setIsPressed(-1), 250); + 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 ( +
+
+
+

+ Color Palette +

+ + {props.colors.length} color{props.colors.length > 1 ? "s" : ""} + +
+
+ +
+ {props.colors.map((color, idx) => ( + + ))} +
+
+ ); +}; +export default ColorsPanel; 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" ? ( +