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))