diff --git a/README.md b/README.md index 1ca1088f..7bd98db6 100644 --- a/README.md +++ b/README.md @@ -42,20 +42,17 @@ When finding the unknown (a `Group` or `Frame` with more than one child and no v ### Tailwind limitations -- **Width:** Tailwind has a maximum width of 256px. 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 256px, the plugin's result might be less than optimal. -- **Height:** The plugin avoids setting the height whenever possible, because width and height work differently in CSS. `h-full` means get the full height of the parent, but the parent **must** have it, while `w-full` doesn't require it. During experiments, avoiding a fixed height, in most cases, brought improved responsiveness and avoided nondeterministic scenarios. +- **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. ### Flutter limits and ideas -- **Align:** currently items are aligned inside a Row/Column according to their average position. Todo: find a way to improve this. -- **Unreadable code:** output code is not formatted, but even [dartpad](https://dartpad.dev/) offers a format button. - **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 FlatButtons**: the plugin could identify specific buttons and output them instead of always using `Container` or `Material`. +- **Identify Buttons**: the plugin could identify specific buttons and output them instead of always using `Container` or `Material`. ## How to build the project -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 reccommend sticking with Webpack. If you are going to make changes in the UI, you **need** to use Rollup for now. +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. ## Issues diff --git a/__tests__/altNodes/altConversions.test.ts b/__tests__/altNodes/altConversions.test.ts index ca44d702..c7a6e9ae 100644 --- a/__tests__/altNodes/altConversions.test.ts +++ b/__tests__/altNodes/altConversions.test.ts @@ -1,3 +1,4 @@ +import { htmlMain } from "./../../src/html/htmlMain"; import { AltFrameNode } from "./../../src/altNodes/altMixins"; import { tailwindMain } from "../../src/tailwind/tailwindMain"; import { createFigma } from "figma-api-stub"; @@ -22,52 +23,70 @@ describe("AltConversions", () => { 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"; - expect(tailwindMain(convertIntoAltNodes([frame]))).toEqual( - '
' + 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( + `username
+gradient
+username
username
+${charsWithLineBreak}
`; + } +}; + +const htmlFrame = (node: AltFrameNode, isJsx: boolean = false): string => { + // const vectorIfExists = tailwindVector(node, isJsx); + // if (vectorIfExists) return vectorIfExists; + + if ( + node.children.length === 1 && + node.children[0].type === "TEXT" && + node?.name?.toLowerCase().match("input") + ) { + const isInput = true; + const [attr, char] = htmlText(node.children[0], isInput, isJsx); + return htmlContainer(node, ` placeholder="${char}"`, attr, isJsx, isInput); + } + + const childrenStr = htmlWidgetGenerator(node.children, isJsx); + + if (node.layoutMode !== "NONE") { + const rowColumn = rowColumnProps(node, isJsx); + return htmlContainer(node, childrenStr, rowColumn, isJsx); + } else { + // node.layoutMode === "NONE" && node.children.length > 1 + // children needs to be absolute + return htmlContainer( + node, + childrenStr, + formatWithJSX("position", isJsx, "relative"), + isJsx, + false, + false + ); + } +}; + +// properties named propSomething always take care of "," +// sometimes a property might not exist, so it doesn't add "," +export const htmlContainer = ( + node: AltFrameNode | AltRectangleNode | AltEllipseNode, + children: string, + additionalStyle: string = "", + isJsx: boolean, + isInput: boolean = false, + isRelative: 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 + if (node.width <= 0 || node.height <= 0) { + return children; + } + + const builder = new HtmlDefaultBuilder(node, showLayerName, isJsx) + .blend(node) + .widthHeight(node) + .autoLayoutPadding(node) + .position(node, parentId, isRelative) + .customColor(node.fills, "background-color") + // TODO image and gradient support (tailwind does not support gradients) + .shadow(node) + .border(node); + + if (isInput) { + return `\n`; + } + + if (builder.style || additionalStyle) { + const build = builder.build(additionalStyle); + + let tag = "div"; + let src = ""; + console.log("with ", node.name, "fill", retrieveTopFill(node.fills)); + if (retrieveTopFill(node.fills)?.type === "IMAGE") { + tag = "img"; + src = ` src="https://via.placeholder.com/${node.width}x${node.height}"`; + } + + if (children) { + return `\n<${tag}${build}${src}>${indentString(children)}\n${tag}>`; + } else if (selfClosingTags.includes(tag) || isJsx) { + return `\n<${tag}${build}${src} />`; + } else { + return `\n<${tag}${build}${src}>${tag}>`; + } + } + + return children; +}; + +export const rowColumnProps = (node: AltFrameNode, isJsx: boolean): string => { + // ROW or COLUMN + + // ignore current node when it has only one child and it has the same size + if ( + node.children.length === 1 && + node.children[0].width === node.width && + node.children[0].height === node.height + ) { + return ""; + } + + // [optimization] + // flex, by default, has flex-row. Therefore, it can be omitted. + const rowOrColumn = + node.layoutMode === "HORIZONTAL" + ? formatWithJSX("flex-direction", isJsx, "row") + : formatWithJSX("flex-direction", isJsx, "column"); + + // special case when there is only one children; need to position correctly in Flex. + // let justify = "justify-center"; + // if (node.children.length === 1) { + // const nodeCenteredPosX = node.children[0].x + node.children[0].width / 2; + // const parentCenteredPosX = node.width / 2; + + // const marginX = nodeCenteredPosX - parentCenteredPosX; + + // // allow a small threshold + // if (marginX < -4) { + // justify = "justify-start"; + // } else if (marginX > 4) { + // justify = "justify-end"; + // } + // } + let primaryAlign: string; + + switch (node.primaryAxisAlignItems) { + case "MIN": + primaryAlign = "flex-start"; + break; + case "CENTER": + primaryAlign = "center"; + break; + case "MAX": + primaryAlign = "flex-end"; + break; + case "SPACE_BETWEEN": + primaryAlign = "space-between"; + break; + } + + primaryAlign = formatWithJSX("justify-content", isJsx, primaryAlign); + + // [optimization] + // when all children are STRETCH and layout is Vertical, align won't matter. Otherwise, center it. + let counterAlign: string; + switch (node.counterAxisAlignItems) { + case "MIN": + counterAlign = "flex-start"; + break; + case "CENTER": + counterAlign = "center"; + break; + case "MAX": + counterAlign = "flex-end"; + break; + } + counterAlign = formatWithJSX("align-items", isJsx, counterAlign); + + // if parent is a Frame with AutoLayout set to Vertical, the current node should expand + let flex = + node.parent && + "layoutMode" in node.parent && + node.parent.layoutMode === node.layoutMode + ? "flex" + : "inline-flex"; + + flex = formatWithJSX("display", isJsx, flex); + + return `${flex}${rowOrColumn}${counterAlign}${primaryAlign}`; +}; + +const addSpacingIfNeeded = ( + node: AltSceneNode, + index: number, + len: number, + isJsx: boolean +): string => { + // Ignore this when SPACE_BETWEEN is set. + if ( + node.parent?.type === "FRAME" && + node.parent.layoutMode !== "NONE" && + node.parent.primaryAxisAlignItems !== "SPACE_BETWEEN" + ) { + // check if itemSpacing is set and if it isn't the last value. + // Don't add at the last value. In Figma, itemSpacing CAN be negative; here it can't. + if (node.parent.itemSpacing > 0 && index < len - 1) { + const wh = node.parent.layoutMode === "HORIZONTAL" ? "width" : "height"; + + // don't show the layer name in these separators. + const style = new HtmlDefaultBuilder(node, false, isJsx).build( + formatWithJSX(wh, isJsx, node.parent.itemSpacing) + ); + return isJsx ? `\n` : `\n`; + } + } + return ""; +}; diff --git a/src/html/htmlTextBuilder.ts b/src/html/htmlTextBuilder.ts new file mode 100644 index 00000000..1c953898 --- /dev/null +++ b/src/html/htmlTextBuilder.ts @@ -0,0 +1,181 @@ +import { numToAutoFixed } from "./../common/numToAutoFixed"; +import { htmlTextSize as htmlTextSizeBox } from "./builderImpl/htmlTextSize"; +import { AltTextNode } from "../altNodes/altMixins"; +import { HtmlDefaultBuilder } from "./htmlDefaultBuilder"; +import { commonLetterSpacing } from "../common/commonTextHeightSpacing"; +import { formatWithJSX } from "../common/parseJSX"; +import { convertFontWeight } from "../common/convertFontWeight"; + +export class HtmlTextBuilder extends HtmlDefaultBuilder { + constructor(node: AltTextNode, showLayerName: boolean, optIsJSX: boolean) { + super(node, showLayerName, optIsJSX); + } + + // must be called before Position method + textAutoSize(node: AltTextNode): this { + if (node.textAutoResize === "NONE") { + // going to be used for position + this.hasFixedSize = true; + } + + this.style += htmlTextSizeBox(node, this.isJSX); + return this; + } + + // todo fontFamily + // fontFamily(node: AltTextNode): this { + // return this; + // } + + /** + * https://tailwindcss.com/docs/font-size/ + * example: text-md + */ + fontSize(node: AltTextNode, isUI: boolean = false): this { + // example: text-md + if (node.fontSize !== figma.mixed) { + // special limit when used in UI. + const value = isUI ? Math.min(node.fontSize, 24) : node.fontSize; + + this.style += formatWithJSX("font-size", this.isJSX, value); + } + + return this; + } + + /** + * https://tailwindcss.com/docs/font-style/ + * example: font-extrabold + * example: italic + */ + fontStyle(node: AltTextNode): this { + if (node.fontName !== figma.mixed) { + const lowercaseStyle = node.fontName.style.toLowerCase(); + + if (lowercaseStyle.match("italic")) { + this.style += formatWithJSX("font-style", this.isJSX, "italic"); + } + + if (lowercaseStyle.match("regular")) { + // ignore the font-style when regular (default) + return this; + } + + const value = node.fontName.style + .replace("italic", "") + .replace(" ", "") + .toLowerCase(); + + const weight = convertFontWeight(value); + + if (weight !== null && weight !== "400") { + this.style += formatWithJSX("font-weight", this.isJSX, weight); + } + } + return this; + } + + /** + * https://tailwindcss.com/docs/letter-spacing/ + * example: tracking-widest + */ + letterSpacing(node: AltTextNode): this { + const letterSpacing = commonLetterSpacing(node); + if (letterSpacing > 0) { + this.style += formatWithJSX("letter-spacing", this.isJSX, letterSpacing); + } + + return this; + } + + /** + * Since Figma is built on top of HTML + CSS, lineHeight properties are easy to map. + */ + lineHeight(node: AltTextNode): this { + if (node.lineHeight !== figma.mixed) { + switch (node.lineHeight.unit) { + case "AUTO": + this.style += formatWithJSX("line-height", this.isJSX, "100%"); + break; + case "PERCENT": + this.style += formatWithJSX( + "line-height", + this.isJSX, + `${numToAutoFixed(node.lineHeight.value)}%` + ); + break; + case "PIXELS": + this.style += formatWithJSX( + "line-height", + this.isJSX, + node.lineHeight.value + ); + break; + } + } + + return this; + } + + /** + * https://tailwindcss.com/docs/text-align/ + * example: text-justify + */ + textAlign(node: AltTextNode): this { + // if alignHorizontal is LEFT, don't do anything because that is native + + // 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. + switch (node.textAlignHorizontal) { + case "CENTER": + this.style += formatWithJSX("text-align", this.isJSX, "center"); + break; + case "RIGHT": + this.style += formatWithJSX("text-align", this.isJSX, "right"); + break; + case "JUSTIFIED": + this.style += formatWithJSX("text-align", this.isJSX, "justify"); + break; + } + } + + return this; + } + + /** + * https://tailwindcss.com/docs/text-transform/ + * example: uppercase + */ + textTransform(node: AltTextNode): this { + if (node.textCase === "LOWER") { + this.style += formatWithJSX("text-transform", this.isJSX, "lowercase"); + } else if (node.textCase === "TITLE") { + this.style += formatWithJSX("text-transform", this.isJSX, "capitalize"); + } else if (node.textCase === "UPPER") { + this.style += formatWithJSX("text-transform", this.isJSX, "uppercase"); + } else if (node.textCase === "ORIGINAL") { + // default, ignore + } + + return this; + } + + /** + * https://tailwindcss.com/docs/text-decoration/ + * example: underline + */ + textDecoration(node: AltTextNode): this { + if (node.textDecoration === "UNDERLINE") { + this.style += formatWithJSX("text-decoration", this.isJSX, "underline"); + } else if (node.textDecoration === "STRIKETHROUGH") { + this.style += formatWithJSX( + "text-decoration", + this.isJSX, + "line-through" + ); + } + + return this; + } +} diff --git a/src/htmlBuilder/htmlGradient.ts b/src/htmlBuilder/htmlGradient.ts deleted file mode 100644 index fed749c5..00000000 --- a/src/htmlBuilder/htmlGradient.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { gradientAngle } from "./../common/color"; -import { retrieveFill } from "../common/retrieveFill"; -import { decomposeRelativeTransform } from "../common/color"; - -/** - * https://tailwindcss.com/docs/box-shadow/ - * example: shadow - */ -export const htmlGradient = ( - fills: ReadonlyArray${charsWithLineBreak}
`; + return `\n${charsWithLineBreak}
`; } }; -const tailwindFrame = (node: AltFrameNode): string => { - const vectorIfExists = tailwindVector(node, isJsx); - if (vectorIfExists) return vectorIfExists; +const tailwindFrame = (node: AltFrameNode, isJsx: boolean): string => { + // const vectorIfExists = tailwindVector(node, isJsx); + // if (vectorIfExists) return vectorIfExists; if ( node.children.length === 1 && node.children[0].type === "TEXT" && node?.name?.toLowerCase().match("input") ) { - const [attr, char] = tailwindText(node.children[0], true); - return tailwindContainer(node, ` placeholder="${char}"`, attr, true); + const [attr, char] = tailwindText(node.children[0], true, isJsx); + return tailwindContainer( + node, + ` placeholder="${char}"`, + attr, + { isRelative: false, isInput: true }, + isJsx + ); } - const childrenStr = tailwindWidgetGenerator(node.children); + const childrenStr = tailwindWidgetGenerator(node.children, isJsx); if (node.layoutMode !== "NONE") { const rowColumn = rowColumnProps(node); - return tailwindContainer(node, childrenStr, rowColumn); + return tailwindContainer( + node, + childrenStr, + rowColumn, + { isRelative: false, isInput: false }, + isJsx + ); } else { // node.layoutMode === "NONE" && node.children.length > 1 // children needs to be absolute - return tailwindContainer(node, childrenStr, "relative "); + return tailwindContainer( + node, + childrenStr, + "relative ", + { isRelative: true, isInput: false }, + isJsx + ); } }; @@ -150,8 +183,12 @@ const tailwindFrame = (node: AltFrameNode): string => { export const tailwindContainer = ( node: AltFrameNode | AltRectangleNode | AltEllipseNode, children: string, - additionalAttr: string = "", - isInput: boolean = false + additionalAttr: string, + attr: { + isRelative: boolean; + isInput: boolean; + }, + isJsx: boolean ): string => { // ignore the view when size is zero or less // while technically it shouldn't get less than 0, due to rounding errors, @@ -160,22 +197,39 @@ export const tailwindContainer = ( return children; } - const builder = new TailwindDefaultBuilder(isJsx, node, showLayerName) + const builder = new TailwindDefaultBuilder(node, showLayerName, isJsx) .blend(node) - .autoLayoutPadding(node) .widthHeight(node) - .position(node, parentId) + .autoLayoutPadding(node) + .position(node, parentId, attr.isRelative) .customColor(node.fills, "bg") // TODO image and gradient support (tailwind does not support gradients) .shadow(node) .border(node); - if (isInput) { + if (attr.isInput) { + // children before the > is not a typo. return `\n`; } if (builder.attributes || additionalAttr) { - return `\nGradients
+Colors
+{name}
+{colorName}
{hex}