Skip to content

Commit f05eee7

Browse files
committed
experimental: Ranges animation control
1 parent acbb038 commit f05eee7

File tree

4 files changed

+272
-0
lines changed

4 files changed

+272
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { AnimationRanges } from "./animation-ranges";
3+
import type { ViewRangeOptionsSchema } from "@webstudio-is/sdk";
4+
import { Grid } from "@webstudio-is/design-system";
5+
6+
// ----- Type definitions -----
7+
8+
const RANGE_TYPES = [
9+
"contain",
10+
"cover",
11+
"entry",
12+
"exit",
13+
"entry-crossing",
14+
"exit-crossing",
15+
] as const;
16+
type RangeType = (typeof RANGE_TYPES)[number];
17+
18+
type StorybookArgs = {
19+
rangeStartType: RangeType;
20+
rangeStartValue: number;
21+
rangeEndType: RangeType;
22+
rangeEndValue: number;
23+
};
24+
25+
// ----- Storybook config -----
26+
27+
const meta: Meta<(args: StorybookArgs) => React.ReactNode> = {
28+
title: "Components/AnimationRanges",
29+
argTypes: {
30+
rangeStartType: {
31+
name: "rangeStart[0]",
32+
control: { type: "select" },
33+
options: RANGE_TYPES,
34+
defaultValue: "entry",
35+
},
36+
rangeStartValue: {
37+
name: "rangeStart[1] (% value)",
38+
control: { type: "range", min: -100, max: 100, step: 1 },
39+
defaultValue: 0,
40+
},
41+
rangeEndType: {
42+
name: "rangeEnd[0]",
43+
control: { type: "select" },
44+
options: RANGE_TYPES,
45+
defaultValue: "exit",
46+
},
47+
rangeEndValue: {
48+
name: "rangeEnd[1] (% value)",
49+
control: { type: "range", min: -100, max: 100, step: 1 },
50+
defaultValue: 100,
51+
},
52+
},
53+
args: {
54+
rangeStartType: "entry",
55+
rangeStartValue: 0,
56+
rangeEndType: "exit",
57+
rangeEndValue: 100,
58+
} satisfies StorybookArgs,
59+
};
60+
export default meta;
61+
62+
// ----- Utility to map SB args to component props -----
63+
64+
const getComponentArgs = (args: StorybookArgs): ViewRangeOptionsSchema => {
65+
return {
66+
rangeStart: [
67+
args.rangeStartType,
68+
{ type: "unit", unit: "%", value: args.rangeStartValue },
69+
],
70+
rangeEnd: [
71+
args.rangeEndType,
72+
{ type: "unit", unit: "%", value: args.rangeEndValue },
73+
],
74+
};
75+
};
76+
77+
// ----- Story -----
78+
79+
type Story = StoryObj<{
80+
// flatten for controls, type-safe
81+
rangeStartType: RangeType;
82+
rangeStartValue: number;
83+
rangeEndType: RangeType;
84+
rangeEndValue: number;
85+
}>;
86+
87+
export const Basic: Story = {
88+
render: (args) => (
89+
<Grid
90+
css={{
91+
width: 400,
92+
height: 240,
93+
gridTemplateColumns: "40px 60px 80px",
94+
gap: 20,
95+
}}
96+
>
97+
<AnimationRanges {...getComponentArgs(args)} />
98+
<AnimationRanges {...getComponentArgs(args)} />
99+
<AnimationRanges {...getComponentArgs(args)} />
100+
</Grid>
101+
),
102+
};
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { toValue } from "@webstudio-is/css-engine";
2+
import { Box, keyframes, styled, theme } from "@webstudio-is/design-system";
3+
import type { ViewRangeOptionsSchema } from "@webstudio-is/sdk";
4+
import { useEffect, useRef } from "react";
5+
6+
type AnimationRangesProps = ViewRangeOptionsSchema;
7+
8+
const insetSize = "60px";
9+
const borderColor = "#ccc";
10+
11+
const Wrapper = styled(Box, {
12+
position: "relative",
13+
alignSelf: "stretch",
14+
// borderRight: `1px solid ${borderColor}`,
15+
// borderLeft: `1px solid ${borderColor}`,
16+
});
17+
18+
const InsetLine = styled(Box, {
19+
position: "absolute",
20+
left: 0,
21+
right: 0,
22+
height: insetSize,
23+
backgroundColor: "rgba(255, 255, 255, 0.4)",
24+
});
25+
26+
const Scrollable = styled(Box, {
27+
position: "absolute",
28+
inset: 0,
29+
overflowY: "scroll",
30+
31+
"&::-webkit-scrollbar": {
32+
display: "none",
33+
},
34+
});
35+
36+
const bgKeyframes = keyframes({
37+
"0%": { backgroundColor: "oklch(54.6% 0.245 262.881)" },
38+
"100%": { backgroundColor: "oklch(58.6% 0.253 17.585)" },
39+
});
40+
41+
const rafInterval = (callback: () => void): (() => void) => {
42+
let frameId: number;
43+
44+
const loop = () => {
45+
callback();
46+
frameId = requestAnimationFrame(loop);
47+
};
48+
49+
frameId = requestAnimationFrame(loop);
50+
51+
return () => {
52+
cancelAnimationFrame(frameId);
53+
};
54+
};
55+
56+
export const AnimationRanges = (props: AnimationRangesProps) => {
57+
const scrollableRef = useRef<HTMLDivElement>(null);
58+
const subjectRef = useRef<HTMLDivElement>(null);
59+
60+
useEffect(() => {
61+
const scrollable = scrollableRef.current;
62+
const subject = subjectRef.current;
63+
64+
if (scrollable == null) {
65+
return;
66+
}
67+
68+
if (subject == null) {
69+
return;
70+
}
71+
72+
const scrollHeight = scrollable.scrollHeight;
73+
const clientHeight = scrollable.clientHeight;
74+
75+
const startTime = Date.now();
76+
77+
return rafInterval(() => {
78+
const timeDelta = Date.now() - startTime;
79+
80+
scrollable.scrollTop =
81+
(Math.sin(timeDelta / 500) * (scrollHeight - clientHeight)) / 2 +
82+
(scrollHeight - clientHeight) / 2;
83+
84+
const progress =
85+
subject.getAnimations()?.[0]?.effect?.getComputedTiming()?.progress ??
86+
0;
87+
88+
const content = `${(progress * 100).toFixed(0)}%`;
89+
90+
if (subject.textContent !== content) {
91+
subject.textContent = `${(progress * 100).toFixed(0)}%`;
92+
}
93+
});
94+
}, []);
95+
96+
const rangeStart = props.rangeStart ?? [
97+
"cover",
98+
{
99+
type: "unit",
100+
value: 0,
101+
unit: "%",
102+
},
103+
];
104+
const rangeEnd = props.rangeEnd ?? [
105+
"cover",
106+
{
107+
type: "unit",
108+
value: 100,
109+
unit: "%",
110+
},
111+
];
112+
113+
return (
114+
<Wrapper>
115+
<Scrollable ref={scrollableRef}>
116+
<Box
117+
css={{
118+
height: `calc(100% - ${insetSize})`,
119+
}}
120+
/>
121+
<Box
122+
ref={subjectRef}
123+
css={{
124+
height: insetSize,
125+
marginLeft: 4,
126+
marginRight: 4,
127+
// border: `1px solid ${borderColor}`,
128+
129+
color: "white",
130+
fontSize: "12px",
131+
display: "flex",
132+
alignItems: "center",
133+
justifyContent: "center",
134+
whiteSpace: "pre",
135+
textAlign: "center",
136+
fontFamily: theme.fonts.robotoMono,
137+
138+
animationName: `${bgKeyframes}`,
139+
animationTimingFunction: "linear",
140+
animationFillMode: "both",
141+
142+
animationRangeStart: `${rangeStart[0]} ${toValue(rangeStart[1])}`,
143+
animationRangeEnd: `${rangeEnd[0]} ${toValue(rangeEnd[1])}`,
144+
animationTimeline: `view(y ${insetSize})`,
145+
}}
146+
/>
147+
<Box
148+
css={{
149+
height: `calc(100% - ${insetSize})`,
150+
}}
151+
/>
152+
</Scrollable>
153+
<InsetLine
154+
// @ts-expect-error inert
155+
inert={""}
156+
css={{
157+
top: 0,
158+
borderBottom: `1px solid ${borderColor}`,
159+
}}
160+
/>
161+
<InsetLine
162+
// @ts-expect-error inert
163+
inert={""}
164+
css={{ bottom: 0, borderTop: `1px solid ${borderColor}` }}
165+
/>
166+
</Wrapper>
167+
);
168+
};

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type {
4141
InsetUnitValue,
4242
DurationUnitValue,
4343
TimeUnit,
44+
ViewRangeOptionsSchema,
4445
} from "./schema/animation-schema";
4546

4647
export {

packages/sdk/src/schema/animation-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,4 @@ export type AnimationAction = z.infer<typeof animationActionSchema>;
248248
export type ScrollAnimation = z.infer<typeof scrollAnimationSchema>;
249249
export type ViewAnimation = z.infer<typeof viewAnimationSchema>;
250250
export type InsetUnitValue = z.infer<typeof insetUnitValueSchema>;
251+
export type ViewRangeOptionsSchema = z.infer<typeof viewRangeOptionsSchema>;

0 commit comments

Comments
 (0)