Skip to content

Commit 0daf6bf

Browse files
authored
experimental: split keyframe styles into popover (#5126)
We want to add a few predefined fields in every keyframe. Here we split each keyframe into separate popover to avoid overloading the ui. <img width="404" alt="Screenshot 2025-04-16 at 01 53 41" src="https://github.com/user-attachments/assets/21dd7f14-9f51-4624-b190-f57055a6c55c" />
1 parent 1c254ed commit 0daf6bf

2 files changed

Lines changed: 148 additions & 142 deletions

File tree

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

Lines changed: 132 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,24 @@ import {
55
} from "@webstudio-is/css-engine";
66
import {
77
Grid,
8-
IconButton,
98
Label,
10-
Separator,
119
Tooltip,
12-
ScrollArea,
1310
theme,
1411
SectionTitle,
1512
SectionTitleButton,
1613
SectionTitleLabel,
14+
FloatingPanel,
15+
CssValueListItem,
16+
SmallIconButton,
17+
CssValueListArrowFocus,
1718
} from "@webstudio-is/design-system";
1819
import { MinusIcon, PlusIcon } from "@webstudio-is/icons";
1920
import type { AnimationKeyframe } from "@webstudio-is/sdk";
20-
import { Fragment, useMemo, useRef, useState } from "react";
21+
import { Fragment, useId, useMemo, useRef, useState } from "react";
2122
import {
2223
CssValueInput,
2324
type IntermediateStyleValue,
2425
} from "~/builder/features/style-panel/shared/css-value-input";
25-
import { useIds } from "~/shared/form-utils";
2626
import { calcOffsets, findInsertionIndex, moveItem } from "./keyframe-helpers";
2727
import { CssEditor } from "~/builder/shared/css-editor";
2828
import type { ComputedStyleDecl } from "~/shared/style-object-model";
@@ -35,6 +35,8 @@ const unitOptions = [
3535
},
3636
];
3737

38+
const roundOffset = (value: number) => Math.round(value * 1000) / 10;
39+
3840
const OffsetInput = ({
3941
id,
4042
value,
@@ -54,9 +56,7 @@ const OffsetInput = ({
5456
<CssValueInput
5557
id={id}
5658
placeholder={
57-
value === undefined
58-
? `auto (${Math.round(placeholder * 1000) / 10}%)`
59-
: "auto"
59+
value === undefined ? `auto (${roundOffset(placeholder)}%)` : "auto"
6060
}
6161
getOptions={() => []}
6262
unitOptions={unitOptions}
@@ -66,11 +66,7 @@ const OffsetInput = ({
6666
property={"font-stretch"}
6767
value={
6868
value !== undefined
69-
? {
70-
type: "unit",
71-
value: Math.round(value * 1000) / 10,
72-
unit: "%",
73-
}
69+
? { type: "unit", value: roundOffset(value), unit: "%" }
7470
: undefined
7571
}
7672
onChange={(styleValue) => {
@@ -121,91 +117,113 @@ const OffsetInput = ({
121117

122118
const Keyframe = ({
123119
value,
120+
index,
124121
offsetPlaceholder,
125122
onChange,
123+
onDelete,
126124
}: {
127125
value: AnimationKeyframe;
126+
index: number;
128127
offsetPlaceholder: number;
129-
onChange: (
130-
value: AnimationKeyframe | undefined,
131-
isEphemeral: boolean
132-
) => void;
128+
onChange: (value: AnimationKeyframe, isEphemeral: boolean) => void;
129+
onDelete: () => void;
133130
}) => {
134-
const ids = useIds(["offset"]);
135-
const declarations: Array<ComputedStyleDecl> = useMemo(
131+
const offsetId = useId();
132+
const declarations = useMemo(
136133
() =>
137-
(Object.keys(value.styles) as Array<CssProperty>).map((property) => {
134+
(Object.keys(value.styles) as CssProperty[]).map((property) => {
138135
const styleValue = value.styles[property];
139136
return {
140137
property,
141138
source: { name: "local" },
142139
cascadedValue: styleValue,
143140
computedValue: styleValue,
144141
usedValue: styleValue,
145-
};
142+
} satisfies ComputedStyleDecl;
146143
}),
147144
[value.styles]
148145
);
149146

150147
return (
151-
<>
152-
<Grid
153-
gap={1}
154-
align={"center"}
155-
css={{
156-
gridTemplateColumns: "1fr 1fr auto",
157-
paddingInline: theme.panel.paddingInline,
158-
}}
159-
>
160-
<Label htmlFor={ids.offset}>Offset</Label>
161-
<OffsetInput
162-
id={ids.offset}
163-
value={value.offset}
164-
placeholder={offsetPlaceholder}
165-
onChange={(offset) => {
166-
onChange({ ...value, offset }, false);
167-
}}
168-
/>
169-
<Tooltip content="Remove keyframe">
170-
<IconButton onClick={() => onChange(undefined, false)}>
171-
<MinusIcon />
172-
</IconButton>
173-
</Tooltip>
174-
</Grid>
175-
<Grid>
176-
<CssEditor
177-
showSearch={false}
178-
showAddStyleInput
179-
propertiesPosition="top"
180-
virtualize={false}
181-
declarations={declarations}
182-
onAddDeclarations={(addedStyleMap) => {
183-
const styles = { ...value.styles };
184-
for (const [property, value] of addedStyleMap) {
185-
styles[property] = value;
186-
}
187-
onChange({ ...value, styles }, false);
188-
}}
189-
onDeleteProperty={(property, options = {}) => {
190-
if (options.isEphemeral === true) {
191-
return;
192-
}
193-
const styles = { ...value.styles };
194-
delete styles[property];
195-
onChange({ ...value, styles }, false);
196-
}}
197-
onSetProperty={(property) => {
198-
return (newValue, options) => {
199-
const styles = { ...value.styles, [property]: newValue };
200-
onChange({ ...value, styles }, options?.isEphemeral ?? false);
201-
};
202-
}}
203-
onDeleteAllDeclarations={() => {
204-
onChange({ ...value, styles: {} }, false);
205-
}}
206-
/>
207-
</Grid>
208-
</>
148+
<FloatingPanel
149+
title="Keyframe"
150+
content={
151+
<Grid css={{ paddingBlock: theme.panel.paddingBlock }}>
152+
<Grid
153+
gap={1}
154+
align="center"
155+
css={{
156+
gridTemplateColumns: `1fr ${theme.spacing[22]}`,
157+
paddingInline: theme.panel.paddingInline,
158+
}}
159+
>
160+
<Label htmlFor={offsetId}>Offset</Label>
161+
<OffsetInput
162+
id={offsetId}
163+
value={value.offset}
164+
placeholder={offsetPlaceholder}
165+
onChange={(offset) => {
166+
onChange({ ...value, offset }, false);
167+
}}
168+
/>
169+
</Grid>
170+
171+
<CssEditor
172+
showSearch={false}
173+
showAddStyleInput
174+
propertiesPosition="top"
175+
virtualize={false}
176+
declarations={declarations}
177+
onAddDeclarations={(addedStyleMap) => {
178+
const styles = { ...value.styles };
179+
for (const [property, value] of addedStyleMap) {
180+
styles[property] = value;
181+
}
182+
onChange({ ...value, styles }, false);
183+
}}
184+
onDeleteProperty={(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+
onSetProperty={(property) => {
193+
return (newValue, options) => {
194+
const styles = { ...value.styles, [property]: newValue };
195+
onChange({ ...value, styles }, options?.isEphemeral ?? false);
196+
};
197+
}}
198+
onDeleteAllDeclarations={() => {
199+
onChange({ ...value, styles: {} }, false);
200+
}}
201+
/>
202+
</Grid>
203+
}
204+
>
205+
<CssValueListItem
206+
id={offsetPlaceholder.toString()}
207+
index={index}
208+
label={
209+
<Label truncate>
210+
{value.offset
211+
? `${roundOffset(value.offset)}%`
212+
: `auto (${roundOffset(offsetPlaceholder)}%)`}
213+
</Label>
214+
}
215+
buttons={
216+
<Tooltip content="Remove keyframe">
217+
<SmallIconButton
218+
variant="destructive"
219+
tabIndex={-1}
220+
icon={<MinusIcon />}
221+
onClick={onDelete}
222+
/>
223+
</Tooltip>
224+
}
225+
></CssValueListItem>
226+
</FloatingPanel>
209227
);
210228
};
211229

@@ -232,7 +250,7 @@ export const Keyframes = ({
232250
const offsets = calcOffsets(keyframes);
233251

234252
return (
235-
<Grid gap={1}>
253+
<div>
236254
<SectionTitle
237255
collapsible={false}
238256
suffix={
@@ -251,52 +269,42 @@ export const Keyframes = ({
251269
>
252270
<SectionTitleLabel>Keyframes</SectionTitleLabel>
253271
</SectionTitle>
254-
<ScrollArea>
255-
<Grid gap={2}>
256-
{keyframes.map((value, index) => (
257-
<Fragment key={keyRefs.current[index]}>
258-
{index > 0 && <Separator />}
259-
<Keyframe
260-
key={keyRefs.current[index]}
261-
value={value}
262-
offsetPlaceholder={offsets[index]}
263-
onChange={(newValue, isEphemeral) => {
264-
if (newValue === undefined && isEphemeral) {
265-
onChange(undefined, true);
266-
return;
267-
}
268-
269-
if (newValue === undefined) {
270-
const newValues = [...keyframes];
271-
newValues.splice(index, 1);
272-
onChange(newValues, isEphemeral);
273-
return;
274-
}
272+
<CssValueListArrowFocus>
273+
{keyframes.map((value, index) => (
274+
<Fragment key={keyRefs.current[index]}>
275+
<Keyframe
276+
value={value}
277+
index={index}
278+
offsetPlaceholder={offsets[index]}
279+
onChange={(newValue, isEphemeral) => {
280+
let newValues = [...keyframes];
281+
newValues[index] = newValue;
275282

276-
let newValues = [...keyframes];
277-
newValues[index] = newValue;
278-
279-
const { offset } = newValue;
280-
if (offset === undefined) {
281-
onChange(newValues, isEphemeral);
282-
return;
283-
}
283+
const { offset } = newValue;
284+
if (offset === undefined) {
285+
onChange(newValues, isEphemeral);
286+
return;
287+
}
284288

285-
const insertionIndex = findInsertionIndex(newValues, index);
286-
newValues = moveItem(newValues, index, insertionIndex);
287-
keyRefs.current = moveItem(
288-
keyRefs.current,
289-
index,
290-
insertionIndex
291-
);
289+
const insertionIndex = findInsertionIndex(newValues, index);
290+
newValues = moveItem(newValues, index, insertionIndex);
291+
keyRefs.current = moveItem(
292+
keyRefs.current,
293+
index,
294+
insertionIndex
295+
);
292296

293-
onChange(newValues, isEphemeral);
294-
}}
295-
/>
296-
</Fragment>
297-
))}
298-
</Grid>
299-
</ScrollArea>
300-
</Grid>
297+
onChange(newValues, isEphemeral);
298+
}}
299+
onDelete={() => {
300+
const newValues = [...keyframes];
301+
newValues.splice(index, 1);
302+
onChange(newValues, false);
303+
}}
304+
/>
305+
</Fragment>
306+
))}
307+
</CssValueListArrowFocus>
308+
</div>
301309
);
302310
};

0 commit comments

Comments
 (0)