diff --git a/apps/designer/app/canvas/canvas.tsx b/apps/designer/app/canvas/canvas.tsx index b6b37946dc..f9ab92f35c 100644 --- a/apps/designer/app/canvas/canvas.tsx +++ b/apps/designer/app/canvas/canvas.tsx @@ -3,10 +3,9 @@ import store from "immerhin"; import type { Build } from "@webstudio-is/project"; import { createElementsTree, - useGlobalStyles, + useAllUserProps, type OnChangeChildren, type Tree, - useAllUserProps, } from "@webstudio-is/react-sdk"; import { useSubscribe } from "~/shared/pubsub"; import { useShortcuts } from "./shared/use-shortcuts"; @@ -24,7 +23,7 @@ import { useUnselectInstance, useUpdateSelectedInstance, } from "./shared/instance"; -import { useManageStyles } from "./shared/styles"; +import { useManageDesignModeStyles, GlobalStyles } from "./shared/styles"; import { useTrackSelectedElement } from "./shared/use-track-selected-element"; import { WrapperComponentDev } from "./features/wrapper-component"; import { useSync } from "./shared/sync"; @@ -91,7 +90,7 @@ type DesignModeProps = { const DesignMode = ({ treeId, buildId }: DesignModeProps) => { useManageBreakpoints(); - useManageStyles(); + useManageDesignModeStyles(); useManageProps(); usePublishSelectedInstanceData(treeId); useInsertInstance(); @@ -123,7 +122,6 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => { const isDesignerReady = useSubscribeDesignerReady(); useInitializeBreakpoints(data.breakpoints); const assets = useAssets(data.assets); - useGlobalStyles({ assets }); useAllUserProps(data.props); usePopulateRootInstance(data.tree); // e.g. toggling preview is still needed in both modes @@ -134,11 +132,17 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => { if (elements === undefined) return null; if (isPreviewMode || isDesignerReady === false) { - return elements; + return ( + <> + + {elements} + + ); } return ( <> + {elements} diff --git a/apps/designer/app/canvas/features/wrapper-component/wrapper-component.tsx b/apps/designer/app/canvas/features/wrapper-component/wrapper-component.tsx index e346c8a7a6..a8458c8359 100644 --- a/apps/designer/app/canvas/features/wrapper-component/wrapper-component.tsx +++ b/apps/designer/app/canvas/features/wrapper-component/wrapper-component.tsx @@ -7,6 +7,7 @@ import { components, type Instance, type OnChangeChildren, + idAttribute, } from "@webstudio-is/react-sdk"; import { useTextEditingInstanceId } from "~/shared/nano-states"; import { useSelectedElement } from "~/canvas/shared/nano-states"; @@ -77,7 +78,7 @@ export const WrapperComponentDev = ({ // for this we need to also make search by id fast id: instance.id, "data-ws-component": instance.component, - "data-ws-id": instance.id, + [idAttribute]: instance.id, onClick: (event: MouseEvent) => { if (instance.component === "Link") { event.preventDefault(); diff --git a/apps/designer/app/canvas/shared/breakpoints.ts b/apps/designer/app/canvas/shared/breakpoints.ts index 15a2bacef0..6d2a960d65 100644 --- a/apps/designer/app/canvas/shared/breakpoints.ts +++ b/apps/designer/app/canvas/shared/breakpoints.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import store from "immerhin"; -import { type Breakpoint, setBreakpoints } from "@webstudio-is/react-sdk"; +import type { Breakpoint } from "@webstudio-is/react-sdk"; import { useSubscribe } from "~/shared/pubsub"; import { deleteCssRulesByBreakpoint } from "~/shared/css-utils"; import { @@ -17,7 +17,6 @@ export const useInitializeBreakpoints = (breakpoints: Array) => { const ref = useRef(false); if (ref.current === false) { ref.current = true; - setBreakpoints(breakpoints); setCurrentBreakpoints(breakpoints); addMediaRules(breakpoints); } @@ -45,7 +44,7 @@ const useBreakpointChange = () => { breakpoints.push(breakpoint); } - setBreakpoints(breakpoints); + addMediaRules(breakpoints); }); }); }; diff --git a/apps/designer/app/canvas/shared/styles.ts b/apps/designer/app/canvas/shared/styles.ts index 323145fca5..7d719af167 100644 --- a/apps/designer/app/canvas/shared/styles.ts +++ b/apps/designer/app/canvas/shared/styles.ts @@ -4,59 +4,98 @@ import { setInstanceStyleMutable } from "~/shared/tree-utils"; import { useSelectedInstance } from "./nano-states"; import { rootInstanceContainer } from "~/shared/nano-states"; import { - toValue, - toVarNamespace, + validStaticValueTypes, + idAttribute, + type Breakpoint, + type ValidStaticStyleValue, type StyleValue, type CssRule, type StyleProperty, type Style, - Breakpoint, - ValidStaticStyleValue, - validStaticValueTypes, } from "@webstudio-is/react-sdk"; -import { globalCss } from "@webstudio-is/design-system"; import { useEffect } from "react"; -import { createCssEngine, type StyleRule } from "@webstudio-is/css-engine"; +import { + createCssEngine, + toValue, + type CssEngine, + type StyleRule, + type PlaintextRule, +} from "@webstudio-is/css-engine"; import { useIsomorphicLayoutEffect } from "react-use"; +import { Asset, FontAsset } from "@webstudio-is/asset-uploader"; +import { FontFormat, FONT_FORMATS, getFontFaces } from "@webstudio-is/fonts"; + +const cssEngine = createCssEngine(); const voidElements = "area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr"; // Helper styles on for canvas in design mode -const styles = { - "[data-ws-component]": { - // When double clicking into an element to edit text, it should not select the word. - userSelect: "none", - }, - [`[data-ws-component]:not(${voidElements}):not(body):empty`]: { - outline: "1px dashed #555", - outlineOffset: -1, - paddingTop: 50, - paddingRight: 50, - }, - "[data-ws-component][contenteditable], [data-ws-component]:focus": { - outline: 0, - }, - "[data-ws-component][contenteditable]": { - boxShadow: "0 0 0px 4px rgb(36 150 255 / 20%)", - }, +const helperStyles = [ + // When double clicking into an element to edit text, it should not select the word. + `[${idAttribute}] { + user-select: none; + }`, + `[${idAttribute}]:not(${voidElements}):not(body):empty { + outline: 1px dashed #555; + outline-offset: -1; + padding-top: 50; + padding-right: 50; + }`, + `[${idAttribute}][contenteditable], [${idAttribute}]:focus { + outline: 0; + }`, + `[${idAttribute}][contenteditable] { + box-shadow: 0 0 0px 4px rgb(36 150 255 / 20%) + }`, // Text Editor wraps each line into a p, so we need to make sure there is no jump between regular rendering and editing - "[data-ws-component][contenteditable] p": { - margin: 0, - }, -}; + `[${idAttribute}][contenteditable] p { + margin: 0 + }`, +]; -// @todo rewrite to use css engine and remove stitches from canvas entirely -const wrapperComponentGlobalStyles = globalCss(styles); - -export const useManageStyles = () => { - wrapperComponentGlobalStyles(); +export const useManageDesignModeStyles = () => { useUpdateStyle(); usePreviewStyle(); useRemoveSsrStyles(); }; -const cssEngine = createCssEngine(); +export const addGlobalRules = ( + engine: CssEngine, + { assets = [] }: { assets?: Array } +) => { + // @todo we need to figure out all global resets while keeping + // the engine aware of all of them. + // Ideally, the user is somehow aware and in control of the reset + engine.addPlaintextRule("html {margin: 0; height: 100%}"); + + const fontAssets = assets.filter((asset) => + FONT_FORMATS.has(asset.format as FontFormat) + ) as Array; + const fontFaces = getFontFaces(fontAssets); + for (const fontFace of fontFaces) { + engine.addFontFaceRule(fontFace); + } +}; + +const globalStylesCssEngine = createCssEngine(); + +export const GlobalStyles = ({ assets }: { assets: Array }) => { + useIsomorphicLayoutEffect(() => { + globalStylesCssEngine.clear(); + + addGlobalRules(globalStylesCssEngine, { assets }); + + for (const style of helperStyles) { + globalStylesCssEngine.addPlaintextRule(style); + } + + if (typeof document !== "undefined") { + globalStylesCssEngine.render(); + } + }); + return null; +}; // Wrapps a normal StyleValue into a VarStyleValue that uses the previous style value as a fallback and allows // to quickly pass the values over CSS variable witout rerendering the components tree. @@ -94,11 +133,11 @@ export const addMediaRules = (breakpoints: Array) => { } }; -const wrappedRulesMap = new Map(); +const wrappedRulesMap = new Map(); const addRule = (id: string, cssRule: CssRule) => { const key = id + cssRule.breakpoint; - const selectorText = `[data-ws-id="${id}"]`; + const selectorText = `[${idAttribute}="${id}"]`; const rule = cssEngine.addStyleRule(selectorText, { ...cssRule, style: toVarStyleWithFallback(id, cssRule.style), @@ -138,6 +177,10 @@ export const useCssRules = ({ }, [id, cssRules]); }; +const toVarNamespace = (id: string, property: string) => { + return `${property}-${id}`; +}; + const setCssVar = (id: string, property: string, value?: StyleValue) => { const customProperty = `--${toVarNamespace(id, property)}`; if (value === undefined) { diff --git a/apps/designer/app/designer/features/style-panel/controls/font-weight/font-weight-control.tsx b/apps/designer/app/designer/features/style-panel/controls/font-weight/font-weight-control.tsx index a00f040f15..bfe647e781 100644 --- a/apps/designer/app/designer/features/style-panel/controls/font-weight/font-weight-control.tsx +++ b/apps/designer/app/designer/features/style-panel/controls/font-weight/font-weight-control.tsx @@ -1,6 +1,6 @@ import { Select } from "@webstudio-is/design-system"; import { FontWeight, fontWeights } from "@webstudio-is/fonts"; -import { toValue } from "@webstudio-is/react-sdk"; +import { toValue } from "@webstudio-is/css-engine"; import { useMemo } from "react"; import { useAssets } from "~/designer/shared/assets"; import { getFinalValue } from "../../shared/get-final-value"; diff --git a/apps/designer/app/designer/features/style-panel/controls/text/text-control.tsx b/apps/designer/app/designer/features/style-panel/controls/text/text-control.tsx index ed1c01dbda..41aa81f7ed 100644 --- a/apps/designer/app/designer/features/style-panel/controls/text/text-control.tsx +++ b/apps/designer/app/designer/features/style-panel/controls/text/text-control.tsx @@ -1,7 +1,8 @@ import { CssValueInput } from "../../shared/css-value-input"; import { getFinalValue } from "../../shared/get-final-value"; import { ControlProps } from "../../style-sections"; -import { type StyleValue, toValue } from "@webstudio-is/react-sdk"; +import { type StyleValue } from "@webstudio-is/react-sdk"; +import { toValue } from "@webstudio-is/css-engine"; import { useState } from "react"; import { Box, Tooltip } from "@webstudio-is/design-system"; diff --git a/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx b/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx index cb0328b750..c5fe5662ee 100644 --- a/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx +++ b/apps/designer/app/designer/features/style-panel/sections/spacing/spacing.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; import { categories, - toValue, type StyleProperty, type StyleValue, } from "@webstudio-is/react-sdk"; import { Box } from "@webstudio-is/design-system"; +import { toValue } from "@webstudio-is/css-engine"; import { SetProperty } from "../../shared/use-style-data"; import { useIsFromCurrentBreakpoint } from "../../shared/use-is-from-current-breakpoint"; import { propertyNameColorForSelectedBreakpoint } from "../../shared/constants"; diff --git a/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx b/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx index f92544cf75..6012a7dfc0 100644 --- a/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx +++ b/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx @@ -1,10 +1,6 @@ import { useState, useMemo } from "react"; -import { - type Unit, - type UnitValue, - StyleValue, - toValue, -} from "@webstudio-is/react-sdk"; +import { type Unit, type UnitValue, StyleValue } from "@webstudio-is/react-sdk"; +import { toValue } from "@webstudio-is/css-engine"; import * as SelectPrimitive from "@radix-ui/react-select"; import { styled, diff --git a/apps/designer/app/designer/features/style-panel/shared/get-final-value.ts b/apps/designer/app/designer/features/style-panel/shared/get-final-value.ts index ccdf9d8ce1..07fb72ba94 100644 --- a/apps/designer/app/designer/features/style-panel/shared/get-final-value.ts +++ b/apps/designer/app/designer/features/style-panel/shared/get-final-value.ts @@ -1,9 +1,5 @@ -import { - Style, - StyleValue, - StyleProperty, - toValue, -} from "@webstudio-is/react-sdk"; +import type { Style, StyleValue, StyleProperty } from "@webstudio-is/react-sdk"; +import { toValue } from "@webstudio-is/css-engine"; import type { InheritedStyle } from "./get-inherited-style"; import { isValid } from "./parse-css-value"; diff --git a/apps/designer/app/designer/features/style-panel/style-sections.tsx b/apps/designer/app/designer/features/style-panel/style-sections.tsx index 04112db691..ed77fa82d3 100644 --- a/apps/designer/app/designer/features/style-panel/style-sections.tsx +++ b/apps/designer/app/designer/features/style-panel/style-sections.tsx @@ -1,10 +1,9 @@ -import { Grid } from "@webstudio-is/design-system"; +import { Grid, type CSS } from "@webstudio-is/design-system"; import type { StyleConfig } from "./shared/configs"; import { type Style, type StyleProperty, type Category, - type CSS, } from "@webstudio-is/react-sdk"; import type { SetProperty, CreateBatchUpdate } from "./shared/use-style-data"; import type { InheritedStyle } from "./shared/get-inherited-style"; diff --git a/apps/designer/app/routes/s/css.ts b/apps/designer/app/routes/s/css.ts index 098924ea01..97d7a57feb 100644 --- a/apps/designer/app/routes/s/css.ts +++ b/apps/designer/app/routes/s/css.ts @@ -6,6 +6,8 @@ import env from "~/env.server"; import { sentryException } from "~/shared/sentry"; import { createCssEngine } from "@webstudio-is/css-engine"; import { getCssRules } from "~/shared/tree-utils"; +import { idAttribute } from "@webstudio-is/react-sdk"; +import { addGlobalRules } from "~/canvas/shared/styles"; export const loader: ActionFunction = async ({ request }) => { try { @@ -33,13 +35,15 @@ export const loader: ActionFunction = async ({ request }) => { const engine = createCssEngine(); + addGlobalRules(engine, canvasData); + for (const breakpoint of canvasData.breakpoints) { engine.addMediaRule(breakpoint.id, breakpoint); } const cssRules = getCssRules(canvasData.tree?.root); for (const [instanceId, cssRule] of cssRules) { - engine.addStyleRule(`[data-ws-id="${instanceId}"]`, cssRule); + engine.addStyleRule(`[${idAttribute}="${instanceId}"]`, cssRule); } const { cssText } = engine; diff --git a/packages/css-engine/src/core/css-engine.test.ts b/packages/css-engine/src/core/css-engine.test.ts index 490fb26bd3..6ba8452d5f 100644 --- a/packages/css-engine/src/core/css-engine.test.ts +++ b/packages/css-engine/src/core/css-engine.test.ts @@ -164,4 +164,43 @@ describe("CssEngine", () => { }" `); }); + + test("plaintext rule", () => { + engine.addPlaintextRule(".c { color: red }"); + expect(engine.cssText).toMatchInlineSnapshot(`".c { color: red }"`); + }); + + test("plaintext - no duplicates", () => { + engine.addPlaintextRule(".c { color: red }"); + engine.addPlaintextRule(".c { color: red }"); + engine.addPlaintextRule(".c { color: green }"); + expect(engine.cssText).toMatchInlineSnapshot(` + ".c { color: red } + .c { color: green }" + `); + }); + + test("font family rule", () => { + engine.addFontFaceRule({ + fontFamily: "Roboto", + fontStyle: "normal", + fontWeight: 400, + fontDisplay: "swap", + src: "url(/src)", + }); + expect(engine.cssText).toMatchInlineSnapshot(` + "@font-face { + font-family: Roboto; font-style: normal; font-weight: 400; font-display: swap; src: url(/src); + }" + `); + }); + + test("clear", () => { + engine.addStyleRule(".c", { + style: style0, + breakpoint: "0", + }); + engine.clear(); + expect(engine.cssText).toMatchInlineSnapshot(`""`); + }); }); diff --git a/packages/css-engine/src/core/css-engine.ts b/packages/css-engine/src/core/css-engine.ts index b25a9703fb..f043972709 100644 --- a/packages/css-engine/src/core/css-engine.ts +++ b/packages/css-engine/src/core/css-engine.ts @@ -1,5 +1,12 @@ import { CssRule } from "@webstudio-is/react-sdk"; -import { MediaRule, StyleRule, type MediaRuleOptions } from "./rules"; +import { + FontFaceRule, + MediaRule, + PlaintextRule, + StyleRule, + type FontFaceOptions, + type MediaRuleOptions, +} from "./rules"; import { StyleElement } from "./style-element"; import { StyleSheet } from "./style-sheet"; @@ -8,6 +15,8 @@ const defaultMediaRuleId = "__default-media-rule__"; export class CssEngine { #element; #mediaRules: Map = new Map(); + #plainRules: Map = new Map(); + #fontFaceRules: Array = []; #sheet: StyleSheet; #isDirty = false; #cssText = ""; @@ -38,6 +47,20 @@ export class CssEngine { } return mediaRule.insertRule(styleRule); } + addPlaintextRule(cssText: string) { + const rule = this.#plainRules.get(cssText); + if (rule !== undefined) return rule; + return this.#plainRules.set(cssText, new PlaintextRule(cssText)); + } + addFontFaceRule(options: FontFaceOptions) { + return this.#fontFaceRules.push(new FontFaceRule(options)); + } + clear() { + this.#mediaRules.clear(); + this.#plainRules.clear(); + this.#fontFaceRules = []; + this.#isDirty = true; + } render() { this.#element.mount(); // This isn't going to do anything if the `cssText` hasn't changed. @@ -49,6 +72,11 @@ export class CssEngine { } this.#isDirty = false; const css: Array = []; + + css.push(...this.#fontFaceRules.map((rule) => rule.cssText)); + for (const plaintextRule of this.#plainRules.values()) { + css.push(plaintextRule.cssText); + } for (const mediaRule of this.#mediaRules.values()) { const { cssText } = mediaRule; if (cssText !== "") css.push(cssText); diff --git a/packages/css-engine/src/core/index.ts b/packages/css-engine/src/core/index.ts index 1b9ef7391f..a985ac4dc5 100644 --- a/packages/css-engine/src/core/index.ts +++ b/packages/css-engine/src/core/index.ts @@ -1,3 +1,10 @@ export { CssEngine } from "./css-engine"; -export type { AnyRule, StyleRule, MediaRule } from "./rules"; +export type { + AnyRule, + StyleRule, + MediaRule, + PlaintextRule, + FontFaceRule, +} from "./rules"; export * from "./create-css-engine"; +export * from "./to-value"; diff --git a/packages/css-engine/src/core/rules.ts b/packages/css-engine/src/core/rules.ts index 8438f96230..46db116cf1 100644 --- a/packages/css-engine/src/core/rules.ts +++ b/packages/css-engine/src/core/rules.ts @@ -1,10 +1,10 @@ import { - toValue, type Style, type StyleProperty, type StyleValue, } from "@webstudio-is/react-sdk"; import hyphenate from "hyphenate-style-name"; +import { toValue } from "./to-value"; class StylePropertyMap { #styleMap: Map = new Map(); @@ -60,13 +60,13 @@ export type MediaRuleOptions = { export class MediaRule { #options: MediaRuleOptions; - rules: Array = []; + rules: Array = []; #mediaType; constructor(options: MediaRuleOptions = {}) { this.#options = options; this.#mediaType = options.mediaType ?? "all"; } - insertRule(rule: StyleRule) { + insertRule(rule: StyleRule | PlaintextRule) { this.rules.push(rule); return rule; } @@ -87,4 +87,32 @@ export class MediaRule { } } -export type AnyRule = StyleRule | MediaRule; +export class PlaintextRule { + cssText; + styleMap = new Map(); + constructor(cssText: string) { + this.cssText = cssText; + } +} + +export type FontFaceOptions = { + fontFamily: string; + fontStyle: "normal" | "italic" | "oblique"; + fontWeight: number; + fontDisplay: "swap" | "auto" | "block" | "fallback" | "optional"; + src: string; +}; + +export class FontFaceRule { + #options: FontFaceOptions; + constructor(options: FontFaceOptions) { + this.#options = options; + } + get cssText() { + const { fontFamily, fontStyle, fontWeight, fontDisplay, src } = + this.#options; + return `@font-face {\n font-family: ${fontFamily}; font-style: ${fontStyle}; font-weight: ${fontWeight}; font-display: ${fontDisplay}; src: ${src};\n}`; + } +} + +export type AnyRule = StyleRule | MediaRule | PlaintextRule | FontFaceRule; diff --git a/packages/css-engine/src/core/to-value.test.ts b/packages/css-engine/src/core/to-value.test.ts new file mode 100644 index 0000000000..7051daf341 --- /dev/null +++ b/packages/css-engine/src/core/to-value.test.ts @@ -0,0 +1,66 @@ +import { toValue } from "./to-value"; + +describe("Convert WS CSS Values to native CSS strings", () => { + test("keyword", () => { + const value = toValue({ type: "keyword", value: "red" }); + expect(value).toBe("red"); + }); + + test("unit", () => { + const value = toValue({ type: "unit", value: 10, unit: "px" }); + expect(value).toBe("10px"); + }); + + test("invalid", () => { + const value = toValue({ type: "invalid", value: "bad" }); + expect(value).toBe("bad"); + }); + + test("unset", () => { + const value = toValue({ type: "unset", value: "" }); + expect(value).toBe(""); + }); + + test("var", () => { + const value = toValue({ type: "var", value: "namespace", fallbacks: [] }); + expect(value).toBe("var(--namespace)"); + }); + + test("var with fallbacks", () => { + const value = toValue({ + type: "var", + value: "namespace", + fallbacks: [ + { + type: "keyword", + value: "normal", + }, + { + type: "unit", + value: 10, + unit: "px", + }, + ], + }); + expect(value).toBe("var(--namespace, normal, 10px)"); + }); + + test("fontFamily", () => { + const value = toValue({ + type: "fontFamily", + value: ["Courier New"], + }); + expect(value).toBe("Courier New, monospace"); + }); + + test("withFallback=false", () => { + const value = toValue( + { + type: "fontFamily", + value: ["Courier New"], + }, + { withFallback: false } + ); + expect(value).toBe("Courier New"); + }); +}); diff --git a/packages/css-engine/src/core/to-value.ts b/packages/css-engine/src/core/to-value.ts new file mode 100644 index 0000000000..a87bd37773 --- /dev/null +++ b/packages/css-engine/src/core/to-value.ts @@ -0,0 +1,41 @@ +import type { StyleValue } from "@webstudio-is/react-sdk"; +import { DEFAULT_FONT_FALLBACK, SYSTEM_FONTS } from "@webstudio-is/fonts"; + +type ToCssOptions = { + withFallback: boolean; +}; + +const defaultOptions = { + withFallback: true, +}; + +export const toValue = ( + value?: StyleValue, + options: ToCssOptions = defaultOptions +): string => { + if (value === undefined) return ""; + if (value.type === "unit") { + return value.value + (value.unit === "number" ? "" : value.unit); + } + if (value.type === "fontFamily") { + if (options.withFallback === false) { + return value.value[0]; + } + const family = value.value[0]; + const fallbacks = SYSTEM_FONTS.get(family); + if (Array.isArray(fallbacks)) { + return [...value.value, ...fallbacks].join(", "); + } + return [...value.value, DEFAULT_FONT_FALLBACK].join(", "); + } + if (value.type === "var") { + const fallbacks = []; + for (const fallback of value.fallbacks) { + fallbacks.push(toValue(fallback, options)); + } + const fallbacksString = + fallbacks.length > 0 ? `, ${fallbacks.join(", ")}` : ""; + return `var(--${value.value}${fallbacksString})`; + } + return value.value; +}; diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 5c51b2848d..b176120cc8 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -59,7 +59,6 @@ "react-dom": "^17.0.2" }, "dependencies": { - "@stitches/core": "^1.2.7", "@webstudio-is/asset-uploader": "^*", "@webstudio-is/fonts": "*", "@webstudio-is/icons": "*", diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 7458f83c4d..ec43abd184 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -1,6 +1,5 @@ export * from "./css"; export * from "./tree"; -export * from "./stitches"; export * as components from "./components"; export * as componentsMeta from "./components/meta"; export * from "./user-props"; diff --git a/packages/react-sdk/src/remix/constants.ts b/packages/react-sdk/src/remix/constants.ts deleted file mode 100644 index b5ddbd71f5..0000000000 --- a/packages/react-sdk/src/remix/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const CRITICAL_CSS_MARKER = "__CRITICAL_CSS__"; diff --git a/packages/react-sdk/src/remix/handle-request.server.tsx b/packages/react-sdk/src/remix/handle-request.server.tsx index 8efe2c9943..66f255cb8a 100644 --- a/packages/react-sdk/src/remix/handle-request.server.tsx +++ b/packages/react-sdk/src/remix/handle-request.server.tsx @@ -1,7 +1,6 @@ import { renderToString } from "react-dom/server"; import { RemixServer } from "@remix-run/react"; import type { EntryContext } from "@remix-run/node"; -import { insertCriticalCss } from "./insert-critical-css"; export const handleRequest = ( request: Request, @@ -15,7 +14,7 @@ export const handleRequest = ( responseHeaders.set("Content-Type", "text/html"); - return new Response("" + insertCriticalCss(markup), { + return new Response(`${markup}`, { status: responseStatusCode, headers: responseHeaders, }); diff --git a/packages/react-sdk/src/remix/insert-critical-css.ts b/packages/react-sdk/src/remix/insert-critical-css.ts deleted file mode 100644 index 9f0bdd20ca..0000000000 --- a/packages/react-sdk/src/remix/insert-critical-css.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { flushCss } from "../stitches"; -import { CRITICAL_CSS_MARKER } from "./constants"; - -export const insertCriticalCss = (markup: string): string => { - return markup.replace(CRITICAL_CSS_MARKER, ``); -}; diff --git a/packages/react-sdk/src/remix/root.tsx b/packages/react-sdk/src/remix/root.tsx index c584e65ebf..3dc43f8d81 100644 --- a/packages/react-sdk/src/remix/root.tsx +++ b/packages/react-sdk/src/remix/root.tsx @@ -1,6 +1,5 @@ import { Links, Meta, Outlet as DefaultOutlet } from "@remix-run/react"; import { DynamicLinks } from "remix-utils"; -import { CRITICAL_CSS_MARKER } from "./constants"; /** * We are using Outlet prop from index layout when user renders site from a subdomain. @@ -20,7 +19,6 @@ export const Root = ({ - {typeof document === "undefined" ? CRITICAL_CSS_MARKER : null} diff --git a/packages/react-sdk/src/stitches/index.ts b/packages/react-sdk/src/stitches/index.ts deleted file mode 100644 index cba98204fc..0000000000 --- a/packages/react-sdk/src/stitches/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./to-css"; -export * from "./stitches"; diff --git a/packages/react-sdk/src/stitches/stitches.ts b/packages/react-sdk/src/stitches/stitches.ts deleted file mode 100644 index e724c3db2e..0000000000 --- a/packages/react-sdk/src/stitches/stitches.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createStitches, type CSS } from "@stitches/core"; -import type { FontFace } from "@webstudio-is/fonts"; -import type { Breakpoint } from "../css"; - -let media = {}; - -const createStitchesInstance = () => createStitches({ media }); - -type StitchesInstance = ReturnType; - -let stitches: StitchesInstance | undefined; - -let fontsCss = ""; - -export { type CSS }; - -const getCachedConfig = () => { - if (stitches === undefined) { - stitches = createStitchesInstance(); - } - return stitches; -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const css: StitchesInstance["css"] = (...args: any[]) => { - return getCachedConfig().css(...args); -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const globalCss: StitchesInstance["globalCss"] = (...args: any[]) => { - return getCachedConfig().globalCss(...args); -}; - -export const flushCss = () => { - const config = getCachedConfig(); - const css = config.getCssText() + fontsCss; - config.reset(); - fontsCss = ""; - return css; -}; - -export const setBreakpoints = (breakpoints: Array) => { - const nextMedia: Record = {}; - for (const breakpoint of breakpoints) { - nextMedia[breakpoint.id] = `(min-width: ${breakpoint.minWidth}px)`; - } - media = nextMedia; - stitches = undefined; -}; - -let styleElement: HTMLStyleElement | undefined; - -/** - * Stitches doesn't update fonts over globalCss - */ -export const renderFontFaces = (fontFaces: Array) => { - fontsCss = fontFaces - .map( - (fontFace) => - ` -@font-face { - font-family: ${fontFace.fontFamily}; - font-style: ${fontFace.fontStyle}; - font-weight: ${fontFace.fontWeight}; - font-display: ${fontFace.fontDisplay}; - src: ${fontFace.src}; -}` - ) - .join(""); - - if ( - typeof document !== "undefined" && - typeof document.createElement === "function" - ) { - if (styleElement === undefined) { - styleElement = document.createElement("style"); - document.head.appendChild(styleElement); - } - styleElement.textContent = fontsCss; - } -}; diff --git a/packages/react-sdk/src/stitches/to-css.test.ts b/packages/react-sdk/src/stitches/to-css.test.ts deleted file mode 100644 index b18084d17f..0000000000 --- a/packages/react-sdk/src/stitches/to-css.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { Instance } from "../db"; -import { type CssRule, initialBreakpoints, type Breakpoint } from "../css"; -import { toCss } from "./to-css"; - -const breakpoints: Array = initialBreakpoints.map( - (breakpoint, index) => ({ - ...breakpoint, - id: String(index), - projectId: "projectId", - }) -); - -const createInstance = (cssRules: Array): Instance => { - return { - id: "0", - cssRules, - component: "Box", - children: [], - }; -}; - -describe("Convert WS CSS rules to stitches", () => { - test("keyword", () => { - const instance = createInstance([ - { - style: { - color: { - type: "keyword", - value: "red", - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - color: "red", - }, - }); - }); - - test("unit", () => { - const instance = createInstance([ - { - style: { - width: { - type: "unit", - value: 10, - unit: "px", - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - width: "10px", - }, - }); - }); - - test("invalid", () => { - const instance = createInstance([ - { - style: { - width: { - type: "invalid", - value: "bad", - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - width: "bad", - }, - }); - }); - - test("unset", () => { - const instance = createInstance([ - { - style: { - width: { - type: "unset", - value: "", - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - width: "", - }, - }); - }); - - test("var", () => { - const instance = createInstance([ - { - style: { - width: { - type: "var", - value: "namespace", - fallbacks: [], - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - width: "var(--namespace)", - }, - }); - }); - - test("var with fallbacks", () => { - const instance = createInstance([ - { - style: { - width: { - type: "var", - value: "namespace", - fallbacks: [ - { - type: "keyword", - value: "normal", - }, - { - type: "unit", - value: 10, - unit: "px", - }, - ], - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - width: "var(--namespace, normal, 10px)", - }, - }); - }); - - test("fontFamily", () => { - const instance = createInstance([ - { - style: { - fontFamily: { - type: "fontFamily", - value: ["Courier New"], - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints); - expect(stitchesCss).toEqual({ - "@0": { - fontFamily: "Courier New, monospace", - }, - }); - }); - - test("withFallback option", () => { - const instance = createInstance([ - { - style: { - fontFamily: { - type: "fontFamily", - value: ["Courier New"], - }, - }, - breakpoint: "0", - }, - ]); - const stitchesCss = toCss(instance, breakpoints, { withFallback: false }); - expect(stitchesCss).toEqual({ - "@0": { - fontFamily: "Courier New", - }, - }); - }); - - test("sort order based on maxWidth in breakpoints", () => { - const instance = createInstance([ - { - style: { - color: { - type: "keyword", - value: "green", - }, - }, - breakpoint: "2", - }, - - { - style: { - color: { - type: "keyword", - value: "blue", - }, - }, - breakpoint: "0", - }, - { - style: { - color: { - type: "keyword", - value: "red", - }, - }, - breakpoint: "1", - }, - ]); - - const stitchesCss = toCss(instance, breakpoints); - expect(Object.keys(stitchesCss)).toStrictEqual(["@0", "@1", "@2"]); - }); -}); diff --git a/packages/react-sdk/src/stitches/to-css.ts b/packages/react-sdk/src/stitches/to-css.ts deleted file mode 100644 index b5b49efabe..0000000000 --- a/packages/react-sdk/src/stitches/to-css.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { CSS } from "./stitches"; -import type { StyleProperty, StyleValue, Breakpoint } from "../css"; -import { DEFAULT_FONT_FALLBACK, SYSTEM_FONTS } from "@webstudio-is/fonts"; -import { Instance } from ".."; - -type ToCssOptions = { - withFallback: boolean; -}; - -const defaultOptions = { - withFallback: true, -}; - -export const toValue = ( - value?: StyleValue, - options: ToCssOptions = defaultOptions -): string => { - if (value === undefined) return ""; - if (value.type === "unit") { - return value.value + (value.unit === "number" ? "" : value.unit); - } - if (value.type === "fontFamily") { - if (options.withFallback === false) { - return value.value[0]; - } - const family = value.value[0]; - const fallbacks = SYSTEM_FONTS.get(family); - if (Array.isArray(fallbacks)) { - return [...value.value, ...fallbacks].join(", "); - } - return [...value.value, DEFAULT_FONT_FALLBACK].join(", "); - } - if (value.type === "var") { - const fallbacks = []; - for (const fallback of value.fallbacks) { - fallbacks.push(toValue(fallback, options)); - } - const fallbacksString = - fallbacks.length > 0 ? `, ${fallbacks.join(", ")}` : ""; - return `var(--${value.value}${fallbacksString})`; - } - return value.value; -}; - -export const toVarNamespace = (id: string, property: string) => { - return `${property}-${id}`; -}; - -/** - * Convert instance cssRules to a stitches CSS object. - */ -export const toCss = ( - instance: Instance, - breakpoints: Array, - options: ToCssOptions = defaultOptions -): CSS => { - const css: CSS = {}; - const { cssRules } = instance; - const breakpointsMap: Record = {}; - for (const breakpoint of breakpoints) { - breakpointsMap[breakpoint.id] = breakpoint.minWidth; - } - - const sortedCssRules = [...cssRules].sort((ruleA, ruleB) => { - // If a rule references a breakpoint that was not found in breakpoints, - // we must have removed the breakpoint and now we fall back to 0. - const maxWidthA = breakpointsMap[ruleA.breakpoint] ?? 0; - const maxWidthB = breakpointsMap[ruleB.breakpoint] ?? 0; - return maxWidthA - maxWidthB; - }); - - for (const cssRule of sortedCssRules) { - const style: CSS = {}; - for (const property in cssRule.style) { - const value = cssRule.style[property as StyleProperty]; - if (value === undefined) continue; - style[property] = toValue(value, options); - } - - if (cssRule.breakpoint in breakpointsMap) { - css["@" + cssRule.breakpoint] = style; - continue; - } - - Object.assign(css, style); - } - return css; -}; diff --git a/packages/react-sdk/src/tree/root.ts b/packages/react-sdk/src/tree/root.ts index 784beadab7..aae3b24513 100644 --- a/packages/react-sdk/src/tree/root.ts +++ b/packages/react-sdk/src/tree/root.ts @@ -1,16 +1,10 @@ import { useAllUserProps } from "../user-props/"; import type { Tree, InstanceProps } from "../db"; import type { Breakpoint } from "../css"; -import { globalCss, setBreakpoints, renderFontFaces } from "../stitches"; import { createElementsTree } from "./create-elements-tree"; import { WrapperComponent } from "./wrapper-component"; -import { - type FontFormat, - FONT_FORMATS, - getFontFaces, -} from "@webstudio-is/fonts"; -import type { Asset, FontAsset } from "@webstudio-is/asset-uploader"; -import { type ComponentProps, useRef } from "react"; +import type { Asset } from "@webstudio-is/asset-uploader"; +import { type ComponentProps } from "react"; export type Data = { tree: Tree | null; @@ -19,27 +13,6 @@ export type Data = { assets: Array; }; -export const useGlobalStyles = ({ assets }: { assets: Array }) => { - const ref = useRef>(); - - // This may look weird, but globalCss API doesn't allow us creating global styles with data, - // so we have to manually ensure calling it only once - if (ref.current === assets) return; - - const fontAssets = assets.filter((asset) => - FONT_FORMATS.has(asset.format as FontFormat) - ) as Array; - - renderFontFaces(getFontFaces(fontAssets)); - globalCss({ - html: { - height: "100%", - }, - })(); - - ref.current = assets; -}; - type RootProps = { data: Data; Component?: (props: ComponentProps) => JSX.Element; @@ -52,8 +25,6 @@ export const InstanceRoot = ({ if (data.tree === null) { throw new Error("Tree is null"); } - setBreakpoints(data.breakpoints); - useGlobalStyles({ assets: data.assets }); useAllUserProps(data.props); return createElementsTree({ instance: data.tree.root, diff --git a/packages/react-sdk/src/tree/wrapper-component.tsx b/packages/react-sdk/src/tree/wrapper-component.tsx index a362f5839a..fa775a717d 100644 --- a/packages/react-sdk/src/tree/wrapper-component.tsx +++ b/packages/react-sdk/src/tree/wrapper-component.tsx @@ -39,9 +39,10 @@ export const WrapperComponent = ({ }: WrapperComponentProps) => { const { Component } = components[instance.component]; const userProps = useUserProps(instance.id); + const props = { ...userProps, ...rest, [idAttribute]: instance.id }; return ( - - {renderWrapperComponentChildren(children)} - + {renderWrapperComponentChildren(children)} ); }; + +export const idAttribute = "data-ws-id"; diff --git a/yarn.lock b/yarn.lock index 97eb7b56f2..df74f1ea59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3854,11 +3854,6 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@stitches/core@^1.2.7": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@stitches/core/-/core-1.2.8.tgz#dce3b8fdc764fbc6dbea30c83b73bfb52cf96173" - integrity sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg== - "@stitches/react@^1.2.8": version "1.2.8" resolved "https://registry.yarnpkg.com/@stitches/react/-/react-1.2.8.tgz#954f8008be8d9c65c4e58efa0937f32388ce3a38"