diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx index 67a0ed251e08..6e7d860d9675 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx @@ -3288,6 +3288,7 @@ describe("Styles", () => { background-image: linear-gradient(350deg,hsl(256.3636363636363 72.13% 23.92%/0.00),hsl(256.2162162162162 72.55% 80.00%/1.00) 49%,#bba7f1); -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; color: transparent } }" diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 4471ffcd8cb0..74b235aa2d1d 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -150,6 +150,32 @@ test("generate props from number and boolean aria attributes", () => { ); }); +test("skip webstudio runtime attributes from pasted html", () => { + const fragment = generateFragmentFromHtml(` + View solutions + `); + + expect(fragment.props).toEqual([ + expect.objectContaining({ name: "href", value: "#services" }), + ]); + expect(fragment.props.some((prop) => prop.name.startsWith("data-ws-"))).toBe( + false + ); + expect(fragment.instances[0]).toEqual( + expect.objectContaining({ + tag: "a", + children: [{ type: "text", value: "View solutions " }], + }) + ); +}); + test("wrap text with span when spotted outside of rich text", () => { expect( generateFragmentFromHtml(` diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index 0577c249dae8..8d8ee11aaca1 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -26,7 +26,10 @@ import { } from "@webstudio-is/css-data"; import { richTextContentTags } from "./content-model"; import { setIsSubsetOf } from "./shim"; -import { isAttributeNameSafe } from "@webstudio-is/react-sdk"; +import { + isAttributeNameSafe, + textContentAttribute, +} from "@webstudio-is/react-sdk"; import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import * as csstree from "css-tree"; import { titleCase } from "title-case"; @@ -34,6 +37,7 @@ import { titleCase } from "title-case"; type ElementNode = DefaultTreeAdapterMap["element"]; const spaceRegex = /^\s*$/; +const wsAttributePrefix = "data-ws-"; const getAttributeType = ( attribute: (typeof ariaAttributes)[number] @@ -742,7 +746,15 @@ export const generateFragmentFromHtml = ( delete instance.tag; } instances.set(instance.id, instance); + const wsTextContentAttr = node.attrs.find( + (attr) => attr.name === textContentAttribute + ); for (const attr of node.attrs) { + // Webstudio runtime metadata can appear when users copy rendered canvas + // DOM. Do not import it as user-authored attributes. + if (attr.name.startsWith(wsAttributePrefix)) { + continue; + } // skip attributes which cannot be rendered in jsx if (!isAttributeNameSafe(attr.name)) { continue; @@ -887,6 +899,21 @@ export const generateFragmentFromHtml = ( } } } + if ( + wsTextContentAttr !== undefined && + node.tagName !== "textarea" && + node.childNodes.every((childNode) => + defaultTreeAdapter.isTextNode(childNode) + ) + ) { + if (wsTextContentAttr.value !== "") { + instance.children.push({ + type: "text", + value: wsTextContentAttr.value, + }); + } + return { type: "id" as const, value: instance.id }; + } let spaceAttachedToPrev = false; for (let index = 0; index < node.childNodes.length; index += 1) { const childNode = node.childNodes[index]; diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx index d58fb71df53d..502511c939a9 100644 --- a/apps/builder/app/shared/tailwind/tailwind.test.tsx +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { css, renderTemplate, ws } from "@webstudio-is/template"; -import { generateFragmentFromTailwind } from "./tailwind"; +import { __testing__, generateFragmentFromTailwind } from "./tailwind"; const getBaseStyleValue = ( fragment: Awaited>, @@ -41,6 +41,24 @@ const getStyleValue = ( )?.value; }; +test("normalize unocss output for webstudio parser", () => { + const { css: normalizedCss } = __testing__.normalizeUnoCssForWebstudio(` + @property --un-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --un-from-opacity { syntax: ""; inherits: false; initial-value: 100%; } + .rounded-full { border-radius: calc(infinity * 1px); } + .from-brand { --un-gradient-from: color-mix(in oklab, #F5A623 var(--un-from-opacity), transparent); } + .bg-gradient { background-image: linear-gradient(to bottom right in oklab, var(--un-gradient-from) 0%, #E8920A 100%); } + .shadow { box-shadow: var(--un-shadow); } + `); + + expect(normalizedCss).toContain("--tw-shadow"); + expect(normalizedCss).toContain("border-radius: 9999px"); + expect(normalizedCss).toMatch( + /linear-gradient\(to bottom right,\s*#F5A623 0%, #E8920A 100%\)/ + ); + expect(normalizedCss).toContain("box-shadow: 0 0 #0000"); +}); + test("extract local styles from tailwind classes", async () => { expect( await generateFragmentFromTailwind( @@ -81,7 +99,6 @@ test("ignore dark mode", async () => { expect(getStyleValue(fragment, "backgroundColor")).toEqual( expect.objectContaining({ type: "color", - colorSpace: "srgb", components: [1, 1, 1], }) ); @@ -264,13 +281,288 @@ test("generate shadow", async () => { type: "layers", value: expect.arrayContaining([ expect.objectContaining({ - type: "var", - value: "tw-ring-offset-shadow", + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 1 }), + blur: expect.objectContaining({ unit: "px", value: 3 }), + }), + expect.objectContaining({ + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 1 }), + blur: expect.objectContaining({ unit: "px", value: 2 }), + spread: expect.objectContaining({ unit: "px", value: -1 }), + }), + ]), + }) + ); +}); + +test("generate arbitrary gradient, full radius and shadow", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + Free consultation + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: expect.stringContaining("#F5A623"), + }), + ]), + }) + ); + expect(getStyleValue(fragment, "borderTopLeftRadius")).toEqual( + expect.objectContaining({ type: "unit", unit: "px", value: 9999 }) + ); + expect(getStyleValue(fragment, "boxShadow")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 4 }), + blur: expect.objectContaining({ unit: "px", value: 20 }), + color: expect.objectContaining({ alpha: 0.35 }), + }), + ]), + }) + ); +}); + +test("generate clipped gradient text", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + Your solar plant + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "color")).toEqual( + expect.objectContaining({ type: "keyword", value: "transparent" }) + ); + expect(getStyleValue(fragment, "backgroundClip")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ type: "keyword", value: "text" }), + ]), + }) + ); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: expect.stringContaining("#F97316"), + }), + ]), + }) + ); +}); + +test("generate gradient background with via color stop", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: + "linear-gradient(to bottom right,#0A2830 0%,#0D4F5C 50%,#0A3040 100%)", }), - expect.objectContaining({ type: "var", value: "tw-ring-shadow" }), ]), }) ); + expect(getStyleValue(fragment, "paddingBlockStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 6 }) + ); + expect(getStyleValue(fragment, "paddingInlineStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 1 }) + ); +}); + +test("input padding utilities override preflight reset", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + + + + ) + ); + + const inputStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "1") + ?.values.at(-1); + const inputStyles = fragment.styles.filter( + (style) => style.styleSourceId === inputStyleSourceId + ); + + expect(inputStyles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "width", + value: expect.objectContaining({ unit: "%", value: 100 }), + }), + expect.objectContaining({ + property: "paddingInlineStart", + value: expect.objectContaining({ unit: "rem", value: 1 }), + }), + expect.objectContaining({ + property: "paddingBlockStart", + value: expect.objectContaining({ unit: "rem", value: 0.75 }), + }), + ]) + ); + expect( + inputStyles.some((style) => + ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].includes( + style.property + ) + ) + ).toBe(false); + expect(getStyleValue(fragment, "gridTemplateColumns")).toEqual( + expect.objectContaining({ + type: "unparsed", + value: "repeat(2,minmax(0,1fr))", + }) + ); +}); + +test("axis padding utilities do not clear unrelated inline padding", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + ) + ); + + expect(getStyleValue(fragment, "paddingTop")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 2 }) + ); + expect(getStyleValue(fragment, "paddingInlineStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 1 }) + ); + expect(getStyleValue(fragment, "paddingRight")).toBeUndefined(); +}); + +test("space-y form children stretch by default", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + + + + + + + + + Send request + + + + + ) + ); + + const cardStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "1") + ?.values.at(-1); + const formStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "2") + ?.values.at(-1); + + expect(fragment.styles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + styleSourceId: cardStyleSourceId, + property: "maxWidth", + value: expect.objectContaining({ unit: "rem", value: 48 }), + }), + expect.objectContaining({ + styleSourceId: cardStyleSourceId, + property: "marginInlineStart", + value: expect.objectContaining({ type: "keyword", value: "auto" }), + }), + expect.objectContaining({ + styleSourceId: formStyleSourceId, + property: "display", + value: expect.objectContaining({ type: "keyword", value: "flex" }), + }), + expect.objectContaining({ + styleSourceId: formStyleSourceId, + property: "flexDirection", + value: expect.objectContaining({ type: "keyword", value: "column" }), + }), + ]) + ); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === formStyleSourceId && + style.property === "alignItems" + ) + ).toBe(false); }); test("preserve or override existing local styles", async () => { @@ -438,16 +730,16 @@ describe("extract breakpoints", () => { max-width: 479px */ - @media (max-width: 479px) { + /* base -> max-width: 639px */ + @media (max-width: 639px) { opacity: 0.1; } /* min-width: 640px -> max-width: 767px */ @media (max-width: 767px) { opacity: 0.2; } - /* min-width: 768px -> max-width: 991px */ - @media (max-width: 991px) { + /* min-width: 768px -> max-width: 1023px */ + @media (max-width: 1023px) { opacity: 0.3; } /* min-width: 1024px -> base */ @@ -456,8 +748,8 @@ describe("extract breakpoints", () => { @media (min-width: 1280px) { opacity: 0.5; } - /* min-width: 1536px -> min-width: 1440px */ - @media (min-width: 1440px) { + /* min-width: 1536px -> min-width: 1536px */ + @media (min-width: 1536px) { opacity: 0.6; } `} @@ -481,7 +773,7 @@ describe("extract breakpoints", () => { { { { @media (min-width: 1280px) { color: red; } - @media (max-width: 479px) { + @media (max-width: 639px) { max-width: none; } @media (max-width: 767px) { max-width: 640px; } - @media (max-width: 991px) { + @media (max-width: 1023px) { max-width: 768px; } max-width: 1024px; @media (min-width: 1280px) { max-width: 1280px; } - @media (min-width: 1440px) { + @media (min-width: 1536px) { max-width: 1536px; } width: 100%; @@ -662,13 +954,13 @@ describe("extract breakpoints", () => { { color: red; } color: green; - @media (max-width: 479px) { + @media (max-width: 639px) { opacity: 0.1; } @media (max-width: 767px) { @@ -741,7 +1033,7 @@ describe("extract breakpoints", () => { ws:tag="div" ws:style={css` opacity: 0.1; - @media (max-width: 479px) { + @media (max-width: 639px) { &:hover { opacity: unset; } @@ -770,20 +1062,20 @@ describe("extract breakpoints", () => { { { { ); // container should only create max-width breakpoints, not 1280/1440/1920 min-width ones expect(fragment.breakpoints).toEqual([ - { id: "0", label: "479", maxWidth: 479 }, + { id: "0", label: "639", maxWidth: 639 }, { id: "1", label: "767", maxWidth: 767 }, - { id: "2", label: "991", maxWidth: 991 }, + { id: "2", label: "1023", maxWidth: 1023 }, { id: "base", label: "" }, ]); }); @@ -892,9 +1184,9 @@ describe("extract breakpoints", () => { ) ); - // sm: should only create 479 max-width and base, not all breakpoints + // sm: should only create 639 max-width and base, not all breakpoints expect(fragment.breakpoints).toEqual([ - { id: "0", label: "479", maxWidth: 479 }, + { id: "0", label: "639", maxWidth: 639 }, { id: "base", label: "" }, ]); }); @@ -928,7 +1220,6 @@ test("generate space without display property", async () => { ws:style={css` display: flex; flex-direction: column; - align-items: start; @media (max-width: 767px) { row-gap: 1rem; } diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index d65d03326bb9..857a9c69d123 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -20,26 +20,14 @@ import { preflight } from "./__generated__/preflight"; // breakpoints used to map tailwind classes to webstudio breakpoints // includes both min-width (desktop-first) and max-width (mobile-first) breakpoints const tailwindBreakpoints: Breakpoint[] = [ - { id: "1920", label: "1920", minWidth: 1920 }, - { id: "1440", label: "1440", minWidth: 1440 }, + { id: "1536", label: "1536", minWidth: 1536 }, { id: "1280", label: "1280", minWidth: 1280 }, { id: "base", label: "" }, - { id: "991", label: "991", maxWidth: 991 }, + { id: "1023", label: "1023", maxWidth: 1023 }, { id: "767", label: "767", maxWidth: 767 }, - { id: "479", label: "479", maxWidth: 479 }, + { id: "639", label: "639", maxWidth: 639 }, ]; -const tailwindToWebstudioMappings: Record = { - 639.9: 479, - 640: 480, - 767.9: 767, - 1023.9: 991, - 1024: 992, - 1279.9: 1279, - 1535.9: 1439, - 1536: 1440, -}; - type StyleDecl = Omit; type StyleBreakpoint = { @@ -143,6 +131,10 @@ const rangesToBreakpoints = ( return breakpoints; }; +const normalizeMediaQueryWidth = (value: number, direction: "min" | "max") => { + return direction === "min" ? Math.ceil(value) : Math.floor(value); +}; + const adaptBreakpoints = ( parsedStyles: StyleDecl[], userBreakpoints: Breakpoint[] @@ -164,13 +156,17 @@ const adaptBreakpoints = ( ) { continue; } - if (mediaQuery?.minWidth) { - mediaQuery.minWidth = - tailwindToWebstudioMappings[mediaQuery.minWidth] ?? mediaQuery.minWidth; + if (mediaQuery?.minWidth !== undefined) { + mediaQuery.minWidth = normalizeMediaQueryWidth( + mediaQuery.minWidth, + "min" + ); } - if (mediaQuery?.maxWidth) { - mediaQuery.maxWidth = - tailwindToWebstudioMappings[mediaQuery.maxWidth] ?? mediaQuery.maxWidth; + if (mediaQuery?.maxWidth !== undefined) { + mediaQuery.maxWidth = normalizeMediaQueryWidth( + mediaQuery.maxWidth, + "max" + ); } const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`; let group = breakpointGroups.get(groupKey); @@ -235,7 +231,85 @@ const hexToRgb = (hex: string) => { return `${r} ${g} ${b}`; }; -const normalizeWind4Css = (css: string, finalVars: Map) => { +const extractPropertyInitialValues = (css: string) => { + const values = new Map(); + for (const match of css.matchAll(/@property\s+(--[\w-]+)\s*\{([^{}]*)\}/g)) { + const initialValue = match[2].match(/initial-value\s*:\s*([^;]+)\s*;?/); + if (initialValue) { + values.set(match[1], initialValue[1].trim()); + } + } + return values; +}; + +const findMatchingParen = (text: string, openIndex: number) => { + let depth = 0; + for (let index = openIndex; index < text.length; index += 1) { + const char = text[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } +}; + +const splitCssVarArguments = (args: string) => { + let depth = 0; + for (let index = 0; index < args.length; index += 1) { + const char = args[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + } else if (char === "," && depth === 0) { + return [args.slice(0, index).trim(), args.slice(index + 1).trim()]; + } + } + return [args.trim()]; +}; + +const resolveCssVars = ( + value: string, + vars: Map, + seen = new Set() +): string => { + let result = ""; + let index = 0; + while (index < value.length) { + const varIndex = value.indexOf("var(", index); + if (varIndex === -1) { + result += value.slice(index); + break; + } + result += value.slice(index, varIndex); + const openIndex = varIndex + "var".length; + const closeIndex = findMatchingParen(value, openIndex); + if (closeIndex === undefined) { + result += value.slice(varIndex); + break; + } + const args = value.slice(openIndex + 1, closeIndex); + const [name, fallback] = splitCssVarArguments(args); + const replacement = vars.get(name); + if (replacement !== undefined && seen.has(name) === false) { + seen.add(name); + result += resolveCssVars(replacement, vars, seen); + seen.delete(name); + } else if (fallback !== undefined) { + result += resolveCssVars(fallback, vars, seen); + } else { + result += value.slice(varIndex, closeIndex + 1); + } + index = closeIndex + 1; + } + return result; +}; + +const normalizeUnoCssValues = (css: string, finalVars: Map) => { // Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing // breakpoint mapping code can keep working unchanged. let normalized = css.replace( @@ -254,13 +328,9 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { } ); - // Inline tracked theme variables so parseCss can resolve computed values - // like calc(var(--spacing) * 2) and var(--text-sm-fontSize). - for (const [name, value] of finalVars.entries()) { - normalized = normalized - .replaceAll(`var(${name})`, value) - .replaceAll(`var(${name},`, `var(${value},`); - } + // Inline tracked theme and utility variables so parseCss can resolve computed + // values like calc(var(--spacing) * 2), gradients, and shadow fallbacks. + normalized = resolveCssVars(normalized, finalVars); // Wind4 uses a leading utility var fallback for typography. normalized = normalized.replace( @@ -268,6 +338,8 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { (_match, fallback) => fallback.trim() ); + normalized = normalized.replaceAll("calc(infinity * 1px)", "9999px"); + // Resolve wind4's color-mix based opacity pipeline into concrete colors that // parseCss can read as typed color values. normalized = normalized.replace( @@ -326,9 +398,52 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { } ); + // Tailwind v4 emits gradients like `linear-gradient(to bottom right in oklab, ...)`. + // Keep imported gradients broadly renderable and parseable by dropping the + // interpolation color space from the direction argument. + normalized = normalized.replace( + /linear-gradient\((to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?|[-+]?\d*\.?\d+(?:deg|rad|grad|turn))\s+in\s+(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|lch|oklch|xyz(?:-d50|-d65)?),/gi, + "linear-gradient($1," + ); + return normalized; }; +const normalizeUnoCssForWebstudio = (generatedCss: string) => { + // UnoCSS uses the --un-* namespace. Keep generated CSS in Tailwind's + // namespace so custom properties match familiar Tailwind output and existing + // Webstudio styles. + const css = generatedCss.replaceAll("--un-", "--tw-"); + + // Normalize CSS custom property values: when the same var is declared in + // multiple utility-class rules, replace every occurrence with the value from + // the LAST declaration (the final cascaded value). This allows per-rule + // two-pass pre-collection to see the correct final value regardless of which + // rule a shorthand (e.g. border-color) lives in. + const finalVars = new Map([ + ...extractPropertyInitialValues(css), + ...extractCssCustomProperties(css), + ]); + + let normalizedCss = normalizeUnoCssValues(css, finalVars); + if (finalVars.size > 0) { + normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { + const colonIdx = match.indexOf(":"); + const propName = match.slice(0, colonIdx).trim(); + const finalValue = finalVars.get(propName); + return finalValue !== undefined + ? `${propName}: ${resolveCssVars(finalValue, finalVars)}` + : match; + }); + } + + return { css: normalizedCss, vars: finalVars }; +}; + +export const __testing__ = { + normalizeUnoCssForWebstudio, +}; + const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => { if ( styleDecl.property.startsWith("border-") === false || @@ -359,6 +474,37 @@ const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => { ); }; +const stylePropertyGroups: Record> = { + margin: new Set([ + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "marginBlockStart", + "marginBlockEnd", + "marginInlineStart", + "marginInlineEnd", + ]), + padding: new Set([ + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "paddingBlockStart", + "paddingBlockEnd", + "paddingInlineStart", + "paddingInlineEnd", + ]), +}; + +const getStylePropertyGroup = (property: string) => { + for (const [groupName, properties] of Object.entries(stylePropertyGroups)) { + if (properties.has(property)) { + return groupName; + } + } +}; + const parseTailwindClasses = async ( classes: string, userBreakpoints: Breakpoint[], @@ -406,23 +552,9 @@ const parseTailwindClasses = async ( }) .join(" "); const generated = await generator.generate(classes); - // use tailwind prefix instead of unocss one - const css = generated.css.replaceAll("--un-", "--tw-"); - // Normalize CSS custom property values: when the same var is declared in - // multiple utility-class rules, replace every occurrence with the value from - // the LAST declaration (the final cascaded value). This allows per-rule - // two-pass pre-collection to see the correct final value regardless of which - // rule a shorthand (e.g. border-color) lives in. - const finalVars = extractCssCustomProperties(css); - let normalizedCss = normalizeWind4Css(css, finalVars); - if (finalVars.size > 0) { - normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { - const colonIdx = match.indexOf(":"); - const propName = match.slice(0, colonIdx).trim(); - const finalValue = finalVars.get(propName); - return finalValue !== undefined ? `${propName}: ${finalValue}` : match; - }); - } + const { css: normalizedCss, vars: finalVars } = normalizeUnoCssForWebstudio( + generated.css + ); let parsedStyles: StyleDecl[] = []; // @todo probably builtin in v4 if (normalizedCss.includes("border")) { @@ -534,10 +666,6 @@ const parseTailwindClasses = async ( { property: "flex-direction", value: { type: "keyword", value: "column" }, - }, - { - property: "align-items", - value: { type: "keyword", value: "start" }, } ); } @@ -618,6 +746,7 @@ export const generateFragmentFromTailwind = async ( const styles = new Map( fragment.styles.map((item) => [getStyleDeclKey(item), item]) ); + const preflightStyleDeclKeys = new Set(); const getLocalStyleSource = (instanceId: Instance["id"]) => { const styleSourceSelection = styleSourceSelections.get(instanceId); const lastStyleSourceId = styleSourceSelection?.values.at(-1); @@ -647,6 +776,7 @@ export const generateFragmentFromTailwind = async ( ) => { const localStyleSource = getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId); + const clearedPropertyGroups = new Set(); for (const parsedStyleDecl of newStyles) { const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint); // ignore unknown breakpoints @@ -666,8 +796,41 @@ export const generateFragmentFromTailwind = async ( if (skipExisting && styles.has(styleDeclKey)) { continue; } + if (skipExisting === false) { + const propertyGroup = getStylePropertyGroup(styleDecl.property); + const groupProperties = + propertyGroup === undefined + ? undefined + : stylePropertyGroups[propertyGroup]; + const groupKey = + propertyGroup === undefined + ? undefined + : `${styleDecl.styleSourceId}:${styleDecl.breakpointId}:${ + styleDecl.state ?? "" + }:${propertyGroup}`; + if ( + groupProperties !== undefined && + groupKey !== undefined && + clearedPropertyGroups.has(groupKey) === false + ) { + clearedPropertyGroups.add(groupKey); + for (const property of groupProperties) { + const groupStyleDeclKey = getStyleDeclKey({ + ...styleDecl, + property, + }); + if (preflightStyleDeclKeys.has(groupStyleDeclKey)) { + styles.delete(groupStyleDeclKey); + preflightStyleDeclKeys.delete(groupStyleDeclKey); + } + } + } + } styles.delete(styleDeclKey); styles.set(styleDeclKey, styleDecl); + if (skipExisting) { + preflightStyleDeclKeys.add(styleDeclKey); + } } }; diff --git a/packages/css-engine/src/core/prefixer.test.ts b/packages/css-engine/src/core/prefixer.test.ts index ca7eec40ba7a..cde93a438d7a 100644 --- a/packages/css-engine/src/core/prefixer.test.ts +++ b/packages/css-engine/src/core/prefixer.test.ts @@ -14,6 +14,33 @@ test("prefix background-clip", () => { ); }); +test("prefix text fill color for clipped text", () => { + expect( + prefixStyles( + new Map([ + ["color", { type: "keyword", value: "transparent" }], + [ + "background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + ]) + ) + ).toEqual( + new Map([ + ["-webkit-text-fill-color", { type: "keyword", value: "transparent" }], + ["color", { type: "keyword", value: "transparent" }], + [ + "-webkit-background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + [ + "background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + ]) + ); +}); + test("prefix user-select", () => { expect( prefixStyles(new Map([["user-select", { type: "keyword", value: "none" }]])) diff --git a/packages/css-engine/src/core/prefixer.ts b/packages/css-engine/src/core/prefixer.ts index bb40662796cc..1fe2678ea5dd 100644 --- a/packages/css-engine/src/core/prefixer.ts +++ b/packages/css-engine/src/core/prefixer.ts @@ -1,7 +1,21 @@ import type { StyleMap } from "./rules"; +import type { CssProperty, StyleValue } from "../schema"; + +const isKeyword = (value: StyleValue, keyword: string) => { + if (value.type === "keyword") { + return value.value === keyword; + } + if (value.type === "layers") { + return value.value.some((layer) => isKeyword(layer, keyword)); + } + return false; +}; export const prefixStyles = (styleMap: StyleMap) => { const newStyleMap: StyleMap = new Map(); + const backgroundClip = styleMap.get("background-clip"); + const hasTextBackgroundClip = + backgroundClip !== undefined && isKeyword(backgroundClip, "text"); for (const [property, value] of styleMap) { // chrome started to support unprefixed background-clip in December 2023 // https://caniuse.com/background-clip-text @@ -25,6 +39,13 @@ export const prefixStyles = (styleMap: StyleMap) => { if (property === "backdrop-filter") { newStyleMap.set("-webkit-backdrop-filter", value); } + if ( + property === "color" && + hasTextBackgroundClip && + isKeyword(value, "transparent") + ) { + newStyleMap.set("-webkit-text-fill-color" as CssProperty, value); + } // Safari and FF do not support this property and strip it from the CSS // For polyfill to work we need to set it as a CSS property