diff --git a/packages/sdk-components-react/src/templates.ts b/packages/sdk-components-react/src/templates.ts index e577449ff29b..8c13f93deb35 100644 --- a/packages/sdk-components-react/src/templates.ts +++ b/packages/sdk-components-react/src/templates.ts @@ -1,2 +1,3 @@ export { meta as Button } from "./button.template"; export { meta as List } from "./list.template"; +export { meta as Vimeo } from "./vimeo.template"; diff --git a/packages/sdk-components-react/src/vimeo.template.tsx b/packages/sdk-components-react/src/vimeo.template.tsx new file mode 100644 index 000000000000..ce0d2a7a80d8 --- /dev/null +++ b/packages/sdk-components-react/src/vimeo.template.tsx @@ -0,0 +1,79 @@ +import { PlayIcon, SpinnerIcon } from "@webstudio-is/icons/svg"; +import { type TemplateMeta, $, css } from "@webstudio-is/template"; + +export const meta: TemplateMeta = { + category: "media", + order: 1, + description: + "Add a video to your page that is hosted on Vimeo. Paste a Vimeo URL and configure the video in the Settings tab.", + template: ( + <$.Vimeo + ws:style={css` + position: relative; + aspect-ratio: 640/360; + width: 100%; + `} + > + <$.VimeoPreviewImage + ws:style={css` + position: absolute; + object-fit: cover; + object-position: cover; + width: 100%; + height: 100%; + border-radius: 20px; + `} + alt="Vimeo video preview image" + sizes="100vw" + /> + <$.VimeoSpinner + ws:label="Spinner" + ws:style={css` + position: absolute; + top: 50%; + left: 50%; + width: 70px; + height: 70px; + margin-top: -35px; + margin-left: -35px; + `} + > + <$.HtmlEmbed ws:label="Spinner SVG" code={SpinnerIcon} /> + + <$.VimeoPlayButton + ws:style={css` + position: absolute; + width: 140px; + height: 80px; + top: 50%; + left: 50%; + margin-top: -40px; + margin-left: -70px; + display: flex; + align-items: center; + justify-content: center; + border-style: none; + border-radius: 5px; + cursor: pointer; + background-color: rgb(18, 18, 18); + color: rgb(255, 255, 255); + &:hover { + background-color: rgb(0, 173, 239); + } + `} + aria-label="Play button" + > + <$.Box + ws:label="Play Icon" + ws:style={css` + width: 60px; + height: 60px; + `} + aria-hidden={true} + > + <$.HtmlEmbed ws:label="Play SVG" code={PlayIcon} /> + + + + ), +}; diff --git a/packages/sdk-components-react/src/vimeo.ws.ts b/packages/sdk-components-react/src/vimeo.ws.ts index 2830aaf7972c..85c8c2ed4fd6 100644 --- a/packages/sdk-components-react/src/vimeo.ws.ts +++ b/packages/sdk-components-react/src/vimeo.ws.ts @@ -1,4 +1,5 @@ -import { PlayIcon, SpinnerIcon, VimeoIcon } from "@webstudio-is/icons/svg"; +import type { ComponentProps } from "react"; +import { VimeoIcon } from "@webstudio-is/icons/svg"; import { defaultStates, type PresetStyle, @@ -8,18 +9,13 @@ import { import { div } from "@webstudio-is/sdk/normalize.css"; import { props } from "./__generated__/vimeo.props"; import { Vimeo } from "./vimeo"; -import type { ComponentProps } from "react"; const presetStyle = { div, } satisfies PresetStyle<"div">; export const meta: WsComponentMeta = { - category: "media", type: "container", - description: - "Add a video to your page that is hosted on Vimeo. Paste a Vimeo URL and configure the video in the Settings tab.", - order: 1, icon: VimeoIcon, states: defaultStates, presetStyle, @@ -27,292 +23,6 @@ export const meta: WsComponentMeta = { relation: "ancestor", component: { $nin: ["Button", "Link", "Heading"] }, }, - template: [ - { - type: "instance", - component: "Vimeo", - styles: [ - { - property: "position", - value: { type: "keyword", value: "relative" }, - }, - { - property: "aspectRatio", - value: { type: "keyword", value: "640/360" }, - }, - { - property: "width", - value: { type: "unit", value: 100, unit: "%" }, - }, - ], - children: [ - { - type: "instance", - component: "VimeoPreviewImage", - styles: [ - { - property: "position", - value: { type: "keyword", value: "absolute" }, - }, - { - property: "objectFit", - value: { type: "keyword", value: "cover" }, - }, - { - property: "width", - value: { type: "unit", value: 100, unit: "%" }, - }, - { - property: "height", - value: { type: "unit", value: 100, unit: "%" }, - }, - { - property: "borderTopLeftRadius", - value: { type: "unit", value: 20, unit: "px" }, - }, - { - property: "borderTopRightRadius", - value: { type: "unit", value: 20, unit: "px" }, - }, - { - property: "borderBottomLeftRadius", - value: { type: "unit", value: 20, unit: "px" }, - }, - { - property: "borderBottomRightRadius", - value: { type: "unit", value: 20, unit: "px" }, - }, - { - property: "objectPosition", - value: { type: "keyword", value: "cover" }, - }, - ], - children: [], - props: [ - { - type: "string", - name: "alt", - value: "Vimeo video preview image", - }, - { - type: "string", - name: "sizes", - value: "100vw", - }, - ], - }, - { - type: "instance", - component: "VimeoSpinner", - label: "Spinner", - styles: [ - { - property: "position", - value: { type: "keyword", value: "absolute" }, - }, - { - property: "top", - value: { type: "unit", value: 50, unit: "%" }, - }, - { - property: "left", - value: { type: "unit", value: 50, unit: "%" }, - }, - { - property: "width", - value: { type: "unit", value: 70, unit: "px" }, - }, - { - property: "height", - value: { type: "unit", value: 70, unit: "px" }, - }, - { - property: "marginTop", - value: { type: "unit", value: -35, unit: "px" }, - }, - { - property: "marginLeft", - value: { type: "unit", value: -35, unit: "px" }, - }, - ], - children: [ - { - type: "instance", - component: "HtmlEmbed", - label: "Spinner SVG", - props: [ - { - type: "string", - name: "code", - value: SpinnerIcon, - }, - ], - children: [], - }, - ], - }, - { - type: "instance", - component: "VimeoPlayButton", - props: [ - { - type: "string", - name: "aria-label", - value: "Play button", - }, - ], - styles: [ - { - property: "position", - value: { type: "keyword", value: "absolute" }, - }, - { - property: "width", - value: { type: "unit", value: 140, unit: "px" }, - }, - { - property: "height", - value: { type: "unit", value: 80, unit: "px" }, - }, - { - property: "top", - value: { type: "unit", value: 50, unit: "%" }, - }, - { - property: "left", - value: { type: "unit", value: 50, unit: "%" }, - }, - { - property: "marginTop", - value: { type: "unit", value: -40, unit: "px" }, - }, - { - property: "marginLeft", - value: { type: "unit", value: -70, unit: "px" }, - }, - { - property: "display", - value: { type: "keyword", value: "flex" }, - }, - { - property: "alignItems", - value: { type: "keyword", value: "center" }, - }, - { - property: "justifyContent", - value: { type: "keyword", value: "center" }, - }, - { - property: "borderTopStyle", - value: { type: "keyword", value: "none" }, - }, - { - property: "borderRightStyle", - value: { type: "keyword", value: "none" }, - }, - { - property: "borderBottomStyle", - value: { type: "keyword", value: "none" }, - }, - { - property: "borderLeftStyle", - value: { type: "keyword", value: "none" }, - }, - { - property: "borderTopLeftRadius", - value: { type: "unit", value: 5, unit: "px" }, - }, - { - property: "borderTopRightRadius", - value: { type: "unit", value: 5, unit: "px" }, - }, - { - property: "borderBottomLeftRadius", - value: { type: "unit", value: 5, unit: "px" }, - }, - { - property: "borderBottomRightRadius", - value: { type: "unit", value: 5, unit: "px" }, - }, - { - property: "cursor", - value: { type: "keyword", value: "pointer" }, - }, - { - property: "backgroundColor", - value: { - type: "rgb", - r: 18, - g: 18, - b: 18, - alpha: 1, - }, - }, - { - property: "color", - value: { - type: "rgb", - r: 255, - g: 255, - b: 255, - alpha: 1, - }, - }, - { - state: ":hover", - property: "backgroundColor", - value: { - type: "rgb", - r: 0, - g: 173, - b: 239, - alpha: 1, - }, - }, - ], - children: [ - { - type: "instance", - component: "Box", - label: "Play Icon", - styles: [ - { - property: "width", - value: { type: "unit", value: 60, unit: "px" }, - }, - { - property: "height", - value: { type: "unit", value: 60, unit: "px" }, - }, - ], - props: [ - { - type: "string", - name: "aria-hidden", - value: "true", - }, - ], - children: [ - { - type: "instance", - component: "HtmlEmbed", - label: "Play SVG", - props: [ - { - type: "string", - name: "code", - value: PlayIcon, - }, - ], - children: [], - }, - ], - }, - ], - }, - ], - }, - ], }; const initialProps: Array> = [ diff --git a/packages/template/package.json b/packages/template/package.json index 060bbadf7bd8..7378c4007360 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -16,6 +16,8 @@ "test": "vitest run" }, "dependencies": { + "@webstudio-is/css-data": "workspace:*", + "@webstudio-is/css-engine": "workspace:*", "@webstudio-is/sdk": "workspace:*", "react": "18.3.0-canary-14898b6a9-20240318" }, diff --git a/packages/template/src/css.ts b/packages/template/src/css.ts new file mode 100644 index 000000000000..bebf8461e023 --- /dev/null +++ b/packages/template/src/css.ts @@ -0,0 +1,17 @@ +import { parseCss } from "@webstudio-is/css-data"; +import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine"; + +export type TemplateStyleDecl = { + state?: string; + property: StyleProperty; + value: StyleValue; +}; + +export const css = (strings: TemplateStringsArray): TemplateStyleDecl[] => { + const cssString = `.styles{ ${strings.join()} }`; + const styles: TemplateStyleDecl[] = []; + for (const { state, property, value } of parseCss(cssString)) { + styles.push({ state, property, value }); + } + return styles; +}; diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts index df7c87c7e2b6..b792b88205d6 100644 --- a/packages/template/src/index.ts +++ b/packages/template/src/index.ts @@ -1,2 +1,3 @@ export * from "./jsx"; +export * from "./css"; export * from "./template"; diff --git a/packages/template/src/jsx.test.tsx b/packages/template/src/jsx.test.tsx index e9cfb274d8de..12a96c0c3b13 100644 --- a/packages/template/src/jsx.test.tsx +++ b/packages/template/src/jsx.test.tsx @@ -8,6 +8,7 @@ import { PlaceholderValue, renderTemplate, } from "./jsx"; +import { css } from "./css"; test("render jsx into instances with generated id", () => { const { instances } = renderTemplate( @@ -213,3 +214,80 @@ test("render placeholder value", () => { }, ]); }); + +test("generate local styles", () => { + const { breakpoints, styleSources, styleSourceSelections, styles } = + renderTemplate( + <$.Body + ws:style={css` + color: red; + `} + > + <$.Box + ws:style={css` + font-size: 10px; + `} + > + + ); + expect(breakpoints).toEqual([{ id: "base", label: "" }]); + expect(styleSources).toEqual([ + { id: "0:ws:style", type: "local" }, + { id: "1:ws:style", type: "local" }, + ]); + expect(styleSourceSelections).toEqual([ + { instanceId: "0", values: ["0:ws:style"] }, + { instanceId: "1", values: ["1:ws:style"] }, + ]); + expect(styles).toEqual([ + { + breakpointId: "base", + styleSourceId: "0:ws:style", + property: "color", + value: { type: "keyword", value: "red" }, + }, + { + breakpointId: "base", + styleSourceId: "1:ws:style", + property: "fontSize", + value: { type: "unit", unit: "px", value: 10 }, + }, + ]); +}); + +test("generate local styles with states", () => { + const { styles } = renderTemplate( + <$.Body + ws:style={css` + color: red; + &:hover { + color: blue; + } + `} + > + ); + expect(styles).toEqual([ + { + breakpointId: "base", + styleSourceId: "0:ws:style", + property: "color", + value: { type: "keyword", value: "red" }, + }, + { + breakpointId: "base", + styleSourceId: "0:ws:style", + state: ":hover", + property: "color", + value: { type: "keyword", value: "blue" }, + }, + ]); +}); + +test("avoid generating style data without styles", () => { + const { breakpoints, styleSources, styleSourceSelections, styles } = + renderTemplate(<$.Body>); + expect(breakpoints).toEqual([]); + expect(styleSources).toEqual([]); + expect(styleSourceSelections).toEqual([]); + expect(styles).toEqual([]); +}); diff --git a/packages/template/src/jsx.ts b/packages/template/src/jsx.ts index a41a794581cc..e082bb52059b 100644 --- a/packages/template/src/jsx.ts +++ b/packages/template/src/jsx.ts @@ -1,11 +1,16 @@ import { Fragment, type JSX, type ReactNode } from "react"; import type { + Breakpoint, Instance, Instances, Prop, Props, + StyleDecl, + StyleSource, + StyleSourceSelection, WebstudioFragment, } from "@webstudio-is/sdk"; +import type { TemplateStyleDecl } from "./css"; export class ExpressionValue { value: string; @@ -109,6 +114,10 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => { let lastId = -1; const instances: Instance[] = []; const props: Prop[] = []; + const breakpoints: Breakpoint[] = []; + const styleSources: StyleSource[] = []; + const styleSourceSelections: StyleSourceSelection[] = []; + const styles: StyleDecl[] = []; const ids = new Map(); const getId = (key: unknown) => { let id = ids.get(key); @@ -119,12 +128,44 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => { } return id; }; + // lazily create breakpoint + const getBreakpointId = () => { + if (breakpoints.length > 0) { + return breakpoints[0].id; + } + const breakpointId = "base"; + breakpoints.push({ + id: breakpointId, + label: "", + }); + return breakpointId; + }; const children = traverseJsx(root, (element, children) => { const instanceId = element.props?.["ws:id"] ?? getId(element); for (const [name, value] of Object.entries({ ...element.props })) { if (name === "ws:id" || name === "ws:label" || name === "children") { continue; } + if (name === "ws:style") { + const styleSourceId = `${instanceId}:${name}`; + styleSources.push({ + type: "local", + id: styleSourceId, + }); + styleSourceSelections.push({ + instanceId, + values: [styleSourceId], + }); + const localStyles = value as TemplateStyleDecl[]; + for (const styleDecl of localStyles) { + styles.push({ + breakpointId: getBreakpointId(), + styleSourceId, + ...styleDecl, + }); + } + continue; + } const propId = `${instanceId}:${name}`; const base = { id: propId, instanceId, name }; if (value instanceof ExpressionValue) { @@ -190,13 +231,13 @@ export const renderTemplate = (root: JSX.Element): WebstudioFragment => { children, instances, props, + breakpoints, + styleSources, + styleSourceSelections, + styles, assets: [], dataSources: [], resources: [], - breakpoints: [], - styleSourceSelections: [], - styleSources: [], - styles: [], }; }; @@ -219,6 +260,7 @@ type ComponentProps = Record & Record<`${string}:expression`, string> & { "ws:id"?: string; "ws:label"?: string; + "ws:style"?: TemplateStyleDecl[]; children?: ReactNode | ExpressionValue; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078ff5dc86db..fc048760167b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2009,6 +2009,12 @@ importers: packages/template: dependencies: + '@webstudio-is/css-data': + specifier: workspace:* + version: link:../css-data + '@webstudio-is/css-engine': + specifier: workspace:* + version: link:../css-engine '@webstudio-is/sdk': specifier: workspace:* version: link:../sdk