diff --git a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx
index e06730b73594..14831acec914 100644
--- a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx
+++ b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx
@@ -141,11 +141,11 @@ test("inline code", () => {
});
test("code", () => {
- expect(parse("```js meta\nfoo\n```")).toEqual(
+ expect(parse("```js meta\nfoo\nbar\n```")).toEqual(
renderTemplate(
- {"foo "}
+ {"foo\nbar\n"}
)
@@ -177,13 +177,30 @@ test("thematic break | separator", () => {
});
test("strikethrough", () => {
- expect(parse("~One~ ~~two~~ ~~~three~~~.")).toEqual(
+ expect(parse("~One~\n\n~~two~~")).toEqual(
renderTemplate(
-
- One
- two
- ~~~three~~~.
-
+ <>
+
+ One
+
+
+ two
+
+ >
+ )
+ );
+});
+
+test("preserve spaces between strong and em", () => {
+ expect(parse("**One** *two* text")).toEqual(
+ renderTemplate(
+ <>
+
+ One{" "}
+ two
+ {" text"}
+
+ >
)
);
});
diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx
index 3f8a5b928ccb..0f54fa50036c 100644
--- a/apps/builder/app/shared/html.test.tsx
+++ b/apps/builder/app/shared/html.test.tsx
@@ -199,9 +199,26 @@ test("collapse any spacing characters inside text", () => {
another line
`)
+ ).toEqual(
+ renderTemplate({"line another line"})
+ );
+});
+
+test("collapse any spacing characters inside rich text", () => {
+ expect(
+ generateFragmentFromHtml(`
+
+ line
+ another line
+ text
+
+ `)
).toEqual(
renderTemplate(
- {" line another line "}
+
+ line{" "}
+ another line text
+
)
);
});
diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts
index 71801ccc9911..c7809f1a44ae 100644
--- a/apps/builder/app/shared/html.ts
+++ b/apps/builder/app/shared/html.ts
@@ -231,7 +231,8 @@ export const generateFragmentFromHtml = (
}
}
}
- for (const childNode of node.childNodes) {
+ for (let index = 0; index < node.childNodes.length; index += 1) {
+ const childNode = node.childNodes[index];
if (defaultTreeAdapter.isElementNode(childNode)) {
const child = convertElementToInstance(childNode);
if (child) {
@@ -239,14 +240,28 @@ export const generateFragmentFromHtml = (
}
}
if (defaultTreeAdapter.isTextNode(childNode)) {
- if (spaceRegex.test(childNode.value)) {
- continue;
+ // trim spaces around rich text
+ // do not for code
+ if (spaceRegex.test(childNode.value) && node.tagName !== "code") {
+ if (index === 0 || index === node.childNodes.length - 1) {
+ continue;
+ }
}
let child: Instance["children"][number] = {
type: "text",
- // collapse spacing characters inside of text to avoid preserved newlines
- value: childNode.value.replaceAll(/\s+/g, " "),
+ value: childNode.value,
};
+ if (node.tagName !== "code") {
+ // collapse spacing characters inside of text to avoid preserved newlines
+ child.value = child.value.replaceAll(/\s+/g, " ");
+ // remove unnecessary spacing in nodes
+ if (index === 0) {
+ child.value = child.value.trimStart();
+ }
+ if (index === node.childNodes.length - 1) {
+ child.value = child.value.trimEnd();
+ }
+ }
// textarea content is initial value
// and represented with fake value attribute
if (node.tagName === "textarea") {
@@ -271,6 +286,10 @@ export const generateFragmentFromHtml = (
//
//
if (hasNonRichTextContent) {
+ // remove spaces between elements outside of rich text
+ if (spaceRegex.test(childNode.value)) {
+ continue;
+ }
const span: Instance = {
type: "instance",
id: getNewId(),
diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx
index f944abe962d9..339d6d0ca478 100644
--- a/apps/builder/app/shared/tailwind/tailwind.test.tsx
+++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx
@@ -1,4 +1,4 @@
-import { expect, test } from "vitest";
+import { describe, expect, test } from "vitest";
import { css, renderTemplate, ws } from "@webstudio-is/template";
import { generateFragmentFromTailwind } from "./tailwind";
@@ -246,79 +246,271 @@ test("extract states from tailwind classes", async () => {
);
});
-test("extract new breakpoints from tailwind classes", async () => {
- expect(
- await generateFragmentFromTailwind(
+describe("extract breakpoints", () => {
+ test("extract new breakpoints from tailwind classes", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
renderTemplate(
max-width: 479px */
+ @media (max-width: 479px) {
+ 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) {
+ opacity: 0.3;
+ }
+ /* min-width: 1024px -> base */
+ opacity: 0.4;
+ /* unchanged */
+ @media (min-width: 1280px) {
+ opacity: 0.5;
+ }
+ /* min-width: 1536px -> min-width: 1440px */
+ @media (min-width: 1440px) {
+ opacity: 0.6;
+ }
+ `}
>
)
- )
- ).toEqual(
- renderTemplate(
- {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
- )
- );
-});
+ `}
+ >
+ )
+ );
+ });
-test("merge tailwind breakpoints with already defined ones", async () => {
- expect(
- await generateFragmentFromTailwind(
+ test("base is first breakpoint", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
renderTemplate(
)
- )
- ).toEqual(
- renderTemplate(
- {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
+ )
+ );
+ });
+
+ test("base is middle breakpoint", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
+ )
+ );
+ });
+
+ test("preserve breakpoint when no base breakpoint", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
+ )
+ );
+ });
+
+ test("extract container class", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate()
+ )
+ ).toEqual(
+ renderTemplate(
+
+ )
+ );
+ });
+
+ test("merge tailwind breakpoints with already defined ones", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
- )
- );
+ `}
+ >
+ )
+ );
+ });
+
+ test("state", async () => {
+ expect(
+ await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ )
+ ).toEqual(
+ renderTemplate(
+
+ )
+ );
+ });
});
test("generate space without display property", async () => {
@@ -344,7 +536,9 @@ test("generate space without display property", async () => {
@@ -379,11 +573,11 @@ test("generate space with display property", async () => {
>
diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts
index 7105098a3dfd..861006733967 100644
--- a/apps/builder/app/shared/tailwind/tailwind.ts
+++ b/apps/builder/app/shared/tailwind/tailwind.ts
@@ -16,6 +16,154 @@ import {
import { isBaseBreakpoint } from "../nano-states";
import { preflight } from "./__generated__/preflight";
+const availableBreakpoints = [
+ { id: "1920", minWidth: 1920 },
+ { id: "1440", minWidth: 1440 },
+ { id: "1280", minWidth: 1280 },
+ { id: "base" },
+ { id: "991", maxWidth: 991 },
+ { id: "767", maxWidth: 767 },
+ { id: "479", maxWidth: 479 },
+];
+
+const tailwindToWebstudioMappings: Record = {
+ 639: 479,
+ 640: 480,
+ 1023: 991,
+ 1024: 992,
+ 1535: 1439,
+ 1536: 1440,
+};
+
+type Breakpoint = {
+ key: string;
+ minWidth?: number;
+ maxWidth?: number;
+};
+
+type Range = {
+ key: string;
+ start: number;
+ end: number;
+};
+
+const serializeBreakpoint = (breakpoint: Breakpoint) => {
+ if (breakpoint?.minWidth) {
+ return `(min-width: ${breakpoint.minWidth}px)`;
+ }
+ if (breakpoint?.maxWidth) {
+ return `(max-width: ${breakpoint.maxWidth}px)`;
+ }
+};
+
+const UPPER_BOUND = Number.MAX_SAFE_INTEGER;
+
+const breakpointsToRanges = (breakpoints: Breakpoint[]) => {
+ // collect lower bounds and ids
+ const values = new Set([0]);
+ const keys = new Map();
+ for (const breakpoint of breakpoints) {
+ if (breakpoint.minWidth !== undefined) {
+ values.add(breakpoint.minWidth);
+ keys.set(breakpoint.minWidth, breakpoint.key);
+ } else if (breakpoint.maxWidth !== undefined) {
+ values.add(breakpoint.maxWidth + 1);
+ keys.set(breakpoint.maxWidth, breakpoint.key);
+ } else {
+ // base breakpoint
+ keys.set(undefined, breakpoint.key);
+ }
+ }
+ const sortedValues = Array.from(values).sort((left, right) => left - right);
+ const ranges: Range[] = [];
+ for (let index = 0; index < sortedValues.length; index += 1) {
+ const start = sortedValues[index];
+ let end;
+ if (index === sortedValues.length - 1) {
+ end = UPPER_BOUND;
+ } else {
+ end = sortedValues[index + 1] - 1;
+ }
+ const key = keys.get(start) ?? keys.get(end) ?? keys.get(undefined);
+ if (key) {
+ ranges.push({ key, start, end });
+ }
+ }
+ return ranges;
+};
+
+const rangesToBreakpoints = (ranges: Range[]) => {
+ const breakpoints: Breakpoint[] = [];
+ for (const range of ranges) {
+ let matchedBreakpoint;
+ for (const breakpoint of availableBreakpoints) {
+ if (breakpoint.minWidth === range.start) {
+ matchedBreakpoint = { key: range.key, minWidth: range.start };
+ }
+ if (breakpoint.maxWidth === range.end) {
+ matchedBreakpoint = { key: range.key, maxWidth: range.end };
+ }
+ if (
+ breakpoint.minWidth === undefined &&
+ breakpoint.maxWidth === undefined
+ ) {
+ matchedBreakpoint ??= { key: range.key };
+ }
+ }
+ if (matchedBreakpoint) {
+ breakpoints.push(matchedBreakpoint);
+ }
+ }
+ return breakpoints;
+};
+
+const adaptBreakpoints = (
+ parsedStyles: Omit[]
+) => {
+ const breakpointGroups = new Map();
+ for (const styleDecl of parsedStyles) {
+ const mediaQuery = styleDecl.breakpoint
+ ? parseMediaQuery(styleDecl.breakpoint)
+ : undefined;
+ if (mediaQuery?.minWidth) {
+ mediaQuery.minWidth =
+ tailwindToWebstudioMappings[mediaQuery.minWidth] ?? mediaQuery.minWidth;
+ }
+ if (mediaQuery?.maxWidth) {
+ mediaQuery.maxWidth =
+ tailwindToWebstudioMappings[mediaQuery.maxWidth] ?? mediaQuery.maxWidth;
+ }
+ const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`;
+ let group = breakpointGroups.get(groupKey);
+ if (group === undefined) {
+ group = [];
+ breakpointGroups.set(groupKey, group);
+ }
+ const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`;
+ group.push({ key: styleDeclKey, ...mediaQuery });
+ }
+ const breakpointsByKey = new Map();
+ for (let group of breakpointGroups.values()) {
+ const ranges = breakpointsToRanges(group);
+ // adapt breakpoints only when first range is defined
+ // for example opacity-10 sm:opacity-20 will work
+ // but sm:opacity-20 alone does not have the base to switch to
+ if (ranges[0].start === 0) {
+ group = rangesToBreakpoints(ranges);
+ }
+ for (const breakpoint of group) {
+ breakpointsByKey.set(breakpoint.key, breakpoint);
+ }
+ }
+ for (const styleDecl of parsedStyles) {
+ const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`;
+ const breakpoint = breakpointsByKey.get(styleDeclKey);
+ if (breakpoint) {
+ styleDecl.breakpoint = serializeBreakpoint(breakpoint);
+ }
+ }
+};
+
const createUnoGenerator = async () => {
return await createGenerator({
presets: [
@@ -38,6 +186,7 @@ const parseTailwindClasses = async (classes: string) => {
let hasColumnGaps = false;
let hasRowGaps = false;
let hasFlexOrGrid = false;
+ let hasContainer = false;
classes = classes
.split(" ")
.map((item) => {
@@ -53,9 +202,8 @@ const parseTailwindClasses = async (classes: string) => {
hasRowGaps = true;
return `gap-y-${item.slice(spaceY.length)}`;
}
- if (item.endsWith("flex") || item.endsWith("grid")) {
- hasFlexOrGrid = true;
- }
+ hasFlexOrGrid ||= item.endsWith("flex") || item.endsWith("grid");
+ hasContainer ||= item === "container";
return item;
})
.join(" ");
@@ -78,6 +226,14 @@ const parseTailwindClasses = async (classes: string) => {
parsedStyles = parsedStyles.filter(
(styleDecl) => !styleDecl.state?.startsWith("::")
);
+ // setup base breakpoint for container class
+ // to avoid hole in ranges
+ if (hasContainer) {
+ parsedStyles.unshift({
+ property: "max-width",
+ value: { type: "keyword", value: "none" },
+ });
+ }
// gaps work only with flex and grid
// so try to use one or another for different axes
if (hasColumnGaps && !hasFlexOrGrid) {
@@ -87,11 +243,22 @@ const parseTailwindClasses = async (classes: string) => {
});
}
if (hasRowGaps && !hasFlexOrGrid) {
- parsedStyles.unshift({
- property: "display",
- value: { type: "keyword", value: "grid" },
- });
+ parsedStyles.unshift(
+ {
+ property: "display",
+ value: { type: "keyword", value: "flex" },
+ },
+ {
+ property: "flex-direction",
+ value: { type: "keyword", value: "column" },
+ },
+ {
+ property: "align-items",
+ value: { type: "keyword", value: "start" },
+ }
+ );
}
+ adaptBreakpoints(parsedStyles);
const newClasses = classes
.split(" ")
.filter((item) => !generated.matched.has(item))