From 6f1a94c5f534132ba9e0c79c40d91c8e568de786 Mon Sep 17 00:00:00 2001 From: istarkov Date: Sat, 8 Feb 2025 16:40:27 +0000 Subject: [PATCH 01/10] Animate Animate 2 Animation UI - Type Select Subject selection Create new Animation New animation Add Add sort/delete subject Add Ranges Select Fix scroll Ranges Ready Fix comments Allow negative values Add storybook Add storie Add error Add offset Add anim Edit keyframes Add Fill Mode Add easing Rename Fix errors Hide animate children Add hook upd Fix Fix hook Allow calc Add ability to play localy isPinned Add mutations to track Support composition Switch auto on add Add animation-composition Use default composite Fix tests Allow select self Add kebab Change private detection refactor: output hyphenated properties from css parser (#4900) We are going to switch to hyphenated properties in styles. Here refactored css parser to output hyphenated property instead of camel case and added camelCaseProperty utility which does the opposite of hyphenateProperty. fix my env --- .devcontainer/postinstall.sh | 9 +- .../features/components/components.tsx | 8 +- .../builder/features/pages/page-settings.tsx | 11 +- .../settings-panel/controls/combined.tsx | 6 + .../animation/animation-keyframes.tsx | 218 ++++++++++ .../animation-panel-content.stories.tsx | 99 +++++ .../animation/animation-panel-content.tsx | 409 ++++++++++++++++++ .../animation/animation-section.tsx | 243 +++++++++++ .../animation/animations-select.tsx | 270 ++++++++++++ .../animation/new-scroll-animations.ts | 61 +++ .../animation/new-view-animations.ts | 61 +++ .../animation/set-css-property.test.tsx | 87 ++++ .../animation/set-css-property.ts | 64 +++ .../animation/subject-select.tsx | 158 +++++++ .../props-section/props-section.tsx | 22 +- .../props-section/use-props-logic.ts | 7 + .../features/settings-panel/shared.tsx | 6 +- .../css-value-input/css-value-input.tsx | 65 ++- .../parse-intermediate-or-invalid-value.ts | 39 +- ...e-intermediate-or-invalid-value.ts.test.ts | 35 +- .../shared/css-value-input/unit-select.tsx | 7 +- .../features/style-panel/shared/model.tsx | 12 + .../app/builder/shared/code-editor.tsx | 62 ++- apps/builder/app/routes/_ui.playground.tsx | 92 ---- apps/builder/app/shared/nano-states/misc.ts | 15 + apps/builder/package.json | 1 + apps/builder/vite.config.ts | 21 +- .../webstudio-features/.template/package.json | 3 + .../.template/vite.config.ts | 26 ++ fixtures/webstudio-features/package.json | 3 +- fixtures/webstudio-features/vite.config.ts | 26 ++ .../css-data/src/__generated__/properties.ts | 8 +- packages/css-data/src/custom-data.ts | 8 +- .../design-system/src/components/combobox.tsx | 1 - .../src/components/floating-panel.tsx | 7 + .../src/components/input-field.tsx | 11 +- .../primitives/numeric-gesture-control.ts | 6 +- .../design-system/src/components/switch.tsx | 7 +- packages/feature-flags/src/flags.ts | 1 + packages/react-sdk/src/component-generator.ts | 3 +- .../sdk-components-animation/package.json | 4 +- packages/sdk-components-animation/private-src | 2 +- .../src/animate-children.tsx | 48 ++ .../src/components.ts | 2 +- .../sdk-components-animation/src/hooks.ts | 3 +- .../sdk-components-animation/src/metas.ts | 2 +- .../sdk-components-animation/src/props.ts | 2 +- .../sdk-components-animation/src/scroll.tsx | 14 - .../sdk-components-animation/src/scroll.ws.ts | 23 + .../src/shared/animation-types.tsx | 140 ------ .../src/shared/consts.ts | 2 + .../sdk-components-animation/tsconfig.json | 7 +- .../sdk-components-animation/vitest.config.ts | 24 + packages/sdk/src/index.ts | 25 ++ packages/sdk/src/schema/animation-schema.ts | 207 +++++++++ packages/sdk/src/schema/prop-meta.ts | 8 + packages/sdk/src/schema/props.ts | 6 + pnpm-lock.yaml | 12 + 58 files changed, 2410 insertions(+), 319 deletions(-) create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/new-scroll-animations.ts create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/new-view-animations.ts create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/set-css-property.test.tsx create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/set-css-property.ts create mode 100644 apps/builder/app/builder/features/settings-panel/props-section/animation/subject-select.tsx delete mode 100644 apps/builder/app/routes/_ui.playground.tsx create mode 100644 packages/sdk-components-animation/src/animate-children.tsx delete mode 100644 packages/sdk-components-animation/src/scroll.tsx create mode 100644 packages/sdk-components-animation/src/scroll.ws.ts delete mode 100644 packages/sdk-components-animation/src/shared/animation-types.tsx create mode 100644 packages/sdk-components-animation/src/shared/consts.ts create mode 100644 packages/sdk/src/schema/animation-schema.ts diff --git a/.devcontainer/postinstall.sh b/.devcontainer/postinstall.sh index db5b4ed3013b..5c1d30a15435 100755 --- a/.devcontainer/postinstall.sh +++ b/.devcontainer/postinstall.sh @@ -14,17 +14,14 @@ sudo rm -rf /tmp/corepack-cache sudo rm -rf /usr/local/lib/node_modules/corepack # Manually remove global corepack # Reinstall corepack globally via npm -npm install -g corepack@latest # Install latest corepack version +npm install -g corepack@latest --force # Install latest corepack version sudo corepack enable # Re-enable corepack # Check corepack version after reinstall -echo "--- Corepack version after reinstall ---" corepack --version -echo "--- End corepack version check ---" - # Prepare pnpm (again, after corepack reinstall) -sudo corepack prepare pnpm@9.14.4 --activate +corepack prepare pnpm@9.14.4 --activate # Go to workspace directory cd /workspaces/webstudio @@ -42,7 +39,7 @@ find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' + # Install dependencies, build, and migrate pnpm install -pnpm run build +pnpm build pnpm migrations migrate # Add git aliases diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 71fe141ecf9e..b80ee794654d 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -59,9 +59,15 @@ const $metas = computed( const availableComponents = new Set(); const metas: Meta[] = []; for (const [name, componentMeta] of componentMetas) { + if ( + isFeatureEnabled("animation") === false && + name.endsWith(":AnimateChildren") + ) { + continue; + } + // only set available components from component meta availableComponents.add(name); - if ( isFeatureEnabled("headSlotComponent") === false && name === "HeadSlot" diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index df94c7e956b0..d58404df94fe 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -1631,7 +1631,16 @@ export const PageSettings = ({ onDuplicate={() => { const newPageId = duplicatePage(pageId); if (newPageId !== undefined) { - onDuplicate(newPageId); + // In `canvas.tsx`, within `subscribeStyles`, we use `requestAnimationFrame` (RAF) for style recalculation. + // After `duplicatePage`, styles are not yet recalculated. + // To ensure they are properly updated, we use double RAF. + requestAnimationFrame(() => { + // At this tick styles are updating + requestAnimationFrame(() => { + // At this tick styles are updated + onDuplicate(newPageId); + }); + }); } }} > diff --git a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx index d9814fa1ef3b..418f367d2c4d 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/combined.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/combined.tsx @@ -216,6 +216,12 @@ export const renderControl = ({ ); } + if (prop.type === "animationAction") { + throw new Error( + `Cannot render a fallback control for prop "${rest.propName}" with type animationAction` + ); + } + prop satisfies never; } diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx new file mode 100644 index 000000000000..c93070891e3a --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx @@ -0,0 +1,218 @@ +import { parseCss } from "@webstudio-is/css-data"; +import { StyleValue, toValue } from "@webstudio-is/css-engine"; +import { + Text, + Grid, + IconButton, + Label, + Separator, + Tooltip, +} from "@webstudio-is/design-system"; +import { MinusIcon, PlusIcon } from "@webstudio-is/icons"; +import type { AnimationKeyframe } from "@webstudio-is/sdk"; +import { Fragment, useMemo, useState } from "react"; +import { + CssValueInput, + type IntermediateStyleValue, +} from "~/builder/features/style-panel/shared/css-value-input"; +import { toKebabCase } from "~/builder/features/style-panel/shared/keyword-utils"; +import { CodeEditor } from "~/builder/shared/code-editor"; +import { useIds } from "~/shared/form-utils"; + +const unitOptions = [ + { + id: "%" as const, + label: "%", + type: "unit" as const, + }, +]; + +const OffsetInput = ({ + id, + value, + onChange, +}: { + id: string; + value: number | undefined; + onChange: (value: number | undefined) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + return ( + []} + unitOptions={unitOptions} + intermediateValue={intermediateValue} + styleSource="default" + /* same as offset has 0 - 100% */ + property={"fontStretch"} + value={ + value !== undefined + ? { + type: "unit", + value: Math.round(value * 1000) / 10, + unit: "%", + } + : undefined + } + onChange={(styleValue) => { + if (styleValue === undefined) { + setIntermediateValue(styleValue); + return; + } + + const clampedStyleValue = { ...styleValue }; + if ( + clampedStyleValue.type === "unit" && + clampedStyleValue.unit === "%" + ) { + clampedStyleValue.value = Math.min( + 100, + Math.max(0, clampedStyleValue.value) + ); + } + + setIntermediateValue(clampedStyleValue); + }} + onHighlight={(_styleValue) => { + /* @todo: think about preview */ + }} + onChangeComplete={(event) => { + setIntermediateValue(undefined); + + if (event.value.type === "unit" && event.value.unit === "%") { + onChange(Math.min(100, Math.max(0, event.value.value)) / 100); + return; + } + + setIntermediateValue({ + type: "invalid", + value: toValue(event.value), + }); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + onChange(undefined); + }} + /> + ); +}; + +const Keyframe = ({ + value, + onChange, +}: { + value: AnimationKeyframe; + onChange: (value: AnimationKeyframe | undefined) => void; +}) => { + const ids = useIds(["offset"]); + + const cssProperties = useMemo(() => { + let result = ``; + for (const [property, style] of Object.entries(value.styles)) { + result = `${result}${toKebabCase(property)}: ${toValue(style)};\n`; + } + return result; + }, [value.styles]); + + return ( + <> + + + { + onChange({ ...value, offset }); + }} + /> + + onChange(undefined)}> + + + + + + { + /* do nothing */ + }} + onChangeComplete={(cssText) => { + const parsedStyles = parseCss(`selector{${cssText}}`); + onChange({ + ...value, + styles: parsedStyles.reduce( + (r, { property, value }) => ({ ...r, [property]: value }), + {} + ), + }); + }} + /> + + + ); +}; + +export const Keyframes = ({ + value: keyframes, + onChange, +}: { + value: AnimationKeyframe[]; + onChange: (value: AnimationKeyframe[]) => void; +}) => { + const ids = useIds(["addKeyframe"]); + + return ( + + + + + onChange([...keyframes, { offset: undefined, styles: {} }]) + } + > + + + + + {keyframes.map((value, index) => ( + + + { + if (newValue === undefined) { + const newValues = [...keyframes]; + newValues.splice(index, 1); + onChange(newValues); + return; + } + + const newValues = [...keyframes]; + newValues[index] = newValue; + onChange(newValues); + }} + /> + + ))} + + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx new file mode 100644 index 000000000000..5b66afd4e424 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimationPanelContent } from "./animation-panel-content"; +import { theme } from "@webstudio-is/design-system"; +import { useState } from "react"; +import type { ScrollAnimation, ViewAnimation } from "@webstudio-is/sdk"; + +const meta = { + title: "Builder/Settings Panel/Animation Panel Content", + component: AnimationPanelContent, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(newValue as ScrollAnimation); + }} + /> + ); +}; + +const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(newValue as ViewAnimation); + }} + /> + ); +}; + +export const ScrollAnimationStory: Story = { + render: ScrollAnimationTemplate, + args: { + type: "scroll", + value: { + name: "scroll-animation", + timing: { + rangeStart: ["start", { type: "unit", value: 0, unit: "%" }], + rangeEnd: ["end", { type: "unit", value: 100, unit: "%" }], + }, + keyframes: [ + { + offset: 0, + styles: { + opacity: { type: "unit", value: 0, unit: "%" }, + color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 }, + }, + }, + ], + }, + onChange: () => {}, + }, +}; + +export const ViewAnimationStory: Story = { + render: ViewAnimationTemplate, + args: { + type: "view", + value: { + name: "view-animation", + timing: { + rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }], + rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }], + }, + keyframes: [ + { + offset: 0, + styles: { + opacity: { type: "unit", value: 0, unit: "%" }, + color: { type: "rgb", r: 255, g: 0, b: 0, alpha: 1 }, + }, + }, + ], + }, + onChange: () => {}, + }, +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx new file mode 100644 index 000000000000..e22bfb8101e4 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx @@ -0,0 +1,409 @@ +import { + Box, + Grid, + Label, + Select, + theme, + toast, +} from "@webstudio-is/design-system"; +import { toPascalCase } from "~/builder/features/style-panel/shared/keyword-utils"; +import { useIds } from "~/shared/form-utils"; + +import type { + RangeUnitValue, + ScrollAnimation, + ViewAnimation, +} from "@webstudio-is/sdk"; +import { + RANGE_UNITS, + rangeUnitValueSchema, + scrollAnimationSchema, + viewAnimationSchema, +} from "@webstudio-is/sdk"; +import { + CssValueInput, + type IntermediateStyleValue, +} from "~/builder/features/style-panel/shared/css-value-input/css-value-input"; +import { toValue, type StyleValue } from "@webstudio-is/css-engine"; +import { useState } from "react"; +import { Keyframes } from "./animation-keyframes"; +import { styleConfigByName } from "~/builder/features/style-panel/shared/configs"; + +type Props = { + type: "scroll" | "view"; + value: ScrollAnimation | ViewAnimation; + onChange: (value: ScrollAnimation | ViewAnimation) => void; +}; + +const fillModeDescriptions: Record< + NonNullable, + string +> = { + none: "No animation is applied before or after the active period", + forwards: + "The animation state is applied after the active period. Prefered for Out Animations", + backwards: + "The animation state is applied before the active period. Prefered for In Animations", + both: "The animation state is applied before and after the active period", +}; + +const fillModeNames = Object.keys(fillModeDescriptions) as NonNullable< + ViewAnimation["timing"]["fill"] +>[]; + +/** + * https://developer.mozilla.org/en-US/docs/Web/CSS/animation-range-start + * + * + **/ +const viewTimelineRangeName = { + entry: + "Animates during the subject element entry (starts entering → fully visible)", + exit: "Animates during the subject element exit (starts exiting → fully hidden)", + contain: + "Animates only while the subject element is fully in view (fullly visible after entering → starts exiting)", + cover: + "Animates entire time the subject element is visible (starts entering → ends after exiting)", + "entry-crossing": + "Animates as the subject element enters (leading edge → trailing edge enters view)", + "exit-crossing": + "Animates as the subject element exits (leading edge → trailing edge leaves view)", +}; + +/** + * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges + * However, for simplicity and type unification with the view, we will use the names "start" and "end," + * which will be transformed as follows: + * - "start" → `calc(0% + range)` + * - "end" → `calc(100% - range)` + */ +const scrollTimelineRangeName = { + start: "Distance from the top of the scroll container where animation begins", + end: "Distance from the bottom of the scroll container where animation ends", +}; + +const unitOptions = RANGE_UNITS.map((unit) => ({ + id: unit, + label: unit, + type: "unit" as const, +})); + +const RangeValueInput = ({ + id, + value, + onChange, +}: { + id: string; + value: RangeUnitValue; + onChange: (value: RangeUnitValue) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + return ( + { + setIntermediateValue(styleValue); + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + getOptions={() => []} + onHighlight={() => { + /* Nothing to Highlight */ + }} + onChangeComplete={(event) => { + const parsedValue = rangeUnitValueSchema.safeParse(event.value); + if (parsedValue.success) { + onChange(parsedValue.data); + setIntermediateValue(undefined); + return; + } + + setIntermediateValue({ + type: "invalid", + value: toValue(event.value), + }); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + }} + /> + ); +}; + +const EasingInput = ({ + id, + value, + onChange, +}: { + id: string; + value: string | undefined; + onChange: (value: string | undefined) => void; +}) => { + const [intermediateValue, setIntermediateValue] = useState< + StyleValue | IntermediateStyleValue + >(); + + const property = "animationTimingFunction"; + + return ( + [ + ...styleConfigByName(property).items.map((item) => ({ + type: "keyword" as const, + value: item.name, + })), + ]} + property={property} + intermediateValue={intermediateValue} + onChange={(styleValue) => { + setIntermediateValue(styleValue); + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onHighlight={() => { + /* Nothing to Highlight */ + }} + onChangeComplete={(event) => { + onChange(toValue(event.value)); + setIntermediateValue(undefined); + }} + onAbort={() => { + /* @todo: allow to change some ephemeral property to see the result in action */ + }} + onReset={() => { + setIntermediateValue(undefined); + }} + /> + ); +}; + +export const AnimationPanelContent = ({ onChange, value, type }: Props) => { + const fieldIds = useIds([ + "rangeStartName", + "rangeStartValue", + "rangeEndName", + "rangeEndValue", + "fill", + "easing", + ] as const); + + const timelineRangeDescriptions = + type === "scroll" ? scrollTimelineRangeName : viewTimelineRangeName; + + const timelineRangeNames = Object.keys(timelineRangeDescriptions); + + const animationSchema = + type === "scroll" ? scrollAnimationSchema : viewAnimationSchema; + + const handleChange = (rawValue: unknown) => { + const parsedValue = animationSchema.safeParse(rawValue); + if (parsedValue.success) { + onChange(parsedValue.data); + return; + } + + toast.error("Animation schema is incompatible, try fix"); + }; + + return ( + + + + + + toPascalCase(timelineRangeName) + } + value={value.timing.rangeStart?.[0] ?? timelineRangeNames[0]!} + getDescription={(timelineRangeName: string) => ( + + { + timelineRangeDescriptions[ + timelineRangeName as keyof typeof timelineRangeDescriptions + ] + } + + )} + onChange={(timelineRangeName) => { + handleChange({ + ...value, + timing: { + ...value.timing, + rangeStart: [ + timelineRangeName, + value.timing.rangeStart?.[1] ?? { + type: "unit", + value: 0, + unit: "%", + }, + ], + }, + }); + }} + /> + { + const defaultTimelineRangeName = timelineRangeNames[0]!; + + handleChange({ + ...value, + timing: { + ...value.timing, + rangeStart: [ + value.timing.rangeStart?.[0] ?? defaultTimelineRangeName, + rangeStart, + ], + }, + }); + }} + /> + + + + + toPascalCase(animationType) + } + value={value.type} + getDescription={(animationType: AnimationAction["type"]) => ( + + {animationTypeDescription[animationType]} + + )} + onChange={(typeValue) => { + handleChange({ ...value, type: typeValue, animations: [] }); + }} + /> + + + + + { + handleChange({ ...value, axis }); + }} + > + {Object.entries(animationAxisDescription).map( + ([key, { icon, label, description }]) => ( + + {label} + {description} + + } + > + {icon} + + ) + )} + + + + {value.type === "scroll" && ( + + + + subject.value)} + value={value.subject ?? "self"} + getLabel={(subject) => + subjects.find((s) => s.value === subject)?.label ?? "-" + } + onItemHighlight={(subject) => { + const selector = + subjects.find((s) => s.value === subject)?.selector ?? undefined; + $hoveredInstanceSelector.set(selector); + }} + onChange={(subject) => { + const newValue = { + ...value, + subject: subject === "self" ? undefined : subject, + }; + const parsedValue = animationActionSchema.safeParse(newValue); + + if (parsedValue.success) { + const subjectItem = subjects.find((s) => s.value === subject); + + if (subjectItem === undefined) { + toast.error(`Subject "${newValue.subject}" not found`); + return; + } + + if ( + subjectItem.isTimelineExists === false && + newValue.subject !== undefined + ) { + serverSyncStore.createTransaction( + [$breakpoints, $styles, $styleSources, $styleSourceSelections], + (breakpoints, styles, styleSources, styleSourceSelections) => { + if (newValue.subject === undefined) { + return; + } + + setListedCssProperty( + breakpoints, + styleSources, + styleSourceSelections, + styles + )(subjectItem.instanceId, "viewTimelineName", { + type: "unparsed", + value: newValue.subject, + }); + } + ); + } + + onChange(parsedValue.data); + return; + } + + toast.error("Schemas are incompatible, try fix"); + }} + /> + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx index 36f2f19bebf6..f1c7719e1434 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx +++ b/apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx @@ -24,6 +24,7 @@ import { renderControl } from "../controls/combined"; import { usePropsLogic, type PropAndMeta } from "./use-props-logic"; import { serverSyncStore } from "~/shared/sync"; import { $selectedInstanceKey } from "~/shared/awareness"; +import { AnimateSection } from "./animation/animation-section"; type Item = { name: string; @@ -165,10 +166,29 @@ export const PropsSection = (props: PropsSectionProps) => { const hasItems = logic.addedProps.length > 0 || addingProp || logic.initialProps.length > 0; + const animationAction = logic.initialProps.find( + (prop) => prop.meta.type === "animationAction" + ); + + const hasAnimation = animationAction !== undefined; + const showPropertiesSection = isDesignMode || (isContentMode && logic.initialProps.length > 0); - return ( + return hasAnimation ? ( + <> + + logic.handleChangeByPropName(animationAction.propName, { + type: "animationAction", + value, + }) + } + /> + + + ) : ( <> { throw new Error( "A prop with type string[] must have a meta, we can't provide a default one because we need a list of options" ); + + case "animationAction": + return { + type: "animationAction", + control: "animationAction", + required: false, + }; case "json": throw new Error( "A prop with type json must have a meta, we can't provide a default one because we need a list of options" diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index b340ac9d4369..1d52b92b850d 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -49,7 +49,11 @@ export type PropValue = | { type: "expression"; value: string } | { type: "asset"; value: Asset["id"] } | { type: "page"; value: Extract["value"] } - | { type: "action"; value: Extract["value"] }; + | { type: "action"; value: Extract["value"] } + | { + type: "animationAction"; + value: Extract["value"]; + }; // Weird code is to make type distributive // https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx index 1d4805b86f80..ca309b2bf16f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -36,7 +36,7 @@ import { useMemo, type ComponentProps, } from "react"; -import { useUnitSelect } from "./unit-select"; +import { useUnitSelect, type UnitOption } from "./unit-select"; import { parseIntermediateOrInvalidValue } from "./parse-intermediate-or-invalid-value"; import { toValue } from "@webstudio-is/css-engine"; import { @@ -58,6 +58,7 @@ import { isComplexValue, ValueEditorDialog, } from "./value-editor-dialog"; +import { useEffectEvent } from "~/shared/hook-utils/effect-event"; // We need to enable scrub on properties that can have numeric value. const canBeNumber = (property: StyleProperty, value: CssValueInputValue) => { @@ -81,15 +82,21 @@ const scrubUnitAcceleration = new Map([ const useScrub = ({ value, + intermediateValue, + defaultUnit, property, onChange, onChangeComplete, + onAbort, shouldHandleEvent, }: { + defaultUnit: Unit | undefined; value: CssValueInputValue; + intermediateValue: CssValueInputValue | undefined; property: StyleProperty; - onChange: (value: CssValueInputValue) => void; + onChange: (value: CssValueInputValue | undefined) => void; onChangeComplete: (value: StyleValue) => void; + onAbort: () => void; shouldHandleEvent?: (node: Node) => boolean; }): [ React.RefObject, @@ -102,11 +109,19 @@ const useScrub = ({ const onChangeCompleteRef = useRef(onChangeComplete); const valueRef = useRef(value); + const intermediateValueRef = useRef(intermediateValue); + onChangeCompleteRef.current = onChangeComplete; onChangeRef.current = onChange; valueRef.current = value; + const updateIntermediateValue = useEffectEvent(() => { + intermediateValueRef.current = intermediateValue; + }); + + const onAbortStable = useEffectEvent(onAbort); + // const type = valueRef.current.type; // Since scrub is going to call onChange and onChangeComplete callbacks, it will result in a new value and potentially new callback refs. @@ -124,7 +139,7 @@ const useScrub = ({ return; } - let unit: Unit = "number"; + let unit: Unit = defaultUnit ?? "number"; const validateValue = (numericValue: number) => { let value: CssValueInputValue = { @@ -193,6 +208,20 @@ const useScrub = ({ if (valueRef.current.type === "unit") { unit = valueRef.current.unit; } + + updateIntermediateValue(); + }, + onAbort() { + onAbortStable(); + // Returning focus that we've moved above + scrubRef.current?.removeAttribute("tabindex"); + onChangeRef.current(intermediateValueRef.current); + + // Otherwise selectionchange event can be triggered after 300-1000ms after focus + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); }, onValueInput(event) { // Moving focus to container of the input to hide the caret @@ -221,7 +250,13 @@ const useScrub = ({ }, shouldHandleEvent, }); - }, [shouldHandleEvent, property]); + }, [ + shouldHandleEvent, + property, + updateIntermediateValue, + onAbortStable, + defaultUnit, + ]); return [scrubRef, inputRef]; }; @@ -283,6 +318,9 @@ type CssValueInputProps = Pick< onReset: () => void; icon?: ReactNode; showSuffix?: boolean; + unitOptions?: UnitOption[]; + id?: string; + placeholder?: string; }; const initialValue: IntermediateStyleValue = { @@ -429,6 +467,7 @@ const Description = styled(Box, { width: theme.spacing[27] }); * - Evaluated math expression: "2px + 3em" (like CSS calc()) */ export const CssValueInput = ({ + id, autoFocus, icon, prefix, @@ -444,6 +483,8 @@ export const CssValueInput = ({ fieldSizing, variant, text, + unitOptions, + placeholder, ...props }: CssValueInputProps) => { const value = props.intermediateValue ?? props.value ?? initialValue; @@ -455,6 +496,9 @@ export const CssValueInput = ({ StyleValue | undefined >(); + const defaultUnit = + unitOptions?.[0]?.type === "unit" ? unitOptions[0].id : undefined; + const onChange = (input: string | undefined) => { if (input === undefined) { props.onChange(undefined); @@ -500,7 +544,11 @@ export const CssValueInput = ({ return; } - const parsedValue = parseIntermediateOrInvalidValue(property, value); + const parsedValue = parseIntermediateOrInvalidValue( + property, + value, + defaultUnit + ); if (parsedValue.type === "invalid") { props.onChange(parsedValue); @@ -527,6 +575,7 @@ export const CssValueInput = ({ highlightedIndex, closeMenu, } = useCombobox({ + inputId: id, // Used for description to match the item when nothing is highlighted yet and value is still in non keyword mode getItems: getOptions, value, @@ -555,6 +604,7 @@ export const CssValueInput = ({ const inputProps = getInputProps(); const [isUnitsOpen, unitSelectElement] = useUnitSelect({ + options: unitOptions, property, value, onChange: (unitOrKeyword) => { @@ -656,11 +706,14 @@ export const CssValueInput = ({ }, []); const [scrubRef, inputRef] = useScrub({ + defaultUnit, value, property, + intermediateValue: props.intermediateValue, onChange: props.onChange, onChangeComplete: (value) => onChangeComplete({ value, type: "scrub-end" }), shouldHandleEvent, + onAbort, }); const menuProps = getMenuProps(); @@ -905,10 +958,12 @@ export const CssValueInput = ({ { + const unitGroups = + properties[property as keyof typeof properties]?.unitGroups ?? []; + + for (const unitGroup of unitGroups) { + if (unitGroup === "number") { + continue; + } + + if (unitGroup === "length") { + return "px"; + } + + return units[unitGroup][0]!; + } + + if (unitGroups.includes("number" as never)) { + return "number"; + } + + return "px"; +}; + export const parseIntermediateOrInvalidValue = ( property: StyleProperty, styleValue: IntermediateStyleValue | InvalidValue, + defaultUnit: Unit = getDefaultUnit(property), originalValue?: string ): StyleValue => { let value = styleValue.value.trim(); @@ -40,7 +70,11 @@ export const parseIntermediateOrInvalidValue = ( const unit = "unit" in styleValue ? styleValue.unit : undefined; // Use number as a fallback for custom properties - const fallbackUnitAsString = property.startsWith("--") ? "" : "px"; + const fallbackUnitAsString = property.startsWith("--") + ? "" + : defaultUnit === "number" + ? "" + : defaultUnit; const testUnit = unit === "number" ? "" : (unit ?? fallbackUnitAsString); @@ -133,6 +167,7 @@ export const parseIntermediateOrInvalidValue = ( ...styleValue, value: value.replace(/,/g, "."), }, + defaultUnit, originalValue ?? value ); } diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts index ab3e225918a0..122c79e4d31f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/parse-intermediate-or-invalid-value.ts.test.ts @@ -41,19 +41,30 @@ describe("Parse intermediate or invalid value without math evaluation", () => { } }); - test("fallback to px", () => { - for (const propery of properties) { - const result = parseIntermediateOrInvalidValue(propery, { - type: "intermediate", - value: "10", - }); + test.each(properties)(`fallback to px for property = "%s"`, (propery) => { + const result = parseIntermediateOrInvalidValue(propery, { + type: "intermediate", + value: "10", + }); - expect(result).toEqual({ - type: "unit", - value: 10, - unit: "px", - }); - } + expect(result).toEqual({ + type: "unit", + value: 10, + unit: "px", + }); + }); + + test("fallback to % if px is not supported", () => { + const result = parseIntermediateOrInvalidValue("fontStretch", { + type: "intermediate", + value: "10", + }); + + expect(result).toEqual({ + type: "unit", + value: 10, + unit: "%", + }); }); test("switch on new unit if previous not known", () => { diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx index 3d28a0b97785..2fd279b4c5f0 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/unit-select.tsx @@ -29,6 +29,7 @@ type UseUnitSelectType = { value: { type: "unit"; value: Unit } | { type: "keyword"; value: string } ) => void; onCloseAutoFocus: (event: Event) => void; + options?: UnitOption[]; }; export const useUnitSelect = ({ @@ -36,12 +37,14 @@ export const useUnitSelect = ({ value, onChange, onCloseAutoFocus, + options: unitOptions, }: UseUnitSelectType): [boolean, JSX.Element | undefined] => { const [isOpen, setIsOpen] = useState(false); const options = useMemo( - () => buildOptions(property, value, nestedSelectButtonUnitless), - [property, value] + () => + unitOptions ?? buildOptions(property, value, nestedSelectButtonUnitless), + [property, value, unitOptions] ); const unit = diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 5492a915086e..686ae7fb9fb4 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -40,6 +40,7 @@ import { $selectedInstancePathWithRoot, type InstancePath, } from "~/shared/awareness"; +import type { InstanceSelector } from "~/shared/tree-utils"; const $presetStyles = computed($registeredComponentMetas, (metas) => { const presetStyles = new Map(); @@ -376,6 +377,17 @@ export const useParentComputedStyleDecl = (property: StyleProperty) => { return useStore($store); }; +export const getInstanceStyleDecl = ( + property: StyleProperty, + instanceSelector: InstanceSelector +) => { + return getComputedStyleDecl({ + model: $model.get(), + instanceSelector, + property, + }); +}; + export const useComputedStyles = (properties: StyleProperty[]) => { // cache each computed style store const cachedStores = useRef( diff --git a/apps/builder/app/builder/shared/code-editor.tsx b/apps/builder/app/builder/shared/code-editor.tsx index 8f4528e78827..48b95650cb47 100644 --- a/apps/builder/app/builder/shared/code-editor.tsx +++ b/apps/builder/app/builder/shared/code-editor.tsx @@ -12,7 +12,12 @@ import { highlightSpecialChars, highlightActiveLine, } from "@codemirror/view"; -import { bracketMatching, indentOnInput } from "@codemirror/language"; +import { + bracketMatching, + indentOnInput, + LanguageSupport, + LRLanguage, +} from "@codemirror/language"; import { autocompletion, closeBrackets, @@ -30,12 +35,20 @@ import { foldGutterExtension, getMinMaxHeightVars, } from "./code-editor-base"; +import { cssCompletionSource, cssLanguage } from "@codemirror/lang-css"; const wrapperStyle = css({ position: "relative", - // 1 line is 16px - // set min 10 lines and max 20 lines - ...getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }), + + variants: { + size: { + default: getMinMaxHeightVars({ minHeight: "160px", maxHeight: "320px" }), + keyframe: getMinMaxHeightVars({ minHeight: "60px", maxHeight: "120px" }), + }, + }, + defaultVariants: { + size: "default", + }, }); const getHtmlExtensions = () => [ @@ -80,20 +93,55 @@ const getMarkdownExtensions = () => [ keymap.of(closeBracketsKeymap), ]; +const cssPropertiesLanguage = LRLanguage.define({ + name: "css", + parser: cssLanguage.configure({ top: "Styles" }).parser, +}); +const cssProperties = new LanguageSupport( + cssPropertiesLanguage, + cssPropertiesLanguage.data.of({ + autocomplete: cssCompletionSource, + }) +); + +const getCssPropertiesExtensions = () => [ + highlightActiveLine(), + highlightSpecialChars(), + indentOnInput(), + cssProperties, + // render autocomplete in body + // to prevent popover scroll overflow + tooltips({ parent: document.body }), + autocompletion({ icons: false }), +]; + export const CodeEditor = forwardRef< HTMLDivElement, Omit, "extensions"> & { - lang?: "html" | "markdown"; + lang?: "html" | "markdown" | "css-properties"; title?: ReactNode; + size?: "default" | "keyframe"; } ->(({ lang, title, ...editorContentProps }, ref) => { +>(({ lang, title, size, ...editorContentProps }, ref) => { const extensions = useMemo(() => { if (lang === "html") { return getHtmlExtensions(); } + if (lang === "markdown") { return getMarkdownExtensions(); } + + if (lang === "css-properties") { + return getCssPropertiesExtensions(); + } + + if (lang === undefined) { + return []; + } + + lang satisfies never; + return []; }, [lang]); @@ -120,7 +168,7 @@ export const CodeEditor = forwardRef< }; }, []); return ( -
+
{ - return ( - - -

- HELLO WORLD -

-
- - Start scrolling, and when the current box scrolls out, the “HELLO WORLD” - header will fly in and become hoverable. (During the animation, it won’t - be hoverable.) - - - - When you see this box, the “HELLO WORLD” header will fly out. - -
- ); -}; - -export default Playground; - -// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files. -export const config = { - maxDuration: 30, -}; diff --git a/apps/builder/app/shared/nano-states/misc.ts b/apps/builder/app/shared/nano-states/misc.ts index f8002292e64e..efd1d85cb2f8 100644 --- a/apps/builder/app/shared/nano-states/misc.ts +++ b/apps/builder/app/shared/nano-states/misc.ts @@ -62,10 +62,25 @@ export const $propsIndex = computed($props, (props) => { }; }); +/** + * $styles contains actual styling rules + * (breakpointId, styleSourceId, property, value, listed), tied to styleSourceIds + * $styles.styleSourceId -> $styleSources.id + */ export const $styles = atom(new Map()); +/** + * styleSources defines where styles come from (local or token). + * + * $styles contains actual styling rules, tied to styleSourceIds. + * $styles.styleSourceId -> $styleSources.id + */ export const $styleSources = atom(new Map()); +/** + * This is a list of connections between instances (instanceIds) and styleSources. + * $styleSourceSelections.values[] -> $styleSources.id[] + */ export const $styleSourceSelections = atom(new Map()); export type StyleSourceSelector = { diff --git a/apps/builder/package.json b/apps/builder/package.json index 01ed76d8cbed..76ad2efc71b4 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -129,6 +129,7 @@ "@types/react": "^18.2.70", "@types/react-dom": "^18.2.25", "@webstudio-is/tsconfig": "workspace:*", + "fast-glob": "^3.3.2", "html-tags": "^4.0.0", "react-router-dom": "^6.28.1", "react-test-renderer": "18.3.0-canary-14898b6a9-20240318", diff --git a/apps/builder/vite.config.ts b/apps/builder/vite.config.ts index eced690c8c74..4db7986825b9 100644 --- a/apps/builder/vite.config.ts +++ b/apps/builder/vite.config.ts @@ -9,20 +9,17 @@ import { getAuthorizationServerOrigin, isBuilderUrl, } from "./app/shared/router-utils/origins"; -import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; +import fg from "fast-glob"; -const isFolderEmpty = (folderPath: string) => { - if (!existsSync(folderPath)) { - return true; // Folder does not exist - } - const contents = readdirSync(folderPath); - - return contents.length === 0; -}; +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); -const hasPrivateFolders = !isFolderEmpty( - path.join(__dirname, "../../packages/sdk-components-animation/private-src") -); +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; export default defineConfig(({ mode }) => { if (mode === "test") { diff --git a/fixtures/webstudio-features/.template/package.json b/fixtures/webstudio-features/.template/package.json index b21dce86c93a..85ed77245ffd 100644 --- a/fixtures/webstudio-features/.template/package.json +++ b/fixtures/webstudio-features/.template/package.json @@ -8,5 +8,8 @@ "@webstudio-is/sdk-components-react-radix": "workspace:*", "@webstudio-is/sdk-components-react-router": "workspace:*", "webstudio": "workspace:*" + }, + "devDependencies": { + "fast-glob": "^3.3.2" } } diff --git a/fixtures/webstudio-features/.template/vite.config.ts b/fixtures/webstudio-features/.template/vite.config.ts index af40508cdc7b..b33951009b77 100644 --- a/fixtures/webstudio-features/.template/vite.config.ts +++ b/fixtures/webstudio-features/.template/vite.config.ts @@ -3,7 +3,33 @@ import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; // @ts-ignore import { dedupeMeta } from "./proxy-emulator/dedupe-meta"; +import { existsSync, readdirSync } from "fs"; +// @ts-ignore +import path from "path"; +// @ts-ignore +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, plugins: [reactRouter(), dedupeMeta], }); diff --git a/fixtures/webstudio-features/package.json b/fixtures/webstudio-features/package.json index bde13cfe1728..8b8c6f7c2b58 100644 --- a/fixtures/webstudio-features/package.json +++ b/fixtures/webstudio-features/package.json @@ -34,7 +34,8 @@ "webstudio": "workspace:*", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.25", - "typescript": "5.7.3" + "typescript": "5.7.3", + "fast-glob": "^3.3.2" }, "engines": { "node": ">=20.0.0" diff --git a/fixtures/webstudio-features/vite.config.ts b/fixtures/webstudio-features/vite.config.ts index af40508cdc7b..b33951009b77 100644 --- a/fixtures/webstudio-features/vite.config.ts +++ b/fixtures/webstudio-features/vite.config.ts @@ -3,7 +3,33 @@ import { defineConfig } from "vite"; import { reactRouter } from "@react-router/dev/vite"; // @ts-ignore import { dedupeMeta } from "./proxy-emulator/dedupe-meta"; +import { existsSync, readdirSync } from "fs"; +// @ts-ignore +import path from "path"; +// @ts-ignore +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, plugins: [reactRouter(), dedupeMeta], }); diff --git a/packages/css-data/src/__generated__/properties.ts b/packages/css-data/src/__generated__/properties.ts index 68e3f33ba324..a95901bc2f6d 100644 --- a/packages/css-data/src/__generated__/properties.ts +++ b/packages/css-data/src/__generated__/properties.ts @@ -31,8 +31,8 @@ export const properties = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--view-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name", @@ -41,8 +41,8 @@ export const properties = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--scroll-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name", diff --git a/packages/css-data/src/custom-data.ts b/packages/css-data/src/custom-data.ts index 68c3f81c5b89..0c96e15f6144 100644 --- a/packages/css-data/src/custom-data.ts +++ b/packages/css-data/src/custom-data.ts @@ -80,8 +80,8 @@ propertiesData.viewTimelineName = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--view-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name", }; @@ -89,8 +89,8 @@ propertiesData.scrollTimelineName = { unitGroups: [], inherited: false, initial: { - type: "unparsed", - value: "--scroll-timeline", + type: "keyword", + value: "none", }, mdnUrl: "https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name", diff --git a/packages/design-system/src/components/combobox.tsx b/packages/design-system/src/components/combobox.tsx index 894746fc689a..17f562070f93 100644 --- a/packages/design-system/src/components/combobox.tsx +++ b/packages/design-system/src/components/combobox.tsx @@ -281,7 +281,6 @@ export const useCombobox = ({ defaultHighlightedIndex, selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode isOpen, - onIsOpenChange(state) { const { type, isOpen, inputValue } = state; diff --git a/packages/design-system/src/components/floating-panel.tsx b/packages/design-system/src/components/floating-panel.tsx index 7c7353aa922e..903b0d7d2064 100644 --- a/packages/design-system/src/components/floating-panel.tsx +++ b/packages/design-system/src/components/floating-panel.tsx @@ -167,6 +167,13 @@ export const FloatingPanel = ({ event.preventDefault(); } }} + onEscapeKeyDown={(event) => { + if (event.target instanceof HTMLInputElement) { + event.preventDefault(); + + return; + } + }} ref={setContentElement} > {content} diff --git a/packages/design-system/src/components/input-field.tsx b/packages/design-system/src/components/input-field.tsx index b783a431d5c2..0b380d20e2ef 100644 --- a/packages/design-system/src/components/input-field.tsx +++ b/packages/design-system/src/components/input-field.tsx @@ -247,8 +247,17 @@ export const InputField = forwardRef( }); const unfocusContainerRef = useRef(null); const handleKeyDown: KeyboardEventHandler = (event) => { + // If Radix is preventing the Escape key from closing the dialog, + // it intercepts the key event at the document level. + // However, we still want to allow the user to unfocus the input field. + // This means we should not check `defaultPrevented`, but only verify + // if our event handler explicitly prevented it. + const isPreventedBefore = event.defaultPrevented; onKeyDown?.(event); - if (event.key === "Escape" && event.defaultPrevented === false) { + const isPreventedAfter = event.defaultPrevented; + const isEventPrevented = !isPreventedBefore && isPreventedAfter; + + if (event.key === "Escape" && !isEventPrevented) { event.preventDefault(); unfocusContainerRef.current?.focus(); } diff --git a/packages/design-system/src/components/primitives/numeric-gesture-control.ts b/packages/design-system/src/components/primitives/numeric-gesture-control.ts index 391e9246daa2..db2872bf0178 100644 --- a/packages/design-system/src/components/primitives/numeric-gesture-control.ts +++ b/packages/design-system/src/components/primitives/numeric-gesture-control.ts @@ -72,6 +72,7 @@ export type NumericScrubOptions = { direction?: NumericScrubDirection; onValueInput?: NumericScrubCallback; onValueChange?: NumericScrubCallback; + onAbort?: () => void; onStatusChange?: (status: "idle" | "scrubbing") => void; shouldHandleEvent?: (node: Node) => boolean; }; @@ -168,6 +169,7 @@ export const numericScrubControl = ( distanceThreshold = 0, onValueInput, onValueChange, + onAbort, onStatusChange, shouldHandleEvent, } = options; @@ -209,7 +211,9 @@ export const numericScrubControl = ( // Called on ESC key press or in cases of third-party pointer lock exit. const handlePointerLockChange = () => { if (document.pointerLockElement !== targetNode) { + // Reset the value to the initial value cleanup(); + onAbort?.(); return; } @@ -396,7 +400,7 @@ export const numericScrubControl = ( const eventNames = [ "pointerup", "pointerdown", - "pontercancel", + "pointercancel", "lostpointercapture", ] as const; eventNames.forEach((eventName) => diff --git a/packages/design-system/src/components/switch.tsx b/packages/design-system/src/components/switch.tsx index 5c68a56e21b2..08b2e8996cb3 100644 --- a/packages/design-system/src/components/switch.tsx +++ b/packages/design-system/src/components/switch.tsx @@ -30,9 +30,10 @@ const switchStyle = css({ backgroundColor: theme.colors.backgroundNeutralDark, }, - "&[data-state=checked]:not([data-disabled]):before": { - backgroundColor: theme.colors.backgroundPrimary, - }, + "&[data-state=checked]:not([data-disabled]):before, &[aria-checked=true]:not([data-disabled]):before": + { + backgroundColor: theme.colors.backgroundPrimary, + }, "&[data-disabled]:before": { backgroundColor: theme.colors.foregroundDisabled, diff --git a/packages/feature-flags/src/flags.ts b/packages/feature-flags/src/flags.ts index 2e87ceab06dc..756ab1091566 100644 --- a/packages/feature-flags/src/flags.ts +++ b/packages/feature-flags/src/flags.ts @@ -9,3 +9,4 @@ export const contentEditableMode = false; export const command = false; export const headSlotComponent = false; export const stylePanelAdvancedMode = false; +export const animation = false; diff --git a/packages/react-sdk/src/component-generator.ts b/packages/react-sdk/src/component-generator.ts index 36871dd71577..18ec71c5aa3d 100644 --- a/packages/react-sdk/src/component-generator.ts +++ b/packages/react-sdk/src/component-generator.ts @@ -104,7 +104,8 @@ const generatePropValue = ({ prop.type === "number" || prop.type === "boolean" || prop.type === "string[]" || - prop.type === "json" + prop.type === "json" || + prop.type === "animationAction" ) { return JSON.stringify(prop.value); } diff --git a/packages/sdk-components-animation/package.json b/packages/sdk-components-animation/package.json index 82a84f54f5b8..2a02f5197184 100644 --- a/packages/sdk-components-animation/package.json +++ b/packages/sdk-components-animation/package.json @@ -72,10 +72,12 @@ "@webstudio-is/sdk-components-react": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/tsconfig": "workspace:*", + "fast-glob": "^3.3.2", "playwright": "^1.50.1", "react": "18.3.0-canary-14898b6a9-20240318", "react-dom": "18.3.0-canary-14898b6a9-20240318", "type-fest": "^4.32.0", - "vitest": "^3.0.4" + "vitest": "^3.0.4", + "zod": "^3.22.4" } } diff --git a/packages/sdk-components-animation/private-src b/packages/sdk-components-animation/private-src index b977781462fd..150227a8068c 160000 --- a/packages/sdk-components-animation/private-src +++ b/packages/sdk-components-animation/private-src @@ -1 +1 @@ -Subproject commit b977781462fd46fc000b0785b2026c6501786b36 +Subproject commit 150227a8068c7ba6a765b411deb38767a77245ba diff --git a/packages/sdk-components-animation/src/animate-children.tsx b/packages/sdk-components-animation/src/animate-children.tsx new file mode 100644 index 000000000000..5662e2eb8da8 --- /dev/null +++ b/packages/sdk-components-animation/src/animate-children.tsx @@ -0,0 +1,48 @@ +import type { Hook } from "@webstudio-is/react-sdk"; +import type { AnimationAction } from "@webstudio-is/sdk"; +import { forwardRef, type ElementRef } from "react"; +import { animationCanPlayOnCanvasProperty } from "./shared/consts"; + +type ScrollProps = { + debug?: boolean; + children?: React.ReactNode; + action: AnimationAction; +}; + +export const AnimateChildren = forwardRef, ScrollProps>( + ({ debug = false, action, ...props }, ref) => { + return
; + } +); + +const displayName = "AnimateChildren"; +AnimateChildren.displayName = displayName; + +const namespace = "@webstudio-is/sdk-components-animation"; + +export const hooksAnimateChildren: Hook = { + onNavigatorUnselect: (context, event) => { + if ( + event.instancePath.length > 0 && + event.instancePath[0].component === `${namespace}:${displayName}` + ) { + context.setMemoryProp( + event.instancePath[0], + animationCanPlayOnCanvasProperty, + undefined + ); + } + }, + onNavigatorSelect: (context, event) => { + if ( + event.instancePath.length > 0 && + event.instancePath[0].component === `${namespace}:${displayName}` + ) { + context.setMemoryProp( + event.instancePath[0], + animationCanPlayOnCanvasProperty, + true + ); + } + }, +}; diff --git a/packages/sdk-components-animation/src/components.ts b/packages/sdk-components-animation/src/components.ts index b8a77f059a67..ab47b1c28903 100644 --- a/packages/sdk-components-animation/src/components.ts +++ b/packages/sdk-components-animation/src/components.ts @@ -1 +1 @@ -export { Scroll } from "./scroll"; +export { AnimateChildren } from "./animate-children"; diff --git a/packages/sdk-components-animation/src/hooks.ts b/packages/sdk-components-animation/src/hooks.ts index 1ade99e3faec..056ef4c318cc 100644 --- a/packages/sdk-components-animation/src/hooks.ts +++ b/packages/sdk-components-animation/src/hooks.ts @@ -1,3 +1,4 @@ import type { Hook } from "@webstudio-is/react-sdk"; +import { hooksAnimateChildren } from "./animate-children"; -export const hooks: Hook[] = []; +export const hooks: Hook[] = [hooksAnimateChildren]; diff --git a/packages/sdk-components-animation/src/metas.ts b/packages/sdk-components-animation/src/metas.ts index cb0ff5c3b541..3f70a4394ccb 100644 --- a/packages/sdk-components-animation/src/metas.ts +++ b/packages/sdk-components-animation/src/metas.ts @@ -1 +1 @@ -export {}; +export { meta as AnimateChildren } from "./scroll.ws"; diff --git a/packages/sdk-components-animation/src/props.ts b/packages/sdk-components-animation/src/props.ts index cb0ff5c3b541..677fd7a91a70 100644 --- a/packages/sdk-components-animation/src/props.ts +++ b/packages/sdk-components-animation/src/props.ts @@ -1 +1 @@ -export {}; +export { propsMeta as AnimateChildren } from "./scroll.ws"; diff --git a/packages/sdk-components-animation/src/scroll.tsx b/packages/sdk-components-animation/src/scroll.tsx deleted file mode 100644 index 17e0cc3f5aab..000000000000 --- a/packages/sdk-components-animation/src/scroll.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { forwardRef, type ElementRef } from "react"; -import type { AnimationAction } from "./shared/animation-types"; - -type ScrollProps = { - debug?: boolean; - children?: React.ReactNode; - action: AnimationAction; -}; - -export const Scroll = forwardRef, ScrollProps>( - ({ debug = false, action, ...props }, ref) => { - return
; - } -); diff --git a/packages/sdk-components-animation/src/scroll.ws.ts b/packages/sdk-components-animation/src/scroll.ws.ts new file mode 100644 index 000000000000..ce46b4356b08 --- /dev/null +++ b/packages/sdk-components-animation/src/scroll.ws.ts @@ -0,0 +1,23 @@ +import { SlotComponentIcon } from "@webstudio-is/icons/svg"; +import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk"; + +export const meta: WsComponentMeta = { + category: "general", + type: "container", + description: "Animate Children", + icon: SlotComponentIcon, + order: 5, + label: "Animate Children", +}; + +export const propsMeta: WsComponentPropsMeta = { + props: { + action: { + required: false, + control: "animationAction", + type: "animationAction", + description: "Animation Action", + }, + }, + initialProps: ["action"], +}; diff --git a/packages/sdk-components-animation/src/shared/animation-types.tsx b/packages/sdk-components-animation/src/shared/animation-types.tsx deleted file mode 100644 index ba1349e6c249..000000000000 --- a/packages/sdk-components-animation/src/shared/animation-types.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type { StyleValue, UnitValue } from "@webstudio-is/css-engine"; - -export type KeyframeStyles = { [property: string]: StyleValue | undefined }; - -export type AnimationKeyframe = { - offset: number | undefined; - // We are using composite: auto as the default value for now - // composite?: CompositeOperationOrAuto; - styles: KeyframeStyles; -}; - -const RANGE_UNITS = [ - "%", - "px", - // Does not supported by polyfill and we are converting it to px ourselfs - "cm", - "mm", - "q", - "in", - "pt", - "pc", - "em", - "rem", - "ex", - "rex", - "cap", - "rcap", - "ch", - "rch", - "lh", - "rlh", - "vw", - "svw", - "lvw", - "dvw", - "vh", - "svh", - "lvh", - "dvh", - "vi", - "svi", - "lvi", - "dvi", - "vb", - "svb", - "lvb", - "dvb", - "vmin", - "svmin", - "lvmin", - "dvmin", - "vmax", - "svmax", - "lvmax", - "dvmax", -] as const; - -export type RangeUnit = (typeof RANGE_UNITS)[number]; - -export const isRangeUnit = (value: unknown): value is RangeUnit => - RANGE_UNITS.includes(value as RangeUnit); - -export type RangeUnitValue = { type: "unit"; value: number; unit: RangeUnit }; - -({}) as RangeUnitValue satisfies UnitValue; - -type KeyframeEffectOptions = { - easing?: string; - fill?: FillMode; -}; - -/** - * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges - * However, for simplicity and type unification with the view, we will use the names "start" and "end," - * which will be transformed as follows: - * - "start" → `calc(0% + range)` - * - "end" → `calc(100% - range)` - */ -export type ScrollNamedRange = "start" | "end"; - -/** - * Scroll does not support https://drafts.csswg.org/scroll-animations/#named-ranges - * However, for simplicity and type unification with the view, we will use the names "start" and "end," - * See ScrollNamedRange type for more information. - */ -export type ScrollRangeValue = [name: ScrollNamedRange, value: RangeUnitValue]; - -type ScrollRangeOptions = { - rangeStart?: ScrollRangeValue | undefined; - rangeEnd?: ScrollRangeValue | undefined; -}; - -/* -type AnimationTiming = { - delay?: number; - duration?: number; - easing?: string; - fill?: FillMode; -}; -*/ - -type AnimationAxis = "block" | "inline" | "x" | "y"; - -type ScrollAction = { - type: "scroll"; - source?: "closest" | "nearest" | "root"; - axis?: AnimationAxis; - animations: { - timing: KeyframeEffectOptions & ScrollRangeOptions; - keyframes: AnimationKeyframe[]; - }[]; -}; - -export type ViewNamedRange = - | "contain" - | "cover" - | "entry" - | "exit" - | "entry-crossing" - | "exit-crossing"; - -export type ViewRangeValue = [name: ViewNamedRange, value: RangeUnitValue]; - -type ViewRangeOptions = { - rangeStart?: ViewRangeValue | undefined; - rangeEnd?: ViewRangeValue | undefined; -}; - -type ViewAction = { - type: "view"; - subject?: string; - - axis?: AnimationAxis; - animations: { - timing: KeyframeEffectOptions & ViewRangeOptions; - keyframes: AnimationKeyframe[]; - }[]; -}; - -export type AnimationAction = ScrollAction | ViewAction; diff --git a/packages/sdk-components-animation/src/shared/consts.ts b/packages/sdk-components-animation/src/shared/consts.ts new file mode 100644 index 000000000000..0a2dee9706ec --- /dev/null +++ b/packages/sdk-components-animation/src/shared/consts.ts @@ -0,0 +1,2 @@ +export const animationCanPlayOnCanvasProperty = + "data-ws-animation-can-play-on-canvas"; diff --git a/packages/sdk-components-animation/tsconfig.json b/packages/sdk-components-animation/tsconfig.json index b3abf47f839c..0744a4ea7304 100644 --- a/packages/sdk-components-animation/tsconfig.json +++ b/packages/sdk-components-animation/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "@webstudio-is/tsconfig/base.json", - "include": ["src", "../../@types/**/scroll-timeline.d.ts", "private-src"], + "include": [ + "src", + "../../@types/**/scroll-timeline.d.ts", + "private-src", + "../sdk/src/schema/animation-schema.ts" + ], "compilerOptions": { "types": ["react/experimental", "react-dom/experimental", "@types/node"] } diff --git a/packages/sdk-components-animation/vitest.config.ts b/packages/sdk-components-animation/vitest.config.ts index cd93bbe1ed1f..e43995a09338 100644 --- a/packages/sdk-components-animation/vitest.config.ts +++ b/packages/sdk-components-animation/vitest.config.ts @@ -1,6 +1,30 @@ import { defineConfig } from "vitest/config"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; + +const rootDir = ["..", "../..", "../../.."] + .map((dir) => path.join(__dirname, dir)) + .find((dir) => existsSync(path.join(dir, ".git"))); + +const hasPrivateFolders = + fg.sync([path.join(rootDir ?? "", "packages/*/private-src/*")], { + ignore: ["**/node_modules/**"], + }).length > 0; + +const conditions = hasPrivateFolders + ? ["webstudio-private", "webstudio"] + : ["webstudio"]; export default defineConfig({ + resolve: { + conditions, + }, + ssr: { + resolve: { + conditions, + }, + }, test: { passWithNoTests: true, workspace: [ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 10d1c9e110c4..ec34c027b25f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -23,3 +23,28 @@ export * from "./resources-generator"; export * from "./page-meta-generator"; export * from "./url-pattern"; export * from "./css"; + +export type { + AnimationAction, + AnimationActionScroll, + AnimationActionView, + AnimationKeyframe, + KeyframeStyles, + RangeUnit, + RangeUnitValue, + ScrollNamedRange, + ScrollRangeValue, + ViewNamedRange, + ViewRangeValue, + ScrollAnimation, + ViewAnimation, +} from "./schema/animation-schema"; + +export { + animationActionSchema, + scrollAnimationSchema, + viewAnimationSchema, + rangeUnitValueSchema, + animationKeyframeSchema, + RANGE_UNITS, +} from "./schema/animation-schema"; diff --git a/packages/sdk/src/schema/animation-schema.ts b/packages/sdk/src/schema/animation-schema.ts new file mode 100644 index 000000000000..3019bb961d28 --- /dev/null +++ b/packages/sdk/src/schema/animation-schema.ts @@ -0,0 +1,207 @@ +import { StyleValue } from "@webstudio-is/css-engine"; +import { z } from "zod"; + +// Helper for creating union of string literals from array +const literalUnion = (arr: T) => + z.union( + arr.map((val) => z.literal(val)) as [ + z.ZodLiteral, + z.ZodLiteral, + ...z.ZodLiteral[], + ] + ); + +// Range Units +export const RANGE_UNITS = [ + "%", + "px", + "cm", + "mm", + "q", + "in", + "pt", + "pc", + "em", + "rem", + "ex", + "rex", + "cap", + "rcap", + "ch", + "rch", + "lh", + "rlh", + "vw", + "svw", + "lvw", + "dvw", + "vh", + "svh", + "lvh", + "dvh", + "vi", + "svi", + "lvi", + "dvi", + "vb", + "svb", + "lvb", + "dvb", + "vmin", + "svmin", + "lvmin", + "dvmin", + "vmax", + "svmax", + "lvmax", + "dvmax", +] as const; + +export const rangeUnitSchema = literalUnion(RANGE_UNITS); + +export const rangeUnitValueSchema = z.union([ + z.object({ + type: z.literal("unit"), + value: z.number(), + unit: rangeUnitSchema, + }), + z.object({ + type: z.literal("unparsed"), + value: z.string(), + }), +]); + +// @todo: Fix Keyframe Styles +export const keyframeStylesSchema = z.record(StyleValue); + +// Animation Keyframe +export const animationKeyframeSchema = z.object({ + offset: z.number().optional(), + styles: keyframeStylesSchema, +}); + +// Keyframe Effect Options +export const keyframeEffectOptionsSchema = z.object({ + easing: z.string().optional(), + fill: z + .union([ + z.literal("none"), + z.literal("forwards"), + z.literal("backwards"), + z.literal("both"), + ]) + .optional(), // FillMode +}); + +// Scroll Named Range +export const scrollNamedRangeSchema = z.union([ + z.literal("start"), + z.literal("end"), +]); + +// Scroll Range Value +export const scrollRangeValueSchema = z.tuple([ + scrollNamedRangeSchema, + rangeUnitValueSchema, +]); + +// Scroll Range Options +export const scrollRangeOptionsSchema = z.object({ + rangeStart: scrollRangeValueSchema.optional(), + rangeEnd: scrollRangeValueSchema.optional(), +}); + +// Animation Axis +export const animationAxisSchema = z.union([ + z.literal("block"), + z.literal("inline"), + z.literal("x"), + z.literal("y"), +]); + +// View Named Range +export const viewNamedRangeSchema = z.union([ + z.literal("contain"), + z.literal("cover"), + z.literal("entry"), + z.literal("exit"), + z.literal("entry-crossing"), + z.literal("exit-crossing"), +]); + +// View Range Value +export const viewRangeValueSchema = z.tuple([ + viewNamedRangeSchema, + rangeUnitValueSchema, +]); + +// View Range Options +export const viewRangeOptionsSchema = z.object({ + rangeStart: viewRangeValueSchema.optional(), + rangeEnd: viewRangeValueSchema.optional(), +}); + +const baseAnimation = z.object({ + name: z.string().optional(), + description: z.string().optional(), + keyframes: z.array(animationKeyframeSchema), +}); + +export const scrollAnimationSchema = baseAnimation.merge( + z.object({ + timing: keyframeEffectOptionsSchema.merge(scrollRangeOptionsSchema), + }) +); + +// Scroll Action +export const scrollActionSchema = z.object({ + type: z.literal("scroll"), + source: z + .union([z.literal("closest"), z.literal("nearest"), z.literal("root")]) + .optional(), + axis: animationAxisSchema.optional(), + animations: z.array(scrollAnimationSchema), + isPinned: z.boolean().optional(), +}); + +export const viewAnimationSchema = baseAnimation.merge( + z.object({ + timing: keyframeEffectOptionsSchema.merge(viewRangeOptionsSchema), + }) +); + +// View Action +export const viewActionSchema = z.object({ + type: z.literal("view"), + subject: z.string().optional(), + axis: animationAxisSchema.optional(), + animations: z.array(viewAnimationSchema), + isPinned: z.boolean().optional(), +}); + +// Animation Action +export const animationActionSchema = z.discriminatedUnion("type", [ + scrollActionSchema, + viewActionSchema, +]); + +// Helper function to check if a value is a valid range unit +export const isRangeUnit = ( + value: unknown +): value is z.infer => + rangeUnitSchema.safeParse(value).success; + +// Type exports +export type RangeUnit = z.infer; +export type RangeUnitValue = z.infer; +export type KeyframeStyles = z.infer; +export type AnimationKeyframe = z.infer; +export type ScrollNamedRange = z.infer; +export type ScrollRangeValue = z.infer; +export type ViewNamedRange = z.infer; +export type ViewRangeValue = z.infer; +export type AnimationActionScroll = z.infer; +export type AnimationActionView = z.infer; +export type AnimationAction = z.infer; +export type ScrollAnimation = z.infer; +export type ViewAnimation = z.infer; diff --git a/packages/sdk/src/schema/prop-meta.ts b/packages/sdk/src/schema/prop-meta.ts index e10577d3c34a..e19e9798df1f 100644 --- a/packages/sdk/src/schema/prop-meta.ts +++ b/packages/sdk/src/schema/prop-meta.ts @@ -167,6 +167,13 @@ const TextContent = z.object({ defaultValue: z.string().optional(), }); +const AnimationAction = z.object({ + ...common, + control: z.literal("animationAction"), + type: z.literal("animationAction"), + defaultValue: z.undefined().optional(), +}); + export const PropMeta = z.union([ Number, Range, @@ -187,6 +194,7 @@ export const PropMeta = z.union([ Date, Action, TextContent, + AnimationAction, ]); export type PropMeta = z.infer; diff --git a/packages/sdk/src/schema/props.ts b/packages/sdk/src/schema/props.ts index 6848538b1231..a17cbf521242 100644 --- a/packages/sdk/src/schema/props.ts +++ b/packages/sdk/src/schema/props.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { animationActionSchema } from "./animation-schema"; const PropId = z.string(); @@ -80,6 +81,11 @@ export const Prop = z.union([ }) ), }), + z.object({ + ...baseProp, + type: z.literal("animationAction"), + value: animationActionSchema, + }), ]); export type Prop = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 531aa26c67ef..4a494a947e9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,6 +452,9 @@ importers: '@webstudio-is/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 html-tags: specifier: ^4.0.0 version: 4.0.0 @@ -921,6 +924,9 @@ importers: '@types/react-dom': specifier: ^18.2.25 version: 18.2.25 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 typescript: specifier: 5.7.3 version: 5.7.3 @@ -1893,6 +1899,9 @@ importers: '@webstudio-is/tsconfig': specifier: workspace:* version: link:../tsconfig + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 playwright: specifier: ^1.50.1 version: 1.50.1 @@ -1908,6 +1917,9 @@ importers: vitest: specifier: ^3.0.4 version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.7)(@vitest/browser@3.0.5)(jsdom@20.0.3)(msw@2.7.0(@types/node@22.10.7)(typescript@5.7.3)) + zod: + specifier: ^3.22.4 + version: 3.22.4 packages/sdk-components-react: dependencies: From b3f5e9885b6cf50c9e34efbb055250ae52052b5d Mon Sep 17 00:00:00 2001 From: istarkov Date: Sun, 23 Feb 2025 13:30:18 +0000 Subject: [PATCH 02/10] Fix --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5168daa9b3ad..881ba5b98300 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -135,7 +135,7 @@ jobs: const results = [ await assertSize('./fixtures/ssg/dist/client', 352), await assertSize('./fixtures/react-router-netlify/build/client', 360), - await assertSize('./fixtures/webstudio-features/build/client', 926), + await assertSize('./fixtures/webstudio-features/build/client', 932), ] for (const result of results) { if (result.passed) { From 26da52e4bce390708c2fe40bf16c2b32efcb62c0 Mon Sep 17 00:00:00 2001 From: istarkov Date: Sun, 23 Feb 2025 14:24:57 +0000 Subject: [PATCH 03/10] Add camel --- .../props-section/animation/animation-panel-content.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx index e22bfb8101e4..96239dc4ce95 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx +++ b/apps/builder/app/builder/features/settings-panel/props-section/animation/animation-panel-content.tsx @@ -28,6 +28,7 @@ import { toValue, type StyleValue } from "@webstudio-is/css-engine"; import { useState } from "react"; import { Keyframes } from "./animation-keyframes"; import { styleConfigByName } from "~/builder/features/style-panel/shared/configs"; +import { titleCase } from "title-case"; type Props = { type: "scroll" | "view"; @@ -230,7 +231,7 @@ export const AnimationPanelContent = ({ onChange, value, type }: Props) => {