diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index b12a4d89..4491a152 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -122,7 +122,7 @@ const codegenMode = async () => { }, { title: `Text Styles`, - code: htmlCodeGenTextStyles(false), + code: htmlCodeGenTextStyles(userPluginSettings), language: "HTML", }, ]; @@ -139,7 +139,7 @@ const codegenMode = async () => { }, { title: `Text Styles`, - code: htmlCodeGenTextStyles(true), + code: htmlCodeGenTextStyles(userPluginSettings), language: "HTML", }, ]; diff --git a/packages/backend/src/altNodes/altConversion.ts b/packages/backend/src/altNodes/altConversion.ts index 63d97f95..a6c51d71 100644 --- a/packages/backend/src/altNodes/altConversion.ts +++ b/packages/backend/src/altNodes/altConversion.ts @@ -1,16 +1,23 @@ -import { StyledTextSegmentSubset, ParentNode } from "types"; +import { StyledTextSegmentSubset, ParentNode, AltNode } from "types"; import { - overrideReadonlyProperty, assignParent, isNotEmpty, assignRectangleType, assignChildren, + isTypeOrGroupOfTypes, } from "./altNodeUtils"; -import { addWarning } from "../common/commonConversionWarnings"; 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 => { @@ -98,7 +105,12 @@ export const cloneNode = ( } assignParent(parent, cloned); - return cloned; + const altNode = { + ...cloned, + originalNode: node, + canBeFlattened: canBeFlattened(node), + } as AltNode; + return altNode; }; // auto convert Frame to Rectangle when Frame has no Children diff --git a/packages/backend/src/altNodes/altNodeUtils.ts b/packages/backend/src/altNodes/altNodeUtils.ts index f9180cb7..53de7f44 100644 --- a/packages/backend/src/altNodes/altNodeUtils.ts +++ b/packages/backend/src/altNodes/altNodeUtils.ts @@ -1,3 +1,4 @@ +import { AltNode } from "types"; import { curry } from "../common/curry"; export const overrideReadonlyProperty = curry( @@ -19,3 +20,44 @@ export function isNotEmpty( ): value is TValue { return value !== null && value !== undefined; } + +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 const renderNodeAsSVG = async (node: SceneNode) => + await node.exportAsync({ format: "SVG_STRING" }); + +export const renderAndAttachSVG = async (node: SceneNode) => { + const altNode = node as AltNode; + // const nodeName = `${node.type}:${node.id}`; + // console.log(altNode); + if (altNode.canBeFlattened) { + if (altNode.svg) { + // console.log(`SVG already rendered for ${nodeName}`); + return altNode; + } + // console.log(`${nodeName} can be flattened!`); + const svg = await renderNodeAsSVG(altNode.originalNode); + // console.log(`${svg}`); + altNode.svg = svg; + } + return altNode; +}; diff --git a/packages/backend/src/code.ts b/packages/backend/src/code.ts index 8a2ef42a..3017aded 100644 --- a/packages/backend/src/code.ts +++ b/packages/backend/src/code.ts @@ -9,7 +9,7 @@ import { clearWarnings, warnings } from "./common/commonConversionWarnings"; import { PluginSettings } from "types"; import { convertToCode } from "./common/retrieveUI/convertToCode"; -export const run = (settings: PluginSettings) => { +export const run = async (settings: PluginSettings) => { clearWarnings(); const { framework } = settings; const selection = figma.currentPage.selection; @@ -23,8 +23,12 @@ export const run = (settings: PluginSettings) => { return; } - const code = convertToCode(convertedSelection, settings); - const htmlPreview = generateHTMLPreview(convertedSelection, settings, code); + const code = await convertToCode(convertedSelection, settings); + const htmlPreview = await generateHTMLPreview( + convertedSelection, + settings, + code, + ); const colors = retrieveGenericSolidUIColors(framework); const gradients = retrieveGenericGradients(framework); diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts index 4f92d4be..d157b7ac 100644 --- a/packages/backend/src/common/commonFormatAttributes.ts +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -17,8 +17,8 @@ export const formatStyleAttribute = ( return ` style=${isJSX ? `{{${trimmedStyles}}}` : `"${trimmedStyles}"`}`; }; -export const formatLayerNameAttribute = (name: string) => - name === "" ? "" : ` data-layer="${name}"`; +export const formatDataAttribute = (label: string, value?: string) => + ` data-${label}${value === undefined ? `` : `="${value}"`}`; export const formatClassAttribute = ( classes: string[], diff --git a/packages/backend/src/common/nodeVisibility.ts b/packages/backend/src/common/nodeVisibility.ts new file mode 100644 index 00000000..8e3959d7 --- /dev/null +++ b/packages/backend/src/common/nodeVisibility.ts @@ -0,0 +1,4 @@ +type VisibilityMixin = { visible: boolean }; +const isVisible = (node: VisibilityMixin) => node.visible; +export const getVisibleNodes = (nodes: readonly SceneNode[]) => + nodes.filter(isVisible); diff --git a/packages/backend/src/common/retrieveFill.ts b/packages/backend/src/common/retrieveFill.ts index 151c00c6..233c2edb 100644 --- a/packages/backend/src/common/retrieveFill.ts +++ b/packages/backend/src/common/retrieveFill.ts @@ -2,7 +2,7 @@ * 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 | PluginAPI["mixed"] | undefined, ): Paint | undefined => { if (fills && fills !== figma.mixed && fills.length > 0) { // on Figma, the top layer is always at the last position diff --git a/packages/backend/src/common/retrieveUI/convertToCode.ts b/packages/backend/src/common/retrieveUI/convertToCode.ts index 94ef45b6..a8298be3 100644 --- a/packages/backend/src/common/retrieveUI/convertToCode.ts +++ b/packages/backend/src/common/retrieveUI/convertToCode.ts @@ -4,16 +4,19 @@ import { htmlMain } from "../../html/htmlMain"; import { swiftuiMain } from "../../swiftui/swiftuiMain"; import { tailwindMain } from "../../tailwind/tailwindMain"; -export const convertToCode = (nodes: SceneNode[], settings: PluginSettings) => { +export const convertToCode = async ( + nodes: SceneNode[], + settings: PluginSettings, +) => { switch (settings.framework) { case "Tailwind": - return tailwindMain(nodes, settings); + return await tailwindMain(nodes, settings); case "Flutter": - return flutterMain(nodes, settings); + return await flutterMain(nodes, settings); case "SwiftUI": - return swiftuiMain(nodes, settings); + return await swiftuiMain(nodes, settings); case "HTML": default: - return htmlMain(nodes, settings); + return await htmlMain(nodes, settings); } }; diff --git a/packages/backend/src/html/builderImpl/htmlAutoLayout.ts b/packages/backend/src/html/builderImpl/htmlAutoLayout.ts index 98eee1d4..5df954ef 100644 --- a/packages/backend/src/html/builderImpl/htmlAutoLayout.ts +++ b/packages/backend/src/html/builderImpl/htmlAutoLayout.ts @@ -1,3 +1,4 @@ +import { HTMLSettings, PluginSettings } from "types"; import { formatMultipleJSXArray } from "../../common/parseJSX"; const getFlexDirection = (node: InferredAutoLayoutResult): string => @@ -47,7 +48,7 @@ const getFlex = ( export const htmlAutoLayoutProps = ( node: SceneNode, autoLayout: InferredAutoLayoutResult, - isJsx: boolean, + settings: HTMLSettings, ): string[] => formatMultipleJSXArray( { @@ -57,5 +58,5 @@ export const htmlAutoLayoutProps = ( gap: getGap(autoLayout), display: getFlex(node, autoLayout), }, - isJsx, + settings.jsx, ); diff --git a/packages/backend/src/html/builderImpl/htmlBorderRadius.ts b/packages/backend/src/html/builderImpl/htmlBorderRadius.ts index 0d70930f..f2f7fc39 100644 --- a/packages/backend/src/html/builderImpl/htmlBorderRadius.ts +++ b/packages/backend/src/html/builderImpl/htmlBorderRadius.ts @@ -40,29 +40,7 @@ export const htmlBorderRadius = (node: SceneNode, isJsx: boolean): string[] => { 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")); - // } } return comp; diff --git a/packages/backend/src/html/builderImpl/htmlColor.ts b/packages/backend/src/html/builderImpl/htmlColor.ts index 22730646..561d3280 100644 --- a/packages/backend/src/html/builderImpl/htmlColor.ts +++ b/packages/backend/src/html/builderImpl/htmlColor.ts @@ -3,7 +3,7 @@ import { retrieveTopFill } from "../../common/retrieveFill"; // retrieve the SOLID color on HTML export const htmlColorFromFills = ( - fills: ReadonlyArray | PluginAPI["mixed"], + fills: ReadonlyArray | PluginAPI["mixed"] | undefined, ): string => { // kind can be text, bg, border... // [when testing] fills can be undefined diff --git a/packages/backend/src/html/htmlDefaultBuilder.ts b/packages/backend/src/html/htmlDefaultBuilder.ts index 3a50d9b2..6fb998a2 100644 --- a/packages/backend/src/html/htmlDefaultBuilder.ts +++ b/packages/backend/src/html/htmlDefaultBuilder.ts @@ -21,42 +21,55 @@ import { sliceNum, stringToClassName } from "../common/numToAutoFixed"; import { commonStroke } from "../common/commonStroke"; import { formatClassAttribute, - formatLayerNameAttribute, + formatDataAttribute, formatStyleAttribute, } from "../common/commonFormatAttributes"; +import { HTMLSettings } from "types"; export class HtmlDefaultBuilder { styles: Array; - isJSX: boolean; - visible: boolean; - name: string; + data: Array; + node: SceneNode; + settings: HTMLSettings; - constructor(node: SceneNode, showLayerNames: boolean, optIsJSX: boolean) { - this.isJSX = optIsJSX; + get name() { + return this.settings.showLayerNames ? this.node.name : ""; + } + get visible() { + return this.node.visible; + } + get isJSX() { + return this.settings.jsx; + } + get optimizeLayout() { + return this.settings.optimizeLayout; + } + + constructor(node: SceneNode, settings: HTMLSettings) { + this.node = node; + this.settings = settings; this.styles = []; - this.visible = node.visible; - this.name = showLayerNames ? node.name : ""; + this.data = []; } - 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.blur(); return this; } @@ -64,17 +77,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(): this { + const { node } = this; this.addStyles(...htmlBorderRadius(node, this.isJSX)); const commonBorder = commonStroke(node); @@ -82,8 +97,10 @@ 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); + const borderStyle = + "dashPattern" in node && node.dashPattern.length > 0 ? "dotted" : "solid"; const consolidateBorders = (border: number): string => [`${sliceNum(border)}px`, color, borderStyle].filter((d) => d).join(" "); @@ -137,14 +154,15 @@ export class HtmlDefaultBuilder { return this; } - position(node: SceneNode, optimizeLayout: boolean): this { + position(): this { + const { node, optimizeLayout, isJSX } = this; if (commonIsAbsolutePosition(node, optimizeLayout)) { const { x, y } = getCommonPositionValue(node); 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 ( @@ -153,7 +171,7 @@ export class HtmlDefaultBuilder { ((optimizeLayout ? node.inferredAutoLayout : null) ?? node) ?.layoutMode === "NONE") ) { - this.addStyles(formatWithJSX("position", this.isJSX, "relative")); + this.addStyles(formatWithJSX("position", isJSX, "relative")); } } @@ -210,20 +228,24 @@ export class HtmlDefaultBuilder { return styles.filter((value) => value !== "").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 } = htmlSizePartial( + node, + settings.jsx, + settings.optimizeLayout, + ); if (node.type === "TEXT") { switch (node.textAutoResize) { @@ -244,19 +266,21 @@ export class HtmlDefaultBuilder { return this; } - autoLayoutPadding(node: SceneNode, optimizeLayout: boolean): this { + autoLayoutPadding(): this { + const { node, isJSX, optimizeLayout } = this; if ("paddingLeft" in node) { this.addStyles( ...htmlPadding( (optimizeLayout ? node.inferredAutoLayout : null) ?? node, - this.isJSX, + 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, @@ -286,17 +310,28 @@ export class HtmlDefaultBuilder { } } + 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 layerNameAttribute = formatLayerNameAttribute(this.name); - const layerNameClass = stringToClassName(this.name); - const classAttribute = formatClassAttribute( - layerNameClass === "" ? [] : [layerNameClass], - this.isJSX, - ); + let classAttribute = ""; + if (this.name) { + this.addData("layer", this.name); + const layerNameClass = stringToClassName(this.name); + classAttribute = formatClassAttribute( + layerNameClass === "" ? [] : [layerNameClass], + this.isJSX, + ); + } + + const dataAttributes = this.data.join(""); const styleAttribute = formatStyleAttribute(this.styles, this.isJSX); - return `${layerNameAttribute}${classAttribute}${styleAttribute}`; + return `${dataAttributes}${classAttribute}${styleAttribute}`; } } diff --git a/packages/backend/src/html/htmlMain.ts b/packages/backend/src/html/htmlMain.ts index 45a59a4f..92f2c026 100644 --- a/packages/backend/src/html/htmlMain.ts +++ b/packages/backend/src/html/htmlMain.ts @@ -6,28 +6,25 @@ import { htmlAutoLayoutProps } from "./builderImpl/htmlAutoLayout"; import { formatWithJSX } from "../common/parseJSX"; import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; import { addWarning } from "../common/commonConversionWarnings"; -import { PluginSettings, HTMLPreview } from "types"; - -let showLayerNames = false; +import { PluginSettings, HTMLPreview, AltNode, HTMLSettings } from "types"; +import { renderAndAttachSVG } from "../altNodes/altNodeUtils"; +import { getVisibleNodes } from "../common/nodeVisibility"; const selfClosingTags = ["img"]; export let isPreviewGlobal = false; -let localSettings: PluginSettings; let previousExecutionCache: { style: string; text: string }[]; -export const htmlMain = ( +export const htmlMain = async ( sceneNode: Array, settings: PluginSettings, isPreview: boolean = false, -): string => { - showLayerNames = settings.showLayerNames; +): Promise => { isPreviewGlobal = isPreview; previousExecutionCache = []; - localSettings = settings; - let result = htmlWidgetGenerator(sceneNode, settings.jsx); + let result = await htmlWidgetGenerator(sceneNode, settings); // remove the initial \n that is made in Container. if (result.length > 0 && result.startsWith("\n")) { @@ -37,16 +34,16 @@ export const htmlMain = ( return result; }; -export const generateHTMLPreview = ( +export const generateHTMLPreview = async ( nodes: SceneNode[], settings: PluginSettings, code?: string, -): HTMLPreview => { +): Promise => { const htmlCodeAlreadyGenerated = settings.framework === "HTML" && settings.jsx === false && code; const htmlCode = htmlCodeAlreadyGenerated ? code - : htmlMain( + : await htmlMain( nodes, { ...settings, @@ -65,52 +62,63 @@ export const generateHTMLPreview = ( }; // 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); - addWarning("VectorNodes are not fully supported in HTML"); - break; - } - }); + const promiseOfConvertedCode = getVisibleNodes(sceneNode).map( + convertNode(settings), + ); + const code = (await Promise.all(promiseOfConvertedCode)).join(""); + return code; +}; - return comp; +const convertNode = (settings: HTMLSettings) => async (node: SceneNode) => { + const altNode = await renderAndAttachSVG(node); + if (altNode.svg) return htmlWrapSVG(altNode, settings); + + switch (node.type) { + case "RECTANGLE": + case "ELLIPSE": + return htmlContainer(node, "", [], settings); + case "GROUP": + return htmlGroup(node, settings); + case "FRAME": + case "COMPONENT": + case "INSTANCE": + case "COMPONENT_SET": + return htmlFrame(node, settings); + case "SECTION": + return htmlSection(node, settings); + case "TEXT": + return htmlText(node, settings); + case "LINE": + return htmlLine(node, settings); + case "VECTOR": + addWarning("VectorNodes are not fully supported in HTML"); + return htmlAsset(node, settings); + default: + } + 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(); + + return `\n\n${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 @@ -119,32 +127,25 @@ 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, - showLayerNames, - 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

). -const htmlText = (node: TextNode, isJsx: boolean): string => { - let layoutBuilder = new HtmlTextBuilder(node, showLayerNames, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .textAlign(node); +const htmlText = (node: TextNode, settings: HTMLSettings): string => { + let layoutBuilder = new HtmlTextBuilder(node, settings) + .commonPositionStyles() + .textAlign(); const styledHtml = layoutBuilder.getTextSegments(node.id); previousExecutionCache.push(...styledHtml); @@ -182,45 +183,42 @@ const htmlText = (node: TextNode, isJsx: boolean): string => { 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( + commonSortChildrenWhenInferredAutoLayout(node, settings.optimizeLayout), + settings, ); if (node.layoutMode !== "NONE") { - const rowColumn = htmlAutoLayoutProps(node, node, isJsx); - return htmlContainer(node, childrenStr, rowColumn, isJsx); + const rowColumn = htmlAutoLayoutProps(node, node, settings); + return htmlContainer(node, childrenStr, rowColumn, settings); } else { - if (localSettings.optimizeLayout && node.inferredAutoLayout !== null) { + if (settings.optimizeLayout && node.inferredAutoLayout !== null) { const rowColumn = htmlAutoLayoutProps( node, node.inferredAutoLayout, - isJsx, + settings, ); - return htmlContainer(node, childrenStr, rowColumn, isJsx); + return htmlContainer(node, childrenStr, rowColumn, settings); } // node.layoutMode === "NONE" && node.children.length > 1 // children needs to be absolute - return htmlContainer(node, childrenStr, [], isJsx); + return htmlContainer(node, childrenStr, [], settings); } }; -const htmlAsset = (node: SceneNode, isJsx: boolean = false): string => { +const htmlAsset = (node: SceneNode, settings: HTMLSettings): string => { if (!("opacity" in node) || !("layoutAlign" in node) || !("fills" in node)) { return ""; } - const builder = new HtmlDefaultBuilder(node, showLayerNames, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .commonShapeStyles(node); + const builder = new HtmlDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); let tag = "div"; let src = ""; @@ -250,7 +248,7 @@ const htmlContainer = ( MinimalBlendMixin, children: string, additionalStyles: string[] = [], - isJsx: boolean, + settings: HTMLSettings, ): string => { // ignore the view when size is zero or less // while technically it shouldn't get less than 0, due to rounding errors, @@ -259,9 +257,9 @@ const htmlContainer = ( return children; } - const builder = new HtmlDefaultBuilder(node, showLayerNames, isJsx) - .commonPositionStyles(node, localSettings.optimizeLayout) - .commonShapeStyles(node); + const builder = new HtmlDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); if (builder.styles || additionalStyles) { let tag = "div"; @@ -277,7 +275,7 @@ const htmlContainer = ( builder.addStyles( formatWithJSX( "background-image", - isJsx, + settings.jsx, `url(https://via.placeholder.com/${node.width.toFixed( 0, )}x${node.height.toFixed(0)})`, @@ -290,7 +288,7 @@ const htmlContainer = ( if (children) { return `\n<${tag}${build}${src}>${indentString(children)}\n`; - } else if (selfClosingTags.includes(tag) || isJsx) { + } else if (selfClosingTags.includes(tag) || settings.jsx) { return `\n<${tag}${build}${src} />`; } else { return `\n<${tag}${build}${src}>`; @@ -300,11 +298,14 @@ const htmlContainer = ( return children; }; -const htmlSection = (node: SectionNode, isJsx: boolean = false): string => { - const childrenStr = htmlWidgetGenerator(node.children, isJsx); - const builder = new HtmlDefaultBuilder(node, showLayerNames, isJsx) - .size(node, localSettings.optimizeLayout) - .position(node, localSettings.optimizeLayout) +const htmlSection = async ( + node: SectionNode, + settings: HTMLSettings, +): Promise => { + const childrenStr = await htmlWidgetGenerator(node.children, settings); + const builder = new HtmlDefaultBuilder(node, settings) + .size() + .position() .applyFillsToStyle(node.fills, "background"); if (childrenStr) { @@ -314,19 +315,19 @@ const htmlSection = (node: SectionNode, isJsx: boolean = false): string => { } }; -const htmlLine = (node: LineNode, isJsx: boolean): string => { - const builder = new HtmlDefaultBuilder(node, showLayerNames, 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.jsx ? "," : ";").join(";\n")}`, ) .join("\n---\n"); diff --git a/packages/backend/src/html/htmlTextBuilder.ts b/packages/backend/src/html/htmlTextBuilder.ts index e3e6a33d..f66d256c 100644 --- a/packages/backend/src/html/htmlTextBuilder.ts +++ b/packages/backend/src/html/htmlTextBuilder.ts @@ -6,15 +6,14 @@ import { commonLetterSpacing, commonLineHeight, } from "../common/commonTextHeightSpacing"; +import { HTMLSettings } from "types"; export class HtmlTextBuilder extends HtmlDefaultBuilder { - constructor(node: TextNode, showLayerNames: boolean, optIsJSX: boolean) { - super(node, showLayerNames, optIsJSX); + constructor(node: TextNode, settings: HTMLSettings) { + super(node, settings); } - getTextSegments( - id: string, - ): { + getTextSegments(id: string): { style: string; text: string; openTypeFeatures: { [key: string]: boolean }; @@ -117,7 +116,8 @@ export class HtmlTextBuilder extends HtmlDefaultBuilder { return ""; } - textAlign(node: TextNode): this { + textAlign(): this { + const node = this.node as TextNode; // if alignHorizontal is LEFT, don't do anything because that is native // only undefined in testing diff --git a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts index 954e0f30..9160828a 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -22,10 +22,10 @@ import { } from "../common/commonPosition"; import { pxToBlur } from "./conversionTables"; import { + formatDataAttribute, getClassLabel, - formatStyleAttribute, } from "../common/commonFormatAttributes"; -import { TailwindColorType } from "types"; +import { TailwindColorType, TailwindSettings } from "types"; const isNotEmpty = (s: string) => s !== ""; const dropEmptyStrings = (strings: string[]) => strings.filter(isNotEmpty); @@ -33,23 +33,30 @@ 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; - constructor(node: SceneNode, showLayerNames: boolean, optIsJSX: boolean) { - this.isJSX = optIsJSX; + get name() { + return this.settings.showLayerNames ? this.node.name : ""; + } + get visible() { + return this.node.visible; + } + get isJSX() { + return this.settings.jsx; + } + get optimizeLayout() { + return this.settings.optimizeLayout; + } + + constructor(node: SceneNode, settings: TailwindSettings) { + this.node = node; + this.settings = settings; this.styleSeparator = this.isJSX ? "," : ";"; this.style = ""; - this.visible = node.visible; - this.name = showLayerNames ? node.name : ""; - - /* - if (showLayerNames) { - this.attributes.push(className(node.name)); - } - */ + this.data = []; } addAttributes = (...newStyles: string[]) => { @@ -59,62 +66,55 @@ export class TailwindDefaultBuilder { this.attributes.unshift(...dropEmptyStrings(newStyles)); }; - 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 MinimalFillsMixin).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) { + this.addAttributes(tailwindBorderWidth(this.node)); + this.customColor(this.node.strokes, "border"); } return this; } - position(node: SceneNode, optimizeLayout: boolean): this { + position(): this { + const { node, optimizeLayout } = this; + if (commonIsAbsolutePosition(node, optimizeLayout)) { const { x, y } = getCommonPositionValue(node); @@ -172,13 +172,14 @@ 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 { + size(): this { + const { node, optimizeLayout } = this; const { width, height } = tailwindSizePartial(node, optimizeLayout); if (node.type === "TEXT") { @@ -200,18 +201,20 @@ export class TailwindDefaultBuilder { return this; } - autoLayoutPadding(node: SceneNode, optimizeLayout: boolean): this { - if ("paddingLeft" in node) { + autoLayoutPadding(): this { + if ("paddingLeft" in this.node) { this.addAttributes( ...tailwindPadding( - (optimizeLayout ? node.inferredAutoLayout : null) ?? node, + (this.optimizeLayout ? this.node.inferredAutoLayout : null) ?? + 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"); if (blur) { @@ -239,13 +242,21 @@ export class TailwindDefaultBuilder { } } + addData(label: string, value?: string): this { + const attribute = formatDataAttribute(label, value); + this.data.push(attribute); + return this; + } + build(additionalAttr = ""): string { this.addAttributes(additionalAttr); if (this.name !== "") { this.prependAttributes(stringToClassName(this.name)); } - const layerName = this.name ? ` data-layer="${this.name}"` : ""; + if (this.name) { + this.addData("layer", this.name); + } const classLabel = getClassLabel(this.isJSX); const classNames = @@ -253,12 +264,14 @@ export class TailwindDefaultBuilder { ? ` ${classLabel}="${this.attributes.join(" ")}"` : ""; const styles = this.style.length > 0 ? ` style="${this.style}"` : ""; + const dataAttributes = this.data.join(""); - return `${layerName}${classNames}${styles}`; + 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 10913d22..ff3d3441 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -4,8 +4,10 @@ import { TailwindTextBuilder } from "./tailwindTextBuilder"; import { TailwindDefaultBuilder } from "./tailwindDefaultBuilder"; import { tailwindAutoLayoutProps } from "./builderImpl/tailwindAutoLayout"; import { commonSortChildrenWhenInferredAutoLayout } from "../common/commonChildrenOrder"; -import { PluginSettings } from "types"; +import { AltNode, PluginSettings, TailwindSettings } from "types"; import { addWarning } from "../common/commonConversionWarnings"; +import { renderAndAttachSVG } from "../altNodes/altNodeUtils"; +import { getVisibleNodes } from "../common/nodeVisibility"; export let localTailwindSettings: PluginSettings; @@ -13,14 +15,14 @@ let previousExecutionCache: { style: string; text: string }[]; const selfClosingTags = ["img"]; -export const tailwindMain = ( +export const tailwindMain = async ( sceneNode: Array, settings: PluginSettings, -): string => { +) => { 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")) { @@ -30,49 +32,62 @@ export const tailwindMain = ( return result; }; -// todo lint idea: replace BorderRadius.only(topleft: 8, topRight: 8) with BorderRadius.horizontal(8) -const tailwindWidgetGenerator = ( +// TODO: lint idea: replace BorderRadius.only(topleft: 8, topRight: 8) with BorderRadius.horizontal(8) +const tailwindWidgetGenerator = async ( sceneNode: ReadonlyArray, - isJsx: boolean, -): string => { - let comp = ""; - + settings: TailwindSettings, +): 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) => { - switch (node.type) { - case "RECTANGLE": - case "ELLIPSE": - comp += tailwindContainer(node, "", "", isJsx); - break; - case "GROUP": - comp += tailwindGroup(node, isJsx); - break; - case "FRAME": - case "COMPONENT": - case "INSTANCE": - case "COMPONENT_SET": - comp += tailwindFrame(node, isJsx); - break; - case "TEXT": - comp += tailwindText(node, isJsx); - break; - case "LINE": - comp += tailwindLine(node, isJsx); - break; - case "SECTION": - comp += tailwindSection(node, isJsx); - break; - case "VECTOR": - addWarning("VectorNodes are not supported in Tailwind"); - break; - } - }); + const promiseOfConvertedCode = getVisibleNodes(sceneNode).map( + convertNode(settings), + ); + const code = (await Promise.all(promiseOfConvertedCode)).join(""); + return code; +}; + +const convertNode = (settings: TailwindSettings) => async (node: SceneNode) => { + const altNode = await renderAndAttachSVG(node); + if (altNode.svg) return tailwindWrapSVG(altNode, settings); + + switch (node.type) { + case "RECTANGLE": + case "ELLIPSE": + return tailwindContainer(node, "", "", settings); + case "GROUP": + return tailwindGroup(node, settings); + case "FRAME": + case "COMPONENT": + case "INSTANCE": + case "COMPONENT_SET": + return tailwindFrame(node, settings); + case "TEXT": + return tailwindText(node, settings); + case "LINE": + return tailwindLine(node, settings); + case "SECTION": + return tailwindSection(node, settings); + case "VECTOR": + addWarning("VectorNodes are not supported in Tailwind"); + break; + default: + addWarning(`${node.type} nodes are not supported in Tailwind`); + } + return ""; +}; + +const tailwindWrapSVG = ( + node: AltNode, + settings: TailwindSettings, +): string => { + if (node.svg === "") return ""; + const builder = new TailwindDefaultBuilder(node, settings) + .addData("svg-wrapper") + .position(); - return comp; + return `\n\n${node.svg ?? ""}`; }; -const tailwindGroup = (node: GroupNode, isJsx: boolean = false): string => { +const tailwindGroup = async (node: GroupNode, settings: TailwindSettings) => { // ignore the view when size is zero or less // while technically it shouldn't get less than 0, due to rounding errors, // it can get to values like: -0.000004196293048153166 @@ -82,34 +97,29 @@ const tailwindGroup = (node: GroupNode, isJsx: boolean = false): string => { } // this needs to be called after CustomNode because widthHeight depends on it - const builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.showLayerNames, - 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.showLayerNames, - isJsx, - ) - .commonPositionStyles(node, localTailwindSettings.optimizeLayout) - .textAlign(node); +export const tailwindText = ( + node: TextNode, + settings: TailwindSettings, +): string => { + let layoutBuilder = new TailwindTextBuilder(node, settings) + .commonPositionStyles() + .textAlign(); const styledHtml = layoutBuilder.getTextSegments(node.id); previousExecutionCache.push(...styledHtml); @@ -147,16 +157,16 @@ export const tailwindText = (node: TextNode, isJsx: boolean): string => { return `\n${content}`; }; -const tailwindFrame = ( +const tailwindFrame = async ( node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, - isJsx: boolean, -): string => { - const childrenStr = tailwindWidgetGenerator( + settings: TailwindSettings, +): Promise => { + const childrenStr = await tailwindWidgetGenerator( commonSortChildrenWhenInferredAutoLayout( node, localTailwindSettings.optimizeLayout, ), - isJsx, + settings, ); // Add overflow-hidden class if clipsContent is true @@ -168,7 +178,7 @@ const tailwindFrame = ( node, childrenStr, rowColumn + clipsContentClass, - isJsx, + settings, ); } else { if ( @@ -180,13 +190,13 @@ const tailwindFrame = ( node, childrenStr, rowColumn + clipsContentClass, - isJsx, + settings, ); } // node.layoutMode === "NONE" && node.children.length > 1 // children needs to be absolute - return tailwindContainer(node, childrenStr, clipsContentClass, isJsx); + return tailwindContainer(node, childrenStr, clipsContentClass, settings); } }; @@ -201,7 +211,7 @@ 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, @@ -210,13 +220,9 @@ export const tailwindContainer = ( return children; } - let builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.showLayerNames, - isJsx, - ) - .commonPositionStyles(node, localTailwindSettings.optimizeLayout) - .commonShapeStyles(node); + let builder = new TailwindDefaultBuilder(node, settings) + .commonPositionStyles() + .commonShapeStyles(); if (builder.attributes || additionalAttr) { const build = builder.build(additionalAttr); @@ -242,7 +248,7 @@ export const tailwindContainer = ( if (children) { return `\n<${tag}${build}${src}>${indentString(children)}\n`; - } else if (selfClosingTags.includes(tag) || isJsx) { + } else if (selfClosingTags.includes(tag) || settings.jsx) { return `\n<${tag}${build}${src} />`; } else { return `\n<${tag}${build}${src}>`; @@ -252,27 +258,25 @@ export const tailwindContainer = ( return children; }; -export const tailwindLine = (node: LineNode, isJsx: boolean): string => { - const builder = new TailwindDefaultBuilder( - node, - localTailwindSettings.showLayerNames, - 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.showLayerNames, - 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) { diff --git a/packages/backend/src/tailwind/tailwindTextBuilder.ts b/packages/backend/src/tailwind/tailwindTextBuilder.ts index dbfc24b2..9c23e03d 100644 --- a/packages/backend/src/tailwind/tailwindTextBuilder.ts +++ b/packages/backend/src/tailwind/tailwindTextBuilder.ts @@ -170,9 +170,9 @@ export class TailwindTextBuilder extends TailwindDefaultBuilder { * https://tailwindcss.com/docs/text-align/ * example: text-justify */ - textAlign(node: TextNode): this { + textAlign(): 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. diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 82414dc1..992008d5 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -1,20 +1,30 @@ // Settings export type Framework = "Flutter" | "SwiftUI" | "HTML" | "Tailwind"; - -export interface PluginSettings { - framework: Framework; +export interface HTMLSettings { jsx: boolean; - inlineStyle: boolean; optimizeLayout: boolean; showLayerNames: boolean; - responsiveRoot: boolean; - flutterGenerationMode: string; - swiftUIGenerationMode: string; +} +export interface TailwindSettings extends HTMLSettings { roundTailwindValues: boolean; roundTailwindColors: boolean; customTailwindColors: boolean; } - +export interface FlutterSettings { + flutterGenerationMode: string; +} +export interface SwiftUISettings { + swiftUIGenerationMode: string; +} +export interface PluginSettings + extends HTMLSettings, + TailwindSettings, + FlutterSettings, + SwiftUISettings { + framework: Framework; + inlineStyle: boolean; + responsiveRoot: boolean; +} // Messaging export interface ConversionData { code: string; @@ -55,6 +65,13 @@ export type ErrorMessage = Message & { // Nodes export type ParentNode = BaseNode & ChildrenMixin; +export type AltNodeMetadata = { + originalNode: T; + canBeFlattened: boolean; + svg?: string; +}; +export type AltNode = T & AltNodeMetadata; + // Styles & Conversions export type LayoutMode =