Skip to content

Commit 23e4318

Browse files
authored
experimental: add keyframe transforms (#5128)
Here added UI for transform and opacity properties to keyframe configuration. <img width="330" alt="Screenshot 2025-04-16 at 19 42 29" src="https://github.com/user-attachments/assets/9a122d26-c203-4a9b-8e13-44d2eb8b7a23" />
1 parent 0daf6bf commit 23e4318

2 files changed

Lines changed: 342 additions & 12 deletions

File tree

apps/builder/app/builder/features/settings-panel/props-section/animation/animation-keyframes.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Fragment, useId, useMemo, useRef, useState } from "react";
12
import {
23
StyleValue,
34
toValue,
@@ -18,14 +19,17 @@ import {
1819
} from "@webstudio-is/design-system";
1920
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
2021
import type { AnimationKeyframe } from "@webstudio-is/sdk";
21-
import { Fragment, useId, useMemo, useRef, useState } from "react";
2222
import {
2323
CssValueInput,
2424
type IntermediateStyleValue,
2525
} from "~/builder/features/style-panel/shared/css-value-input";
26-
import { calcOffsets, findInsertionIndex, moveItem } from "./keyframe-helpers";
2726
import { CssEditor } from "~/builder/shared/css-editor";
2827
import type { ComputedStyleDecl } from "~/shared/style-object-model";
28+
import { calcOffsets, findInsertionIndex, moveItem } from "./keyframe-helpers";
29+
import {
30+
AnimationTransforms,
31+
transformProperties,
32+
} from "./animation-transforms";
2933

3034
const unitOptions = [
3135
{
@@ -131,16 +135,19 @@ const Keyframe = ({
131135
const offsetId = useId();
132136
const declarations = useMemo(
133137
() =>
134-
(Object.keys(value.styles) as CssProperty[]).map((property) => {
135-
const styleValue = value.styles[property];
136-
return {
137-
property,
138-
source: { name: "local" },
139-
cascadedValue: styleValue,
140-
computedValue: styleValue,
141-
usedValue: styleValue,
142-
} satisfies ComputedStyleDecl;
143-
}),
138+
(Object.keys(value.styles) as CssProperty[])
139+
// avoid duplicating transform properties in css editor
140+
.filter((property) => !transformProperties.includes(property))
141+
.map((property) => {
142+
const styleValue = value.styles[property];
143+
return {
144+
property,
145+
source: { name: "local" },
146+
cascadedValue: styleValue,
147+
computedValue: styleValue,
148+
usedValue: styleValue,
149+
} satisfies ComputedStyleDecl;
150+
}),
144151
[value.styles]
145152
);
146153

@@ -168,6 +175,22 @@ const Keyframe = ({
168175
/>
169176
</Grid>
170177

178+
<AnimationTransforms
179+
styles={value.styles}
180+
onUpdate={(property, newValue, options) => {
181+
const styles = { ...value.styles, [property]: newValue };
182+
onChange({ ...value, styles }, options?.isEphemeral ?? false);
183+
}}
184+
onDelete={(property, options) => {
185+
if (options?.isEphemeral === true) {
186+
return;
187+
}
188+
const styles = { ...value.styles };
189+
delete styles[property];
190+
onChange({ ...value, styles }, false);
191+
}}
192+
/>
193+
171194
<CssEditor
172195
showSearch={false}
173196
showAddStyleInput
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { useState } from "react";
2+
import {
3+
EnhancedTooltip,
4+
Grid,
5+
SmallToggleButton,
6+
theme,
7+
} from "@webstudio-is/design-system";
8+
import {
9+
toValue,
10+
type CssProperty,
11+
type StyleValue,
12+
type TupleValue,
13+
} from "@webstudio-is/css-engine";
14+
import {
15+
Link2Icon,
16+
Link2UnlinkedIcon,
17+
XAxisIcon,
18+
YAxisIcon,
19+
ZAxisRotateIcon,
20+
} from "@webstudio-is/icons";
21+
import { CssValueInputContainer } from "~/builder/features/style-panel/shared/css-value-input";
22+
import { $availableUnitVariables } from "~/builder/features/style-panel/shared/model";
23+
import type { StyleUpdateOptions } from "~/builder/features/style-panel/shared/use-style-data";
24+
import { FieldLabel } from "../../property-label";
25+
26+
const isTupleItem = (value: StyleValue): value is TupleValue["value"][number] =>
27+
value.type === "unit" || value.type === "var" || value.type === "unparsed";
28+
29+
export const transformProperties: CssProperty[] = [
30+
"translate",
31+
"scale",
32+
"rotate",
33+
"opacity",
34+
];
35+
36+
// gap linked gap
37+
const CONTROLS_GAP = 4 + 16 + 4;
38+
39+
export const AnimationTransforms = ({
40+
styles,
41+
onUpdate,
42+
onDelete,
43+
}: {
44+
styles: Record<CssProperty, undefined | StyleValue>;
45+
onUpdate: (
46+
property: CssProperty,
47+
value: StyleValue,
48+
options?: StyleUpdateOptions
49+
) => void;
50+
onDelete: (property: CssProperty, options?: StyleUpdateOptions) => void;
51+
}) => {
52+
const [isScaleLinked, setIsScaleLinked] = useState(
53+
styles.scale?.type !== "tuple" ||
54+
toValue(styles.scale.value[0]) === toValue(styles.scale.value[1])
55+
);
56+
let translateX: StyleValue = { type: "unit", value: 0, unit: "px" };
57+
let translateY: StyleValue = { type: "unit", value: 0, unit: "px" };
58+
if (styles.translate?.type === "tuple") {
59+
[translateX, translateY = translateY] = styles.translate.value;
60+
}
61+
let scaleX: StyleValue = { type: "unit", value: 100, unit: "%" };
62+
let scaleY: StyleValue = { type: "unit", value: 100, unit: "%" };
63+
if (styles.scale?.type === "tuple") {
64+
[scaleX, scaleY = scaleY] = styles.scale.value;
65+
}
66+
let rotateZ: StyleValue = { type: "unit", value: 0, unit: "deg" };
67+
if (styles.rotate?.type === "tuple") {
68+
if (isTupleItem(styles.rotate.value[0])) {
69+
rotateZ = styles.rotate.value[0];
70+
}
71+
}
72+
return (
73+
<Grid gap={2} css={{ padding: theme.panel.padding }}>
74+
<Grid gap={1}>
75+
<FieldLabel
76+
resettable={styles.translate !== undefined}
77+
onReset={() => onDelete("translate")}
78+
>
79+
Translate
80+
</FieldLabel>
81+
<Grid columns={2} css={{ gap: CONTROLS_GAP }}>
82+
<CssValueInputContainer
83+
property="translate"
84+
styleSource={styles.translate ? "local" : "default"}
85+
icon={<XAxisIcon />}
86+
getOptions={() => $availableUnitVariables.get()}
87+
value={translateX}
88+
onUpdate={(value, options) => {
89+
if (value.type === "tuple") {
90+
onUpdate(
91+
"translate",
92+
{ type: "tuple", value: [value.value[0], translateY] },
93+
options
94+
);
95+
}
96+
// for scrabbing
97+
if (value.type === "unit") {
98+
onUpdate(
99+
"translate",
100+
{ type: "tuple", value: [value, translateY] },
101+
options
102+
);
103+
}
104+
}}
105+
onDelete={(options) => {
106+
const value = styles.translate ?? {
107+
type: "tuple",
108+
value: [translateX, translateY],
109+
};
110+
onUpdate("translate", value, options);
111+
}}
112+
/>
113+
<CssValueInputContainer
114+
property="translate"
115+
styleSource={styles.translate ? "local" : "default"}
116+
icon={<YAxisIcon />}
117+
getOptions={() => $availableUnitVariables.get()}
118+
value={translateY}
119+
onUpdate={(value, options) => {
120+
if (value.type === "tuple") {
121+
onUpdate(
122+
"translate",
123+
{ type: "tuple", value: [translateX, value.value[0]] },
124+
options
125+
);
126+
}
127+
// for scrabbing
128+
if (value.type === "unit") {
129+
onUpdate(
130+
"translate",
131+
{ type: "tuple", value: [translateX, value] },
132+
options
133+
);
134+
}
135+
}}
136+
onDelete={(options) => {
137+
const value = styles.translate ?? {
138+
type: "tuple",
139+
value: [translateX, translateY],
140+
};
141+
onUpdate("translate", value, options);
142+
}}
143+
/>
144+
</Grid>
145+
</Grid>
146+
147+
<Grid gap={1}>
148+
<FieldLabel
149+
resettable={styles.scale !== undefined}
150+
onReset={() => onDelete("scale")}
151+
>
152+
Scale
153+
</FieldLabel>
154+
<Grid
155+
gap={1}
156+
align="center"
157+
css={{ gridTemplateColumns: "1fr 16px 1fr" }}
158+
>
159+
<CssValueInputContainer
160+
property="scale"
161+
styleSource={styles.scale ? "local" : "default"}
162+
icon={<XAxisIcon />}
163+
getOptions={() => $availableUnitVariables.get()}
164+
value={scaleX}
165+
onUpdate={(value, options) => {
166+
if (value.type === "tuple") {
167+
const newValue: StyleValue = {
168+
type: "tuple",
169+
value: isScaleLinked
170+
? [value.value[0], value.value[0]]
171+
: [value.value[0], scaleY],
172+
};
173+
onUpdate("scale", newValue, options);
174+
}
175+
// for scrabbing
176+
if (value.type === "unit") {
177+
const newValue: StyleValue = {
178+
type: "tuple",
179+
value: isScaleLinked ? [value, value] : [value, scaleY],
180+
};
181+
onUpdate("scale", newValue, options);
182+
}
183+
}}
184+
onDelete={(options) => {
185+
if (isScaleLinked) {
186+
onDelete("scale", options);
187+
} else {
188+
const value = styles.scale ?? {
189+
type: "tuple",
190+
value: [scaleX, scaleY],
191+
};
192+
onUpdate("scale", value, options);
193+
}
194+
}}
195+
/>
196+
<EnhancedTooltip
197+
content={isScaleLinked ? "Unlink scale axes" : "Link scale axes"}
198+
>
199+
<SmallToggleButton
200+
variant="normal"
201+
icon={isScaleLinked ? <Link2Icon /> : <Link2UnlinkedIcon />}
202+
pressed={isScaleLinked}
203+
onPressedChange={(isPressed) => {
204+
setIsScaleLinked(isPressed);
205+
if (isPressed) {
206+
onUpdate("scale", {
207+
type: "tuple",
208+
value: [scaleX, scaleX],
209+
});
210+
}
211+
}}
212+
/>
213+
</EnhancedTooltip>
214+
<CssValueInputContainer
215+
property="scale"
216+
styleSource={styles.scale ? "local" : "default"}
217+
icon={<YAxisIcon />}
218+
getOptions={() => $availableUnitVariables.get()}
219+
value={scaleY}
220+
onUpdate={(value, options) => {
221+
if (value.type === "tuple") {
222+
const newValue: StyleValue = {
223+
type: "tuple",
224+
value: isScaleLinked
225+
? [value.value[0], value.value[0]]
226+
: [scaleX, value.value[0]],
227+
};
228+
onUpdate("scale", newValue, options);
229+
}
230+
// for scrabbing
231+
if (value.type === "unit") {
232+
const newValue: StyleValue = {
233+
type: "tuple",
234+
value: isScaleLinked ? [value, value] : [scaleX, value],
235+
};
236+
onUpdate("scale", newValue, options);
237+
}
238+
}}
239+
onDelete={(options) => {
240+
if (isScaleLinked) {
241+
onDelete("scale", options);
242+
} else {
243+
const value = styles.scale ?? {
244+
type: "tuple",
245+
value: [scaleX, scaleY],
246+
};
247+
onUpdate("scale", value, options);
248+
}
249+
}}
250+
/>
251+
</Grid>
252+
</Grid>
253+
254+
<Grid columns={2} css={{ gap: CONTROLS_GAP }}>
255+
<Grid gap={1}>
256+
<FieldLabel
257+
resettable={styles.rotate !== undefined}
258+
onReset={() => onDelete("rotate")}
259+
>
260+
Rotate
261+
</FieldLabel>
262+
<CssValueInputContainer
263+
property="rotate"
264+
styleSource={styles.rotate ? "local" : "default"}
265+
icon={<ZAxisRotateIcon />}
266+
getOptions={() => $availableUnitVariables.get()}
267+
value={rotateZ}
268+
onUpdate={(value, options) => {
269+
if (value.type === "tuple" && isTupleItem(value.value[0])) {
270+
const [rotateZ] = value.value;
271+
onUpdate(
272+
"rotate",
273+
{ type: "tuple", value: [rotateZ] },
274+
options
275+
);
276+
} else if (isTupleItem(value)) {
277+
// for scrabbing
278+
onUpdate("rotate", { type: "tuple", value: [value] }, options);
279+
} else {
280+
onDelete("rotate", options);
281+
}
282+
}}
283+
onDelete={(options) => onDelete("rotate", options)}
284+
/>
285+
</Grid>
286+
<Grid gap={1}>
287+
<FieldLabel
288+
resettable={styles.opacity !== undefined}
289+
onReset={() => onDelete("opacity")}
290+
>
291+
Opacity
292+
</FieldLabel>
293+
<CssValueInputContainer
294+
property="opacity"
295+
styleSource={styles.opacity ? "local" : "default"}
296+
getOptions={() => $availableUnitVariables.get()}
297+
value={styles.opacity ?? { type: "unit", value: 100, unit: "%" }}
298+
onUpdate={(value, options) => {
299+
onUpdate("opacity", value, options);
300+
}}
301+
onDelete={(options) => onDelete("opacity", options)}
302+
/>
303+
</Grid>
304+
</Grid>
305+
</Grid>
306+
);
307+
};

0 commit comments

Comments
 (0)