experimental: Animate UI (#4851)

## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Ivan Starkov
2025-02-24 14:27:50 +05:00
committed by GitHub
parent 5cd30fe8b0
commit 62a6d939d8
62 changed files with 2597 additions and 326 deletions

View File

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

View File

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

View File

@ -12,6 +12,7 @@ declare class ScrollTimeline extends AnimationTimeline {
interface ViewTimelineOptions {
subject?: Element | Document | null;
axis?: ScrollAxis;
inset?: string;
}
declare class ViewTimeline extends ScrollTimeline {

View File

@ -59,9 +59,15 @@ const $metas = computed(
const availableComponents = new Set<string>();
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"

View File

@ -1631,7 +1631,16 @@ export const PageSettings = ({
onDuplicate={() => {
const newPageId = duplicatePage(pageId);
if (newPageId !== undefined) {
// 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);
});
});
}
}}
>

View File

@ -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;
}

View File

@ -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 (
<CssValueInput
id={id}
placeholder="auto"
getOptions={() => []}
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 (
<>
<Grid
gap={1}
align={"center"}
css={{ gridTemplateColumns: "1fr 1fr auto" }}
>
<Label htmlFor={ids.offset}>Offset</Label>
<OffsetInput
id={ids.offset}
value={value.offset}
onChange={(offset) => {
onChange({ ...value, offset });
}}
/>
<Tooltip content="Remove keyframe">
<IconButton onClick={() => onChange(undefined)}>
<MinusIcon />
</IconButton>
</Tooltip>
</Grid>
<Grid>
<CodeEditor
lang="css-properties"
size="keyframe"
value={cssProperties}
onChange={() => {
/* do nothing */
}}
onChangeComplete={(cssText) => {
const parsedStyles = parseCss(`selector{${cssText}}`);
onChange({
...value,
styles: parsedStyles.reduce(
(r, { property, value }) => ({ ...r, [property]: value }),
{}
),
});
}}
/>
</Grid>
</>
);
};
export const Keyframes = ({
value: keyframes,
onChange,
}: {
value: AnimationKeyframe[];
onChange: (value: AnimationKeyframe[]) => void;
}) => {
const ids = useIds(["addKeyframe"]);
return (
<Grid gap={2}>
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr auto" }}>
<Label htmlFor={ids.addKeyframe}>
<Text variant={"titles"}>Keyframes</Text>
</Label>
<IconButton
id={ids.addKeyframe}
onClick={() =>
onChange([...keyframes, { offset: undefined, styles: {} }])
}
>
<PlusIcon />
</IconButton>
</Grid>
{keyframes.map((value, index) => (
<Fragment key={index}>
<Separator />
<Keyframe
key={index}
value={value}
onChange={(newValue) => {
if (newValue === undefined) {
const newValues = [...keyframes];
newValues.splice(index, 1);
onChange(newValues);
return;
}
const newValues = [...keyframes];
newValues[index] = newValue;
onChange(newValues);
}}
/>
</Fragment>
))}
</Grid>
);
};

View File

@ -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) => (
<div style={{ background: theme.colors.backgroundPanel, padding: 16 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof AnimationPanelContent>;
export default meta;
type Story = StoryObj<typeof meta>;
const ScrollAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
const [value, setValue] = useState(initialValue);
return (
<AnimationPanelContent
type="scroll"
value={value}
onChange={(newValue) => {
setValue(newValue as ScrollAnimation);
}}
/>
);
};
const ViewAnimationTemplate: Story["render"] = ({ value: initialValue }) => {
const [value, setValue] = useState(initialValue);
return (
<AnimationPanelContent
type="view"
value={value}
onChange={(newValue) => {
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: () => {},
},
};

View File

@ -0,0 +1,410 @@
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";
import { titleCase } from "title-case";
type Props = {
type: "scroll" | "view";
value: ScrollAnimation | ViewAnimation;
onChange: (value: ScrollAnimation | ViewAnimation) => void;
};
const fillModeDescriptions: Record<
NonNullable<ViewAnimation["timing"]["fill"]>,
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
*
* <timeline-range-name>
**/
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 (
<CssValueInput
id={id}
styleSource="default"
value={value}
/* marginLeft to allow negative values */
property={"marginLeft"}
unitOptions={unitOptions}
intermediateValue={intermediateValue}
onChange={(styleValue) => {
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 (
<CssValueInput
id={id}
styleSource="default"
value={
value === undefined
? { type: "keyword", value: "ease" }
: { type: "unparsed", value }
}
getOptions={() => [
...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 (
<Grid gap="2" css={{ padding: theme.panel.padding }}>
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
<Label htmlFor={fieldIds.fill}>Fill Mode</Label>
<Label htmlFor={fieldIds.easing}>Easing</Label>
<Select
id={fieldIds.fill}
options={fillModeNames}
getLabel={(fillModeName: string) => titleCase(fillModeName)}
value={value.timing.fill ?? fillModeNames[0]}
getDescription={(fillModeName: string) => (
<Box
css={{
width: theme.spacing[28],
}}
>
{
fillModeDescriptions[
fillModeName as keyof typeof fillModeDescriptions
]
}
</Box>
)}
onChange={(fillModeName) => {
handleChange({
...value,
timing: {
...value.timing,
fill: fillModeName,
},
});
}}
/>
<EasingInput
id={fieldIds.easing}
value={value.timing.easing}
onChange={(easing) => {
handleChange({
...value,
timing: {
...value.timing,
easing,
},
});
}}
/>
</Grid>
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "2fr 1fr" }}>
<Label htmlFor={fieldIds.rangeStartName}>Range Start</Label>
<Label htmlFor={fieldIds.rangeStartValue}>Value</Label>
<Select
id={fieldIds.rangeStartName}
options={timelineRangeNames}
getLabel={(timelineRangeName: string) =>
toPascalCase(timelineRangeName)
}
value={value.timing.rangeStart?.[0] ?? timelineRangeNames[0]!}
getDescription={(timelineRangeName: string) => (
<Box
css={{
width: theme.spacing[28],
}}
>
{
timelineRangeDescriptions[
timelineRangeName as keyof typeof timelineRangeDescriptions
]
}
</Box>
)}
onChange={(timelineRangeName) => {
handleChange({
...value,
timing: {
...value.timing,
rangeStart: [
timelineRangeName,
value.timing.rangeStart?.[1] ?? {
type: "unit",
value: 0,
unit: "%",
},
],
},
});
}}
/>
<RangeValueInput
id={fieldIds.rangeStartValue}
value={
value.timing.rangeStart?.[1] ?? {
type: "unit",
value: 0,
unit: "%",
}
}
onChange={(rangeStart) => {
const defaultTimelineRangeName = timelineRangeNames[0]!;
handleChange({
...value,
timing: {
...value.timing,
rangeStart: [
value.timing.rangeStart?.[0] ?? defaultTimelineRangeName,
rangeStart,
],
},
});
}}
/>
<Label htmlFor={fieldIds.rangeEndName}>Range End</Label>
<Label htmlFor={fieldIds.rangeEndValue}>Value</Label>
<Select
id={fieldIds.rangeEndName}
options={timelineRangeNames}
getLabel={(timelineRangeName: string) =>
toPascalCase(timelineRangeName)
}
value={value.timing.rangeEnd?.[0] ?? timelineRangeNames[0]!}
getDescription={(timelineRangeName: string) => (
<Box
css={{
width: theme.spacing[28],
}}
>
{
timelineRangeDescriptions[
timelineRangeName as keyof typeof timelineRangeDescriptions
]
}
</Box>
)}
onChange={(timelineRangeName) => {
handleChange({
...value,
timing: {
...value.timing,
rangeEnd: [
timelineRangeName,
value.timing.rangeEnd?.[1] ?? {
type: "unit",
value: 0,
unit: "%",
},
],
},
});
}}
/>
<RangeValueInput
id={fieldIds.rangeEndValue}
value={
value.timing.rangeEnd?.[1] ?? {
type: "unit",
value: 0,
unit: "%",
}
}
onChange={(rangeEnd) => {
const defaultTimelineRangeName = timelineRangeNames[0]!;
handleChange({
...value,
timing: {
...value.timing,
rangeEnd: [
value.timing.rangeEnd?.[0] ?? defaultTimelineRangeName,
rangeEnd,
],
},
});
}}
/>
</Grid>
<Keyframes
value={value.keyframes}
onChange={(keyframes) => handleChange({ ...value, keyframes })}
/>
</Grid>
);
};

View File

@ -0,0 +1,362 @@
import {
Grid,
theme,
Select,
Label,
Separator,
Box,
toast,
ToggleGroup,
Tooltip,
ToggleGroupButton,
Text,
Switch,
} from "@webstudio-is/design-system";
import { useIds } from "~/shared/form-utils";
import type { PropAndMeta } from "../use-props-logic";
import type {
AnimationAction,
AnimationActionScroll,
InsetUnitValue,
} from "@webstudio-is/sdk";
import { toPascalCase } from "~/builder/features/style-panel/shared/keyword-utils";
import {
animationActionSchema,
insetUnitValueSchema,
RANGE_UNITS,
} from "@webstudio-is/sdk";
import { RepeatColumnIcon, RepeatRowIcon } from "@webstudio-is/icons";
import { AnimationsSelect } from "./animations-select";
import { SubjectSelect } from "./subject-select";
import { toValue, type StyleValue } from "@webstudio-is/css-engine";
import {
CssValueInput,
type IntermediateStyleValue,
} from "~/builder/features/style-panel/shared/css-value-input";
import { useState } from "react";
const animationTypeDescription: Record<AnimationAction["type"], string> = {
scroll:
"Scroll-based animations are triggered and controlled by the users scroll position.",
view: "View-based animations occur when an element enters or exits the viewport. They rely on the elements visibility rather than the scroll position.",
};
const animationTypes: AnimationAction["type"][] = Object.keys(
animationTypeDescription
) as AnimationAction["type"][];
const defaultActionValue: AnimationAction = {
type: "scroll",
animations: [],
};
const animationAxisDescription: Record<
NonNullable<AnimationAction["axis"]>,
{ icon: React.ReactNode; label: string; description: React.ReactNode }
> = {
block: {
icon: <RepeatColumnIcon />,
label: "Block axis",
description:
"Uses the scroll progress along the block axis (depends on writing mode, usually vertical in English).",
},
inline: {
icon: <RepeatRowIcon />,
label: "Inline axis",
description:
"Uses the scroll progress along the inline axis (depends on writing mode, usually horizontal in English).",
},
y: {
label: "Y axis",
icon: <RepeatColumnIcon />,
description:
"Always maps to the vertical scroll direction, regardless of writing mode.",
},
x: {
label: "X axis",
icon: <RepeatRowIcon />,
description:
"Always maps to the horizontal scroll direction, regardless of writing mode.",
},
};
const animationSourceDescriptions: Record<
NonNullable<AnimationActionScroll["source"]>,
string
> = {
nearest: "Selects the scrolling container that affects the current element.",
root: "Selects the scrolling element of the document.",
closest: "Selects the nearest ancestor element that is scrollable.",
};
const unitOptions = RANGE_UNITS.map((unit) => ({
id: unit,
label: unit,
type: "unit" as const,
}));
const InsetValueInput = ({
id,
value,
onChange,
}: {
id: string;
value: InsetUnitValue;
onChange: (value: InsetUnitValue) => void;
}) => {
const [intermediateValue, setIntermediateValue] = useState<
StyleValue | IntermediateStyleValue
>();
return (
<CssValueInput
id={id}
styleSource="default"
value={value}
/* marginLeft to allow negative values */
property={"marginLeft"}
unitOptions={unitOptions}
intermediateValue={intermediateValue}
onChange={(styleValue) => {
setIntermediateValue(styleValue);
/* @todo: allow to change some ephemeral property to see the result in action */
}}
getOptions={() => [
{
value: "auto",
type: "keyword",
description:
"Pick the child elements viewTimelineInset property or use the scrolling elements scroll-padding, depending on the selected axis.",
},
]}
onHighlight={() => {
/* Nothing to Highlight */
}}
onChangeComplete={(event) => {
const parsedValue = insetUnitValueSchema.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 animationSources = Object.keys(
animationSourceDescriptions
) as NonNullable<AnimationActionScroll["source"]>[];
export const AnimateSection = ({
animationAction,
onChange,
}: {
animationAction: PropAndMeta;
onChange: (value: AnimationAction) => void;
}) => {
const fieldIds = useIds([
"type",
"subject",
"source",
"insetStart",
"insetEnd",
] as const);
const { prop } = animationAction;
const value: AnimationAction =
prop?.type === "animationAction" ? prop.value : defaultActionValue;
const handleChange = (value: unknown) => {
const parsedValue = animationActionSchema.safeParse(value);
if (parsedValue.success) {
onChange(parsedValue.data);
return;
}
toast.error("Schemas are incompatible, try fix");
};
return (
<Grid
css={{
paddingBottom: theme.panel.paddingBlock,
}}
>
<Box css={{ height: theme.panel.paddingBlock }} />
<Separator />
<Grid
gap={1}
align={"center"}
css={{
gridTemplateColumns: "1fr auto",
padding: theme.panel.paddingInline,
}}
>
<Text variant={"titles"}>Animation</Text>
<Tooltip content={value.isPinned ? "Unpin Animation" : "Pin Animation"}>
<Switch
checked={value.isPinned ?? false}
onCheckedChange={(isPinned) => {
handleChange({ ...value, isPinned });
}}
/>
</Tooltip>
</Grid>
<Separator />
<Box css={{ height: theme.panel.paddingBlock }} />
<Grid gap={2} css={{ paddingInline: theme.panel.paddingInline }}>
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
<Label htmlFor={fieldIds.type}>Action</Label>
<Select
id={fieldIds.type}
options={animationTypes}
getLabel={(animationType: AnimationAction["type"]) =>
toPascalCase(animationType)
}
value={value.type}
getDescription={(animationType: AnimationAction["type"]) => (
<Box
css={{
width: theme.spacing[28],
}}
>
{animationTypeDescription[animationType]}
</Box>
)}
onChange={(typeValue) => {
handleChange({ ...value, type: typeValue, animations: [] });
}}
/>
</Grid>
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr 1fr" }}>
<Label>Axis</Label>
<ToggleGroup
type="single"
value={value.axis ?? ("block" as const)}
onValueChange={(axis) => {
handleChange({ ...value, axis });
}}
>
{Object.entries(animationAxisDescription).map(
([key, { icon, label, description }]) => (
<Tooltip
key={key}
content={
<Grid gap={1}>
<Text variant={"titles"}>{label}</Text>
<Text>{description}</Text>
</Grid>
}
>
<ToggleGroupButton value={key}>{icon}</ToggleGroupButton>
</Tooltip>
)
)}
</ToggleGroup>
</Grid>
{value.type === "scroll" && (
<Grid
gap={1}
align={"center"}
css={{ gridTemplateColumns: "1fr 1fr" }}
>
<Label htmlFor={fieldIds.source}>Scroll Source</Label>
<Select
id={fieldIds.source}
options={animationSources}
getLabel={(
animationSource: NonNullable<AnimationActionScroll["source"]>
) => toPascalCase(animationSource)}
value={value.source ?? "nearest"}
getDescription={(
animationSource: NonNullable<AnimationActionScroll["source"]>
) => (
<Box
css={{
width: theme.spacing[28],
}}
>
{animationSourceDescriptions[animationSource]}
</Box>
)}
onChange={(source) => {
handleChange({ ...value, source });
}}
/>
</Grid>
)}
{value.type === "view" && (
<Grid
gap={1}
align={"center"}
css={{ gridTemplateColumns: "1fr 1fr" }}
>
<Label htmlFor={fieldIds.subject}>Subject</Label>
<SubjectSelect
id={fieldIds.subject}
value={value}
onChange={onChange}
/>
</Grid>
)}
{value.type === "view" && (
<Grid
gap={1}
align={"center"}
css={{ gridTemplateColumns: "1fr 1fr" }}
>
<Label htmlFor={fieldIds.insetStart}>
{value.axis === "inline" || value.axis === "x"
? "Left Inset"
: "Top Inset"}
</Label>
<Label htmlFor={fieldIds.insetEnd}>
{value.axis === "inline" || value.axis === "x"
? "Right Inset"
: "Bottom Inset"}
</Label>
<InsetValueInput
id={fieldIds.insetStart}
value={value.insetStart ?? { type: "keyword", value: "auto" }}
onChange={(insetStart) => {
handleChange({ ...value, insetStart });
}}
/>
<InsetValueInput
id={fieldIds.insetEnd}
value={value.insetEnd ?? { type: "keyword", value: "auto" }}
onChange={(insetEnd) => {
handleChange({ ...value, insetEnd });
}}
/>
</Grid>
)}
<AnimationsSelect value={value} onChange={onChange} />
</Grid>
</Grid>
);
};

View File

@ -0,0 +1,270 @@
import { useState, useMemo } from "react";
import {
theme,
IconButton,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
rawTheme,
CssValueListItem,
SmallToggleButton,
SmallIconButton,
Box,
Text,
Label,
Grid,
useSortable,
CssValueListArrowFocus,
toast,
FloatingPanel,
InputField,
DialogTitle,
} from "@webstudio-is/design-system";
import {
EyeClosedIcon,
EyeOpenIcon,
MinusIcon,
PlusIcon,
} from "@webstudio-is/icons";
import type {
AnimationAction,
ScrollAnimation,
ViewAnimation,
} from "@webstudio-is/sdk";
import { animationActionSchema } from "@webstudio-is/sdk";
import {
newFadeInScrollAnimation,
newFadeOutScrollAnimation,
newScrollAnimation,
} from "./new-scroll-animations";
import {
newFadeInViewAnimation,
newFadeOutViewAnimation,
newViewAnimation,
} from "./new-view-animations";
import { useIds } from "~/shared/form-utils";
import { AnimationPanelContent } from "./animation-panel-content";
const newAnimationsPerType: {
scroll: ScrollAnimation[];
view: ViewAnimation[];
} = {
scroll: [
newScrollAnimation,
newFadeInScrollAnimation,
newFadeOutScrollAnimation,
],
view: [newViewAnimation, newFadeInViewAnimation, newFadeOutViewAnimation],
};
type Props = {
value: AnimationAction;
onChange: (value: AnimationAction) => void;
};
const floatingPanelOffset = { alignmentAxis: -100 };
export const AnimationsSelect = ({ value, onChange }: Props) => {
const fieldIds = useIds(["addAnimation"] as const);
const [newAnimationHint, setNewAnimationHint] = useState<string | undefined>(
undefined
);
const newAnimations = newAnimationsPerType[value.type];
const sortableItems = useMemo(
() => value.animations.map((_, index) => ({ id: `${index}`, index })),
[value.animations]
);
const { dragItemId, placementIndicator, sortableRefCallback } = useSortable({
items: sortableItems,
onSort: (newIndex, oldIndex) => {
const newAnimations = [...value.animations];
const [movedItem] = newAnimations.splice(oldIndex, 1);
newAnimations.splice(newIndex, 0, movedItem);
const newValue = { ...value, animations: newAnimations };
const parsedValue = animationActionSchema.safeParse(newValue);
if (parsedValue.success) {
onChange(parsedValue.data);
return;
}
toast.error("Failed to sort animation");
},
});
const handleChange = (newValue: unknown) => {
const parsedValue = animationActionSchema.safeParse(newValue);
if (parsedValue.success) {
onChange(parsedValue.data);
return;
}
toast.error("Failed to add animation");
};
return (
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr auto" }}>
<Label htmlFor={fieldIds.addAnimation}>
<Text variant={"titles"}>Animations</Text>
</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton id={fieldIds.addAnimation}>
<PlusIcon />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={Number.parseFloat(rawTheme.spacing[5])}
css={{ width: theme.spacing[25] }}
>
{newAnimations.map((animation, index) => (
<DropdownMenuItem
key={index}
onSelect={() => {
handleChange({
...value,
animations: value.animations.concat(animation),
});
}}
onFocus={() => setNewAnimationHint(animation.description)}
onBlur={() => setNewAnimationHint(undefined)}
>
{animation.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem css={{ display: "grid" }} hint>
{newAnimations.map(({ description }, index) => (
<Box
css={{
gridColumn: "1",
gridRow: "1",
visibility: "hidden",
}}
key={index}
>
{description}
</Box>
))}
<Box
css={{
gridColumn: "1",
gridRow: "1",
}}
>
{newAnimationHint ?? "Add new or select existing animation"}
</Box>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CssValueListArrowFocus dragItemId={dragItemId}>
<Grid gap={1} css={{ gridColumn: "span 2" }} ref={sortableRefCallback}>
{value.animations.map((animation, index) => (
<FloatingPanel
key={index}
title={
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
<InputField
css={{
width: "100%",
fontWeight: `inherit`,
}}
variant="chromeless"
value={animation.name}
autoFocus={true}
placeholder="Enter animation name"
onChange={(event) => {
const name = event.currentTarget.value;
const newAnimations = [...value.animations];
newAnimations[index] = { ...animation, name };
const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue);
}}
/>
</DialogTitle>
}
content={
<AnimationPanelContent
type={value.type}
value={animation}
onChange={(animation) => {
const newAnimations = [...value.animations];
newAnimations[index] = animation;
const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue);
}}
/>
}
offset={floatingPanelOffset}
>
<CssValueListItem
key={index}
label={
<Label disabled={false} truncate>
{animation.name ?? "Unnamed"}
</Label>
}
hidden={false}
draggable
active={dragItemId === String(index)}
state={undefined}
index={index}
id={String(index)}
buttons={
<>
<SmallToggleButton
pressed={false}
onPressedChange={() => {
alert("Not implemented");
}}
variant="normal"
tabIndex={-1}
icon={
// eslint-disable-next-line no-constant-condition
false ? <EyeClosedIcon /> : <EyeOpenIcon />
}
/>
<SmallIconButton
variant="destructive"
tabIndex={-1}
icon={<MinusIcon />}
onClick={() => {
const newAnimations = [...value.animations];
newAnimations.splice(index, 1);
const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue);
}}
/>
</>
}
/>
</FloatingPanel>
))}
{placementIndicator}
</Grid>
</CssValueListArrowFocus>
</Grid>
);
};

View File

@ -0,0 +1,61 @@
import { parseCssValue } from "@webstudio-is/css-data";
import type { ScrollAnimation } from "@webstudio-is/sdk";
export const newScrollAnimation: ScrollAnimation = {
name: "New Animation",
description: "Create a new animation.",
timing: {
rangeStart: ["start", { type: "unit", value: 0, unit: "px" }],
rangeEnd: ["end", { type: "unit", value: 0, unit: "px" }],
fill: "backwards",
easing: "linear",
},
keyframes: [
{
offset: 0,
styles: {},
},
],
};
// @todo: visit https://github.com/argyleink/open-props/blob/main/src/props.animations.css
export const newFadeInScrollAnimation: ScrollAnimation = {
name: "Fade In",
description: "Fade in the element as it scrolls into view.",
timing: {
rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
rangeEnd: ["start", { type: "unit", value: 50, unit: "dvh" }],
fill: "backwards",
easing: "linear",
},
keyframes: [
{
offset: 0,
styles: {
opacity: parseCssValue("opacity", "0"),
},
},
],
};
export const newFadeOutScrollAnimation: ScrollAnimation = {
name: "Fade Out",
description: "Fade out the element as it scrolls out of view.",
timing: {
rangeStart: ["end", { type: "unit", value: 50, unit: "dvh" }],
rangeEnd: ["end", { type: "unit", value: 0, unit: "%" }],
fill: "backwards",
easing: "linear",
},
keyframes: [
{
offset: 1,
styles: {
opacity: parseCssValue("opacity", "0"),
},
},
],
};

View File

@ -0,0 +1,61 @@
import { parseCssValue } from "@webstudio-is/css-data";
import type { ViewAnimation } from "@webstudio-is/sdk";
export const newViewAnimation: ViewAnimation = {
name: "New Animation",
description: "Create a new animation.",
timing: {
rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
rangeEnd: ["entry", { type: "unit", value: 100, unit: "%" }],
fill: "backwards",
easing: "linear",
},
keyframes: [
{
offset: 0,
styles: {},
},
],
};
// @todo: visit https://github.com/argyleink/open-props/blob/main/src/props.animations.css
export const newFadeInViewAnimation: ViewAnimation = {
name: "Fade In",
description: "Fade in the element as it scrolls into view.",
timing: {
rangeStart: ["entry", { type: "unit", value: 0, unit: "%" }],
rangeEnd: ["entry", { type: "unit", value: 100, unit: "%" }],
fill: "backwards",
easing: "linear",
},
keyframes: [
{
offset: 0,
styles: {
opacity: parseCssValue("opacity", "0"),
},
},
],
};
export const newFadeOutViewAnimation: ViewAnimation = {
name: "Fade Out",
description: "Fade out the element as it scrolls out of view.",
timing: {
rangeStart: ["exit", { type: "unit", value: 0, unit: "%" }],
rangeEnd: ["exit", { type: "unit", value: 100, unit: "%" }],
fill: "forwards",
easing: "linear",
},
keyframes: [
{
offset: 1,
styles: {
opacity: parseCssValue("opacity", "0"),
},
},
],
};

View File

@ -0,0 +1,87 @@
import { createRegularStyleSheet } from "@webstudio-is/css-engine";
import { ROOT_INSTANCE_ID, type WebstudioData } from "@webstudio-is/sdk";
import { $, ws, css, renderData } from "@webstudio-is/template";
import { expect, test } from "vitest";
import { setListedCssProperty } from "./set-css-property";
const toCss = (data: Omit<WebstudioData, "pages">) => {
const sheet = createRegularStyleSheet();
sheet.addMediaRule("base");
for (const { instanceId, values } of data.styleSourceSelections.values()) {
for (const styleSourceId of values) {
const styleSource = data.styleSources.get(styleSourceId);
let name;
if (styleSource?.type === "local") {
name = `${instanceId}:local`;
}
if (styleSource?.type === "token") {
name = `${instanceId}:token(${styleSource.name})`;
}
if (name) {
const rule = sheet.addNestingRule(name);
for (const styleDecl of data.styles.values()) {
if (styleDecl.styleSourceId === styleSourceId) {
rule.setDeclaration({
breakpoint: styleDecl.breakpointId,
selector: styleDecl.state ?? "",
property: styleDecl.property,
value: styleDecl.value,
});
}
}
}
}
}
return sheet.cssText;
};
test("Add Css Property styles", () => {
const data = renderData(
<ws.root ws:id={ROOT_INSTANCE_ID}>
<$.Body>
<$.Box ws:id="boxId">
<$.Box
ws:id="boxChildId"
ws:style={css`
color: red;
`}
></$.Box>
</$.Box>
</$.Body>
</ws.root>
);
setListedCssProperty(
data.breakpoints,
data.styleSources,
data.styleSourceSelections,
data.styles
)("boxChildId", "viewTimelineName", {
type: "unparsed",
value: "--view-timeline-name-child",
});
setListedCssProperty(
data.breakpoints,
data.styleSources,
data.styleSourceSelections,
data.styles
)("boxId", "viewTimelineName", {
type: "unparsed",
value: "--view-timeline-name",
});
expect(toCss(data)).toMatchInlineSnapshot(`
"@media all {
boxChildId:local {
color: red;
--view-timeline-name: --view-timeline-name-child;
view-timeline-name: --view-timeline-name-child
}
boxId:local {
--view-timeline-name: --view-timeline-name;
view-timeline-name: --view-timeline-name
}
}"
`);
});

View File

@ -0,0 +1,64 @@
import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine";
import {
getStyleDeclKey,
type Breakpoints,
type Instance,
type Styles,
type StyleSources,
type StyleSourceSelections,
} from "@webstudio-is/sdk";
import { nanoid } from "nanoid";
import { isBaseBreakpoint } from "~/shared/nano-states";
export const setListedCssProperty =
(
breakpoints: Breakpoints,
// Mutated
styleSources: StyleSources,
styleSourceSelections: StyleSourceSelections,
styles: Styles
) =>
(instanceId: Instance["id"], property: StyleProperty, value: StyleValue) => {
if (!styleSourceSelections.has(instanceId)) {
const styleSourceId = nanoid();
styleSources.set(styleSourceId, { type: "local", id: styleSourceId });
styleSourceSelections.set(instanceId, {
instanceId,
values: [styleSourceId],
});
}
const styleSourceSelection = styleSourceSelections.get(instanceId)!;
const localStyleSorceId = styleSourceSelection.values.find(
(styleSourceId) => styleSources.get(styleSourceId)?.type === "local"
);
if (localStyleSorceId === undefined) {
throw new Error("Local style source not found");
}
const baseBreakpoint = Array.from(breakpoints.values()).find(
isBaseBreakpoint
);
if (baseBreakpoint === undefined) {
throw new Error("Base breakpoint not found");
}
const styleKey = getStyleDeclKey({
breakpointId: baseBreakpoint.id,
property,
styleSourceId: localStyleSorceId,
});
styles.set(styleKey, {
breakpointId: baseBreakpoint.id,
property,
styleSourceId: localStyleSorceId,
value,
listed: true,
});
};

View File

@ -0,0 +1,158 @@
import { Select, toast } from "@webstudio-is/design-system";
import { nanoid } from "nanoid";
import { useState } from "react";
import {
$hoveredInstanceSelector,
$instances,
$registeredComponentMetas,
$selectedInstanceSelector,
} from "~/shared/nano-states";
import { getInstanceStyleDecl } from "~/builder/features/style-panel/shared/model";
import { getInstanceLabel } from "~/shared/instance-utils";
import { toValue } from "@webstudio-is/css-engine";
import type { AnimationAction } from "@webstudio-is/sdk";
import { animationActionSchema } from "@webstudio-is/sdk";
import { setListedCssProperty } from "./set-css-property";
import { serverSyncStore } from "~/shared/sync";
import {
$breakpoints,
$styles,
$styleSources,
$styleSourceSelections,
} from "~/shared/nano-states";
const initSubjects = () => {
const selectedInstanceSelector = $selectedInstanceSelector.get();
const instances = $instances.get();
const metas = $registeredComponentMetas.get();
if (
selectedInstanceSelector === undefined ||
selectedInstanceSelector.length === 0
) {
return [];
}
const subjects = [
{
value: "self",
label: "Self",
isTimelineExists: true,
instanceId: selectedInstanceSelector.at(0)!,
selector: selectedInstanceSelector,
},
];
for (
let selector = selectedInstanceSelector.slice(1);
selector.length !== 0;
selector = selector.slice(1)
) {
const styleDecl = getInstanceStyleDecl("viewTimelineName", selector);
const instanceId = selector.at(0)!;
const instance = instances.get(selector[0]);
if (instance === undefined) {
continue;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
continue;
}
const viewTimelineName = toValue(styleDecl.computedValue);
const isTimelineExists = viewTimelineName.startsWith("--");
const value = isTimelineExists
? viewTimelineName
: `--generated-timeline-${nanoid()}`;
subjects.push({
value,
label: getInstanceLabel(instance, meta),
isTimelineExists,
instanceId,
selector,
});
}
return subjects;
};
export const SubjectSelect = ({
value,
onChange,
id,
}: {
value: AnimationAction;
onChange: (value: AnimationAction) => void;
id: string;
}) => {
const [subjects] = useState(() => initSubjects());
if (value.type !== "view") {
return;
}
return (
<Select
id={id}
options={subjects.map((subject) => 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");
}}
/>
);
};

View File

@ -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 ? (
<>
<AnimateSection
animationAction={animationAction}
onChange={(value) =>
logic.handleChangeByPropName(animationAction.propName, {
type: "animationAction",
value,
})
}
/>
<Separator />
</>
) : (
<>
<Grid
css={{

View File

@ -92,6 +92,13 @@ const getDefaultMetaForType = (type: Prop["type"]): PropMeta => {
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"

View File

@ -49,7 +49,11 @@ export type PropValue =
| { type: "expression"; value: string }
| { type: "asset"; value: Asset["id"] }
| { type: "page"; value: Extract<Prop, { type: "page" }>["value"] }
| { type: "action"; value: Extract<Prop, { type: "action" }>["value"] };
| { type: "action"; value: Extract<Prop, { type: "action" }>["value"] }
| {
type: "animationAction";
value: Extract<Prop, { type: "animationAction" }>["value"];
};
// Weird code is to make type distributive
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types

View File

@ -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<Unit, number>([
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<HTMLDivElement | null>,
@ -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];
};
@ -273,7 +308,9 @@ type CssValueInputProps = Pick<
/**
* Selected item in the dropdown
*/
getOptions?: () => Array<KeywordValue | VarValue>;
getOptions?: () => Array<
KeywordValue | VarValue | (KeywordValue & { description?: string })
>;
onChange: (value: CssValueInputValue | undefined) => void;
onChangeComplete: (event: ChangeCompleteEvent) => void;
onHighlight: (value: StyleValue | undefined) => void;
@ -283,6 +320,9 @@ type CssValueInputProps = Pick<
onReset: () => void;
icon?: ReactNode;
showSuffix?: boolean;
unitOptions?: UnitOption[];
id?: string;
placeholder?: string;
};
const initialValue: IntermediateStyleValue = {
@ -429,6 +469,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 +485,8 @@ export const CssValueInput = ({
fieldSizing,
variant,
text,
unitOptions,
placeholder,
...props
}: CssValueInputProps) => {
const value = props.intermediateValue ?? props.value ?? initialValue;
@ -455,6 +498,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 +546,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 +577,7 @@ export const CssValueInput = ({
highlightedIndex,
closeMenu,
} = useCombobox<CssValueInputValue>({
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 +606,7 @@ export const CssValueInput = ({
const inputProps = getInputProps();
const [isUnitsOpen, unitSelectElement] = useUnitSelect({
options: unitOptions,
property,
value,
onChange: (unitOrKeyword) => {
@ -656,11 +708,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();
@ -734,10 +789,22 @@ export const CssValueInput = ({
: undefined;
if (valueForDescription) {
const option = getOptions().find(
(item) =>
item.type === "keyword" && item.value === valueForDescription.value
);
if (
option !== undefined &&
"description" in option &&
option?.description
) {
description = option.description;
} else {
const key = `${property}:${toValue(
valueForDescription
)}` as keyof typeof declarationDescriptions;
description = declarationDescriptions[key];
}
} else if (highlightedValue?.type === "var") {
description = "CSS custom property (variable)";
} else if (highlightedValue === undefined) {
@ -905,10 +972,12 @@ export const CssValueInput = ({
<Box {...getComboboxProps()}>
<ComboboxAnchor asChild>
<InputField
id={id}
variant={variant}
disabled={disabled}
aria-disabled={ariaDisabled}
fieldSizing={fieldSizing}
placeholder={placeholder}
{...inputProps}
{...autoScrollProps}
value={getInputValue()}

View File

@ -2,17 +2,47 @@ import type {
StyleProperty,
StyleValue,
InvalidValue,
Unit,
} from "@webstudio-is/css-engine";
import { units, parseCssValue, cssTryParseValue } from "@webstudio-is/css-data";
import {
units,
parseCssValue,
cssTryParseValue,
properties,
} from "@webstudio-is/css-data";
import type { IntermediateStyleValue } from "./css-value-input";
import { evaluateMath } from "./evaluate-math";
import { toKebabCase } from "../keyword-utils";
const unitsList = Object.values(units).flat();
const getDefaultUnit = (property: StyleProperty): Unit => {
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
);
}

View File

@ -41,8 +41,7 @@ describe("Parse intermediate or invalid value without math evaluation", () => {
}
});
test("fallback to px", () => {
for (const propery of properties) {
test.each(properties)(`fallback to px for property = "%s"`, (propery) => {
const result = parseIntermediateOrInvalidValue(propery, {
type: "intermediate",
value: "10",
@ -53,7 +52,19 @@ describe("Parse intermediate or invalid value without math evaluation", () => {
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", () => {

View File

@ -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 =

View File

@ -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<string, StyleValue>();
@ -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(

View File

@ -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<ComponentProps<typeof EditorContent>, "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 (
<div className={wrapperStyle()} ref={ref}>
<div className={wrapperStyle({ size })} ref={ref}>
<EditorDialogControl>
<EditorContent {...editorContentProps} extensions={extensions} />
<EditorDialog

View File

@ -1,92 +0,0 @@
import { Scroll } from "@webstudio-is/sdk-components-animation";
import { parseCssValue } from "@webstudio-is/css-data";
import { Box, styled } from "@webstudio-is/design-system";
const H1 = styled("h1");
const DEBUG = false;
const Playground = () => {
return (
<Box>
<Scroll
debug={DEBUG}
action={{
type: "scroll",
animations: [
{
timing: {
fill: "backwards",
rangeStart: ["start", { type: "unit", value: 0, unit: "%" }],
rangeEnd: ["start", { type: "unit", value: 200, unit: "px" }],
},
keyframes: [
{
offset: 0,
styles: {
transform: parseCssValue(
"transform",
"translate(0, -120px)"
),
opacity: parseCssValue("opacity", "0.2"),
},
},
],
},
{
timing: {
fill: "forwards",
rangeStart: ["end", { type: "unit", value: 200, unit: "px" }],
rangeEnd: ["end", { type: "unit", value: 0, unit: "%" }],
},
keyframes: [
{
offset: 1,
styles: {
transform: parseCssValue(
"transform",
"translate(0, 120px)"
),
opacity: parseCssValue("opacity", "0.0"),
},
},
],
},
],
}}
>
<H1
css={{
position: "fixed",
width: "100%",
textAlign: "center",
top: "80px",
margin: 0,
padding: 0,
"&:hover": {
color: "red",
},
}}
>
HELLO WORLD
</H1>
</Scroll>
<Box css={{ height: "200px", backgroundColor: "#eee", p: 4 }}>
Start scrolling, and when the current box scrolls out, the HELLO WORLD
header will fly in and become hoverable. (During the animation, it wont
be hoverable.)
</Box>
<Box css={{ height: "200vh" }}></Box>
<Box css={{ height: "200px", backgroundColor: "#eee", p: 4 }}>
When you see this box, the HELLO WORLD header will fly out.
</Box>
</Box>
);
};
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,
};

View File

@ -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<Styles>(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<StyleSources>(new Map());
/**
* This is a list of connections between instances (instanceIds) and styleSources.
* $styleSourceSelections.values[] -> $styleSources.id[]
*/
export const $styleSourceSelections = atom<StyleSourceSelections>(new Map());
export type StyleSourceSelector = {

View File

@ -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",

View File

@ -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);
const rootDir = ["..", "../..", "../../.."]
.map((dir) => path.join(__dirname, dir))
.find((dir) => existsSync(path.join(dir, ".git")));
return contents.length === 0;
};
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") {

View File

@ -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"
}
}

View File

@ -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],
});

View File

@ -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"

View File

@ -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],
});

View File

@ -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,12 +41,22 @@ 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",
},
viewTimelineInset: {
unitGroups: ["length", "percentage"],
inherited: false,
initial: {
type: "keyword",
value: "auto",
},
mdnUrl:
"https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset",
},
"-webkit-line-clamp": {
unitGroups: ["number"],
inherited: false,

View File

@ -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,13 +89,24 @@ 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",
};
propertiesData.viewTimelineInset = {
unitGroups: ["length", "percentage"],
inherited: false,
initial: {
type: "keyword",
value: "auto",
},
mdnUrl:
"https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-inset",
};
keywordValues.listStyleType = [
"disc",
"circle",

View File

@ -4,6 +4,7 @@ export type CamelCasedProperty =
| "-webkit-box-orient"
| "viewTimelineName"
| "scrollTimelineName"
| "viewTimelineInset"
| "-webkit-line-clamp"
| "-webkit-overflow-scrolling"
| "-webkit-tap-highlight-color"
@ -343,6 +344,7 @@ export type HyphenatedProperty =
| "-webkit-box-orient"
| "view-timeline-name"
| "scroll-timeline-name"
| "view-timeline-inset"
| "-webkit-line-clamp"
| "-webkit-overflow-scrolling"
| "-webkit-tap-highlight-color"

View File

@ -31,7 +31,8 @@ export const prefixStyles = (styleMap: StyleMap) => {
// https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name
if (
property === "view-timeline-name" ||
property === "scroll-timeline-name"
property === "scroll-timeline-name" ||
property === "view-timeline-inset"
) {
newStyleMap.set(`--${property}`, value);
}

View File

@ -281,7 +281,6 @@ export const useCombobox = <Item,>({
defaultHighlightedIndex,
selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode
isOpen,
onIsOpenChange(state) {
const { type, isOpen, inputValue } = state;

View File

@ -167,6 +167,13 @@ export const FloatingPanel = ({
event.preventDefault();
}
}}
onEscapeKeyDown={(event) => {
if (event.target instanceof HTMLInputElement) {
event.preventDefault();
return;
}
}}
ref={setContentElement}
>
{content}

View File

@ -247,8 +247,17 @@ export const InputField = forwardRef(
});
const unfocusContainerRef = useRef<HTMLDivElement>(null);
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (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();
}

View File

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

View File

@ -30,7 +30,8 @@ const switchStyle = css({
backgroundColor: theme.colors.backgroundNeutralDark,
},
"&[data-state=checked]:not([data-disabled]):before": {
"&[data-state=checked]:not([data-disabled]):before, &[aria-checked=true]:not([data-disabled]):before":
{
backgroundColor: theme.colors.backgroundPrimary,
},

View File

@ -9,3 +9,4 @@ export const contentEditableMode = false;
export const command = false;
export const headSlotComponent = false;
export const stylePanelAdvancedMode = false;
export const animation = false;

View File

@ -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);
}

View File

@ -58,6 +58,7 @@
"@webstudio-is/icons": "workspace:*",
"@webstudio-is/react-sdk": "workspace:*",
"@webstudio-is/sdk": "workspace:*",
"change-case": "^5.4.4",
"react-error-boundary": "^5.0.0",
"scroll-timeline-polyfill": "^1.1.0"
},
@ -72,10 +73,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"
}
}

View File

@ -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<ElementRef<"div">, ScrollProps>(
({ debug = false, action, ...props }, ref) => {
return <div ref={ref} style={{ display: "contents" }} {...props} />;
}
);
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
);
}
},
};

View File

@ -1 +1 @@
export { Scroll } from "./scroll";
export { AnimateChildren } from "./animate-children";

View File

@ -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];

View File

@ -1 +1 @@
export {};
export { meta as AnimateChildren } from "./scroll.ws";

View File

@ -1 +1 @@
export {};
export { propsMeta as AnimateChildren } from "./scroll.ws";

View File

@ -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<ElementRef<"div">, ScrollProps>(
({ debug = false, action, ...props }, ref) => {
return <div ref={ref} style={{ display: "contents" }} {...props} />;
}
);

View File

@ -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"],
};

View File

@ -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;

View File

@ -0,0 +1,2 @@
export const animationCanPlayOnCanvasProperty =
"data-ws-animation-can-play-on-canvas";

View File

@ -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"]
}

View File

@ -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: [

View File

@ -23,3 +23,30 @@ 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,
InsetUnitValue,
} from "./schema/animation-schema";
export {
animationActionSchema,
scrollAnimationSchema,
viewAnimationSchema,
rangeUnitValueSchema,
animationKeyframeSchema,
insetUnitValueSchema,
RANGE_UNITS,
} from "./schema/animation-schema";

View File

@ -0,0 +1,222 @@
import { StyleValue } from "@webstudio-is/css-engine";
import { z } from "zod";
// Helper for creating union of string literals from array
const literalUnion = <T extends readonly string[]>(arr: T) =>
z.union(
arr.map((val) => z.literal(val)) as [
z.ZodLiteral<T[0]>,
z.ZodLiteral<T[1]>,
...z.ZodLiteral<T[number]>[],
]
);
// 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(),
}),
]);
// view-timeline-inset
export const insetUnitValueSchema = z.union([
rangeUnitValueSchema,
z.object({
type: z.literal("keyword"),
value: z.literal("auto"),
}),
]);
// @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),
insetStart: insetUnitValueSchema.optional(),
insetEnd: insetUnitValueSchema.optional(),
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<typeof rangeUnitSchema> =>
rangeUnitSchema.safeParse(value).success;
// Type exports
export type RangeUnit = z.infer<typeof rangeUnitSchema>;
export type RangeUnitValue = z.infer<typeof rangeUnitValueSchema>;
export type KeyframeStyles = z.infer<typeof keyframeStylesSchema>;
export type AnimationKeyframe = z.infer<typeof animationKeyframeSchema>;
export type ScrollNamedRange = z.infer<typeof scrollNamedRangeSchema>;
export type ScrollRangeValue = z.infer<typeof scrollRangeValueSchema>;
export type ViewNamedRange = z.infer<typeof viewNamedRangeSchema>;
export type ViewRangeValue = z.infer<typeof viewRangeValueSchema>;
export type AnimationActionScroll = z.infer<typeof scrollActionSchema>;
export type AnimationActionView = z.infer<typeof viewActionSchema>;
export type AnimationAction = z.infer<typeof animationActionSchema>;
export type ScrollAnimation = z.infer<typeof scrollAnimationSchema>;
export type ViewAnimation = z.infer<typeof viewAnimationSchema>;
export type InsetUnitValue = z.infer<typeof insetUnitValueSchema>;

View File

@ -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<typeof PropMeta>;

View File

@ -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<typeof Prop>;

15
pnpm-lock.yaml generated
View File

@ -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
@ -1856,6 +1862,9 @@ importers:
'@webstudio-is/sdk':
specifier: workspace:*
version: link:../sdk
change-case:
specifier: ^5.4.4
version: 5.4.4
react-error-boundary:
specifier: ^5.0.0
version: 5.0.0(react@18.3.0-canary-14898b6a9-20240318)
@ -1893,6 +1902,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 +1920,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: