mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
Merge pull request #456 from webstudio-is/css-engine
Removing stitches from sdk and canvas entirely
This commit is contained in:
30 files changed
+329
-524
No files matched your search
@@ -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 (
|
||||
<>
|
||||
<GlobalStyles assets={assets} />
|
||||
{elements}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles assets={assets} />
|
||||
<DesignMode treeId={data.tree.id} buildId={data.buildId} />
|
||||
{elements}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Breakpoint>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<Asset> }
|
||||
) => {
|
||||
// @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<FontAsset>;
|
||||
const fontFaces = getFontFaces(fontAssets);
|
||||
for (const fontFace of fontFaces) {
|
||||
engine.addFontFaceRule(fontFace);
|
||||
}
|
||||
};
|
||||
|
||||
const globalStylesCssEngine = createCssEngine();
|
||||
|
||||
export const GlobalStyles = ({ assets }: { assets: Array<Asset> }) => {
|
||||
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<Breakpoint>) => {
|
||||
}
|
||||
};
|
||||
|
||||
const wrappedRulesMap = new Map<string, StyleRule>();
|
||||
const wrappedRulesMap = new Map<string, StyleRule | PlaintextRule>();
|
||||
|
||||
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) {
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
+2
-6
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(`""`);
|
||||
});
|
||||
});
|
||||
@@ -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<string, MediaRule> = new Map();
|
||||
#plainRules: Map<string, PlaintextRule> = new Map();
|
||||
#fontFaceRules: Array<FontFaceRule> = [];
|
||||
#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<string> = [];
|
||||
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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<StyleProperty, StyleValue | undefined> = new Map();
|
||||
@@ -60,13 +60,13 @@ export type MediaRuleOptions = {
|
||||
|
||||
export class MediaRule {
|
||||
#options: MediaRuleOptions;
|
||||
rules: Array<StyleRule> = [];
|
||||
rules: Array<StyleRule | PlaintextRule> = [];
|
||||
#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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const CRITICAL_CSS_MARKER = "__CRITICAL_CSS__";
|
||||
@@ -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("<!DOCTYPE html>" + insertCriticalCss(markup), {
|
||||
return new Response(`<!DOCTYPE html>${markup}`, {
|
||||
status: responseStatusCode,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
@@ -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, `<style>${flushCss()}</style>`);
|
||||
};
|
||||
@@ -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 = ({
|
||||
<Meta />
|
||||
<DynamicLinks />
|
||||
<Links />
|
||||
{typeof document === "undefined" ? CRITICAL_CSS_MARKER : null}
|
||||
</head>
|
||||
|
||||
<Outlet />
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./to-css";
|
||||
export * from "./stitches";
|
||||
@@ -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<typeof createStitchesInstance>;
|
||||
|
||||
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<Breakpoint>) => {
|
||||
const nextMedia: Record<string, string> = {};
|
||||
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<FontFace>) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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<Breakpoint> = initialBreakpoints.map(
|
||||
(breakpoint, index) => ({
|
||||
...breakpoint,
|
||||
id: String(index),
|
||||
projectId: "projectId",
|
||||
})
|
||||
);
|
||||
|
||||
const createInstance = (cssRules: Array<CssRule>): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<Breakpoint>,
|
||||
options: ToCssOptions = defaultOptions
|
||||
): CSS => {
|
||||
const css: CSS = {};
|
||||
const { cssRules } = instance;
|
||||
const breakpointsMap: Record<Breakpoint["id"], number> = {};
|
||||
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;
|
||||
};
|
||||
@@ -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<Asset>;
|
||||
};
|
||||
|
||||
export const useGlobalStyles = ({ assets }: { assets: Array<Asset> }) => {
|
||||
const ref = useRef<Array<Asset>>();
|
||||
|
||||
// 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<FontAsset>;
|
||||
|
||||
renderFontFaces(getFontFaces(fontAssets));
|
||||
globalCss({
|
||||
html: {
|
||||
height: "100%",
|
||||
},
|
||||
})();
|
||||
|
||||
ref.current = assets;
|
||||
};
|
||||
|
||||
type RootProps = {
|
||||
data: Data;
|
||||
Component?: (props: ComponentProps<typeof WrapperComponent>) => 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,
|
||||
|
||||
@@ -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 (
|
||||
<Component {...userProps} {...rest} data-ws-id={instance.id}>
|
||||
{renderWrapperComponentChildren(children)}
|
||||
</Component>
|
||||
<Component {...props}>{renderWrapperComponentChildren(children)}</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const idAttribute = "data-ws-id";
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user