Skip to content

Commit 62a6d93

Browse files
authored
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
1 parent 5cd30fe commit 62a6d93

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+2597
-326
lines changed

.devcontainer/postinstall.sh

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,14 @@ sudo rm -rf /tmp/corepack-cache
1414
sudo rm -rf /usr/local/lib/node_modules/corepack # Manually remove global corepack
1515

1616
# Reinstall corepack globally via npm
17-
npm install -g corepack@latest # Install latest corepack version
17+
npm install -g corepack@latest --force # Install latest corepack version
1818
sudo corepack enable # Re-enable corepack
1919

2020
# Check corepack version after reinstall
21-
echo "--- Corepack version after reinstall ---"
2221
corepack --version
23-
echo "--- End corepack version check ---"
24-
2522

2623
# Prepare pnpm (again, after corepack reinstall)
27-
sudo corepack prepare [email protected] --activate
24+
corepack prepare [email protected] --activate
2825

2926
# Go to workspace directory
3027
cd /workspaces/webstudio
@@ -42,7 +39,7 @@ find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +
4239

4340
# Install dependencies, build, and migrate
4441
pnpm install
45-
pnpm run build
42+
pnpm build
4643
pnpm migrations migrate
4744

4845
# Add git aliases

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ jobs:
135135
const results = [
136136
await assertSize('./fixtures/ssg/dist/client', 352),
137137
await assertSize('./fixtures/react-router-netlify/build/client', 360),
138-
await assertSize('./fixtures/webstudio-features/build/client', 926),
138+
await assertSize('./fixtures/webstudio-features/build/client', 932),
139139
]
140140
for (const result of results) {
141141
if (result.passed) {

@types/scroll-timeline.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare class ScrollTimeline extends AnimationTimeline {
1212
interface ViewTimelineOptions {
1313
subject?: Element | Document | null;
1414
axis?: ScrollAxis;
15+
inset?: string;
1516
}
1617

1718
declare class ViewTimeline extends ScrollTimeline {

apps/builder/app/builder/features/components/components.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,15 @@ const $metas = computed(
5959
const availableComponents = new Set<string>();
6060
const metas: Meta[] = [];
6161
for (const [name, componentMeta] of componentMetas) {
62+
if (
63+
isFeatureEnabled("animation") === false &&
64+
name.endsWith(":AnimateChildren")
65+
) {
66+
continue;
67+
}
68+
6269
// only set available components from component meta
6370
availableComponents.add(name);
64-
6571
if (
6672
isFeatureEnabled("headSlotComponent") === false &&
6773
name === "HeadSlot"

apps/builder/app/builder/features/pages/page-settings.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1631,7 +1631,16 @@ export const PageSettings = ({
16311631
onDuplicate={() => {
16321632
const newPageId = duplicatePage(pageId);
16331633
if (newPageId !== undefined) {
1634-
onDuplicate(newPageId);
1634+
// In `canvas.tsx`, within `subscribeStyles`, we use `requestAnimationFrame` (RAF) for style recalculation.
1635+
// After `duplicatePage`, styles are not yet recalculated.
1636+
// To ensure they are properly updated, we use double RAF.
1637+
requestAnimationFrame(() => {
1638+
// At this tick styles are updating
1639+
requestAnimationFrame(() => {
1640+
// At this tick styles are updated
1641+
onDuplicate(newPageId);
1642+
});
1643+
});
16351644
}
16361645
}}
16371646
>

apps/builder/app/builder/features/settings-panel/controls/combined.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@ export const renderControl = ({
216216
);
217217
}
218218

219+
if (prop.type === "animationAction") {
220+
throw new Error(
221+
`Cannot render a fallback control for prop "${rest.propName}" with type animationAction`
222+
);
223+
}
224+
219225
prop satisfies never;
220226
}
221227

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { parseCss } from "@webstudio-is/css-data";
2+
import { StyleValue, toValue } from "@webstudio-is/css-engine";
3+
import {
4+
Text,
5+
Grid,
6+
IconButton,
7+
Label,
8+
Separator,
9+
Tooltip,
10+
} from "@webstudio-is/design-system";
11+
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
12+
import type { AnimationKeyframe } from "@webstudio-is/sdk";
13+
import { Fragment, useMemo, useState } from "react";
14+
import {
15+
CssValueInput,
16+
type IntermediateStyleValue,
17+
} from "~/builder/features/style-panel/shared/css-value-input";
18+
import { toKebabCase } from "~/builder/features/style-panel/shared/keyword-utils";
19+
import { CodeEditor } from "~/builder/shared/code-editor";
20+
import { useIds } from "~/shared/form-utils";
21+
22+
const unitOptions = [
23+
{
24+
id: "%" as const,
25+
label: "%",
26+
type: "unit" as const,
27+
},
28+
];
29+
30+
const OffsetInput = ({
31+
id,
32+
value,
33+
onChange,
34+
}: {
35+
id: string;
36+
value: number | undefined;
37+
onChange: (value: number | undefined) => void;
38+
}) => {
39+
const [intermediateValue, setIntermediateValue] = useState<
40+
StyleValue | IntermediateStyleValue
41+
>();
42+
43+
return (
44+
<CssValueInput
45+
id={id}
46+
placeholder="auto"
47+
getOptions={() => []}
48+
unitOptions={unitOptions}
49+
intermediateValue={intermediateValue}
50+
styleSource="default"
51+
/* same as offset has 0 - 100% */
52+
property={"fontStretch"}
53+
value={
54+
value !== undefined
55+
? {
56+
type: "unit",
57+
value: Math.round(value * 1000) / 10,
58+
unit: "%",
59+
}
60+
: undefined
61+
}
62+
onChange={(styleValue) => {
63+
if (styleValue === undefined) {
64+
setIntermediateValue(styleValue);
65+
return;
66+
}
67+
68+
const clampedStyleValue = { ...styleValue };
69+
if (
70+
clampedStyleValue.type === "unit" &&
71+
clampedStyleValue.unit === "%"
72+
) {
73+
clampedStyleValue.value = Math.min(
74+
100,
75+
Math.max(0, clampedStyleValue.value)
76+
);
77+
}
78+
79+
setIntermediateValue(clampedStyleValue);
80+
}}
81+
onHighlight={(_styleValue) => {
82+
/* @todo: think about preview */
83+
}}
84+
onChangeComplete={(event) => {
85+
setIntermediateValue(undefined);
86+
87+
if (event.value.type === "unit" && event.value.unit === "%") {
88+
onChange(Math.min(100, Math.max(0, event.value.value)) / 100);
89+
return;
90+
}
91+
92+
setIntermediateValue({
93+
type: "invalid",
94+
value: toValue(event.value),
95+
});
96+
}}
97+
onAbort={() => {
98+
/* @todo: allow to change some ephemeral property to see the result in action */
99+
}}
100+
onReset={() => {
101+
setIntermediateValue(undefined);
102+
onChange(undefined);
103+
}}
104+
/>
105+
);
106+
};
107+
108+
const Keyframe = ({
109+
value,
110+
onChange,
111+
}: {
112+
value: AnimationKeyframe;
113+
onChange: (value: AnimationKeyframe | undefined) => void;
114+
}) => {
115+
const ids = useIds(["offset"]);
116+
117+
const cssProperties = useMemo(() => {
118+
let result = ``;
119+
for (const [property, style] of Object.entries(value.styles)) {
120+
result = `${result}${toKebabCase(property)}: ${toValue(style)};\n`;
121+
}
122+
return result;
123+
}, [value.styles]);
124+
125+
return (
126+
<>
127+
<Grid
128+
gap={1}
129+
align={"center"}
130+
css={{ gridTemplateColumns: "1fr 1fr auto" }}
131+
>
132+
<Label htmlFor={ids.offset}>Offset</Label>
133+
<OffsetInput
134+
id={ids.offset}
135+
value={value.offset}
136+
onChange={(offset) => {
137+
onChange({ ...value, offset });
138+
}}
139+
/>
140+
<Tooltip content="Remove keyframe">
141+
<IconButton onClick={() => onChange(undefined)}>
142+
<MinusIcon />
143+
</IconButton>
144+
</Tooltip>
145+
</Grid>
146+
<Grid>
147+
<CodeEditor
148+
lang="css-properties"
149+
size="keyframe"
150+
value={cssProperties}
151+
onChange={() => {
152+
/* do nothing */
153+
}}
154+
onChangeComplete={(cssText) => {
155+
const parsedStyles = parseCss(`selector{${cssText}}`);
156+
onChange({
157+
...value,
158+
styles: parsedStyles.reduce(
159+
(r, { property, value }) => ({ ...r, [property]: value }),
160+
{}
161+
),
162+
});
163+
}}
164+
/>
165+
</Grid>
166+
</>
167+
);
168+
};
169+
170+
export const Keyframes = ({
171+
value: keyframes,
172+
onChange,
173+
}: {
174+
value: AnimationKeyframe[];
175+
onChange: (value: AnimationKeyframe[]) => void;
176+
}) => {
177+
const ids = useIds(["addKeyframe"]);
178+
179+
return (
180+
<Grid gap={2}>
181+
<Grid gap={1} align={"center"} css={{ gridTemplateColumns: "1fr auto" }}>
182+
<Label htmlFor={ids.addKeyframe}>
183+
<Text variant={"titles"}>Keyframes</Text>
184+
</Label>
185+
<IconButton
186+
id={ids.addKeyframe}
187+
onClick={() =>
188+
onChange([...keyframes, { offset: undefined, styles: {} }])
189+
}
190+
>
191+
<PlusIcon />
192+
</IconButton>
193+
</Grid>
194+
195+
{keyframes.map((value, index) => (
196+
<Fragment key={index}>
197+
<Separator />
198+
<Keyframe
199+
key={index}
200+
value={value}
201+
onChange={(newValue) => {
202+
if (newValue === undefined) {
203+
const newValues = [...keyframes];
204+
newValues.splice(index, 1);
205+
onChange(newValues);
206+
return;
207+
}
208+
209+
const newValues = [...keyframes];
210+
newValues[index] = newValue;
211+
onChange(newValues);
212+
}}
213+
/>
214+
</Fragment>
215+
))}
216+
</Grid>
217+
);
218+
};

0 commit comments

Comments
 (0)