1export const PAGE_SCROLL_ANIMATION_MS = 150;
2export const PAGE_SCROLL_ACCELERATION_MS = 400;
3export const PAGE_SCROLL_MAX_MULTIPLIER = 2;
4
5const PAGE_SCROLL_ALIGNMENT_OFFSET_PX = 36;
6const PAGE_SCROLL_BOUNDARY_EPSILON_PX = 1;
7const PAGE_SCROLL_HOLD_DELAY_MS = PAGE_SCROLL_ANIMATION_MS;
8
9export type PageScrollKey = "PageUp" | "PageDown";
10
11type PageScrollMetrics = {
12 clientHeight: number;
13 scrollHeight: number;
14 scrollTop: number;
15};
16
17function canScrollInDirection(
18 { clientHeight, scrollHeight, scrollTop }: PageScrollMetrics,
19 key: PageScrollKey,
20): boolean {
21 const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
22 const clampedScrollTop = Math.min(maxScrollTop, Math.max(0, scrollTop));
23 return key === "PageUp"
24 ? clampedScrollTop > PAGE_SCROLL_BOUNDARY_EPSILON_PX
25 : clampedScrollTop < maxScrollTop - PAGE_SCROLL_BOUNDARY_EPSILON_PX;
26}
27
28export function getTimelinePageScrollKey({
29 altKey,
30 clientHeight,
31 ctrlKey,
32 defaultPrevented,
33 isComposing,
34 key,
35 keyCode,
36 metaKey,
37 scrollHeight,
38 scrollTop,
39 shiftKey,
40}: {
41 altKey: boolean;
42 clientHeight: number;
43 ctrlKey: boolean;
44 defaultPrevented: boolean;
45 isComposing: boolean;
46 key: string;
47 keyCode: number;
48 metaKey: boolean;
49 scrollHeight: number;
50 scrollTop: number;
51 shiftKey: boolean;
52}): PageScrollKey | null {
53 if (key !== "PageUp" && key !== "PageDown") {
54 return null;
55 }
56 if (
57 defaultPrevented ||
58 isComposing ||
59 keyCode === 229 ||
60 altKey ||
61 ctrlKey ||
62 metaKey ||
63 shiftKey
64 ) {
65 return null;
66 }
67
68 const editorCanScroll = canScrollInDirection({ clientHeight, scrollHeight, scrollTop }, key);
69 return editorCanScroll ? null : key;
70}
71
72type PageScrollContainer = PageScrollMetrics & {
73 getBoundingClientRect: () => {
74 height: number;
75 };
76};
77
78type PageScrollEnv = {
79 now: () => number;
80 requestAnimationFrame: (callback: FrameRequestCallback) => number;
81 cancelAnimationFrame: (handle: number) => void;
82 setTimeout: (callback: () => void, delay: number) => number;
83 clearTimeout: (handle: number) => void;
84};
85
86function getDefaultEnv(): PageScrollEnv {
87 return {
88 now: () => performance.now(),
89 requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
90 cancelAnimationFrame: (handle) => window.cancelAnimationFrame(handle),
91 setTimeout: (callback, delay) => window.setTimeout(callback, delay),
92 clearTimeout: (handle) => window.clearTimeout(handle),
93 };
94}
95
96export function getPageScrollMultiplier(holdElapsedMs: number): number {
97 const progress = Math.max(0, holdElapsedMs) / PAGE_SCROLL_ACCELERATION_MS;
98 return 1 + Math.min(progress, 1) * (PAGE_SCROLL_MAX_MULTIPLIER - 1);
99}
100
101export function getPageScrollVelocityPxPerMs({
102 holdElapsedMs,
103 pageScrollDistancePx,
104}: {
105 holdElapsedMs: number;
106 pageScrollDistancePx: number;
107}): number {
108 return (pageScrollDistancePx * getPageScrollMultiplier(holdElapsedMs)) / PAGE_SCROLL_ANIMATION_MS;
109}
110
111export function getPageScrollDistancePx({
112 containerHeightPx,
113 scrollPaddingBottomPx,
114}: {
115 containerHeightPx: number;
116 scrollPaddingBottomPx: number;
117}): number {
118 return Math.max(0, containerHeightPx - PAGE_SCROLL_ALIGNMENT_OFFSET_PX - scrollPaddingBottomPx);
119}
120
121function getDirection(key: PageScrollKey): -1 | 1 {
122 return key === "PageUp" ? -1 : 1;
123}
124
125function easeInOut(progress: number): number {
126 const ax = 3 * 0.42 - 3 * 0.58 + 1;
127 const bx = 3 * (0.58 - 2 * 0.42);
128 const cx = 3 * 0.42;
129 const ay = -2;
130 const by = 3;
131 const x = (value: number) => ((ax * value + bx) * value + cx) * value;
132 const y = (value: number) => (ay * value + by) * value * value;
133
134 let value = progress;
135 for (let index = 0; index < 5; index += 1) {
136 const delta = x(value) - progress;
137 const derivative = (3 * ax * value + 2 * bx) * value + cx;
138 if (Math.abs(delta) < 1e-4 || derivative === 0) {
139 break;
140 }
141 value -= delta / derivative;
142 value = Math.min(1, Math.max(0, value));
143 }
144
145 return y(value);
146}
147
148export function createPageScrollController({
149 getContainer,
150 getScrollPaddingBottomPx,
151 onScrollStart,
152 env = getDefaultEnv(),
153}: {
154 getContainer: () => PageScrollContainer | null;
155 getScrollPaddingBottomPx: () => number;
156 onScrollStart?: (key: PageScrollKey) => void;
157 env?: PageScrollEnv;
158}) {
159 const state = {
160 activeKey: null as PageScrollKey | null,
161 discreteAnimationFrame: 0,
162 holdDelayTimeout: 0,
163 holdAnimationFrame: 0,
164 holdStartTime: 0,
165 lastFrameTime: 0,
166 holdActive: false,
167 };
168
169 const readPageScrollDistance = (container: PageScrollContainer) =>
170 getPageScrollDistancePx({
171 containerHeightPx: container.getBoundingClientRect().height,
172 scrollPaddingBottomPx: getScrollPaddingBottomPx(),
173 });
174
175 const cancelDiscreteAnimation = () => {
176 if (state.discreteAnimationFrame === 0) {
177 return;
178 }
179
180 env.cancelAnimationFrame(state.discreteAnimationFrame);
181 state.discreteAnimationFrame = 0;
182 };
183
184 const stop = ({
185 cancelDiscreteAnimation: shouldCancelDiscreteAnimation,
186 }: {
187 cancelDiscreteAnimation: boolean;
188 }) => {
189 if (state.holdDelayTimeout !== 0) {
190 env.clearTimeout(state.holdDelayTimeout);
191 state.holdDelayTimeout = 0;
192 }
193
194 if (state.holdAnimationFrame !== 0) {
195 env.cancelAnimationFrame(state.holdAnimationFrame);
196 state.holdAnimationFrame = 0;
197 }
198
199 if (shouldCancelDiscreteAnimation) {
200 cancelDiscreteAnimation();
201 }
202
203 state.activeKey = null;
204 state.holdStartTime = 0;
205 state.lastFrameTime = 0;
206 state.holdActive = false;
207 };
208
209 const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => {
210 cancelDiscreteAnimation();
211
212 const startScrollTop = container.scrollTop;
213 const startTime = env.now();
214
215 const step = (now: number) => {
216 const progress = Math.min(1, (now - startTime) / PAGE_SCROLL_ANIMATION_MS);
217 container.scrollTop = startScrollTop + deltaY * easeInOut(progress);
218
219 if (progress < 1) {
220 state.discreteAnimationFrame = env.requestAnimationFrame(step);
221 return;
222 }
223
224 state.discreteAnimationFrame = 0;
225 };
226
227 state.discreteAnimationFrame = env.requestAnimationFrame(step);
228 };
229
230 const startHoldScroll = (key: PageScrollKey, container: PageScrollContainer) => {
231 cancelDiscreteAnimation();
232 state.holdActive = true;
233 state.holdStartTime = env.now();
234 state.lastFrameTime = state.holdStartTime;
235
236 const step = (now: number) => {
237 if (state.activeKey !== key) {
238 state.holdAnimationFrame = 0;
239 return;
240 }
241
242 const deltaMs = now - state.lastFrameTime;
243 state.lastFrameTime = now;
244
245 const velocityPxPerMs = getPageScrollVelocityPxPerMs({
246 holdElapsedMs: now - state.holdStartTime,
247 pageScrollDistancePx: readPageScrollDistance(container),
248 });
249 const previousScrollTop = container.scrollTop;
250 container.scrollTop = previousScrollTop + velocityPxPerMs * deltaMs * getDirection(key);
251
252 if (deltaMs > 0 && container.scrollTop === previousScrollTop) {
253 stop({ cancelDiscreteAnimation: true });
254 return;
255 }
256
257 state.holdAnimationFrame = env.requestAnimationFrame(step);
258 };
259
260 state.holdAnimationFrame = env.requestAnimationFrame(step);
261 };
262
263 return {
264 handleKeyDown(key: PageScrollKey) {
265 const container = getContainer();
266 if (!container) {
267 return;
268 }
269
270 if (!canScrollInDirection(container, key)) {
271 return;
272 }
273
274 if (state.activeKey === key) {
275 return;
276 }
277
278 stop({ cancelDiscreteAnimation: true });
279 state.activeKey = key;
280 onScrollStart?.(key);
281
282 smoothScrollBy(container, readPageScrollDistance(container) * getDirection(key));
283
284 state.holdDelayTimeout = env.setTimeout(() => {
285 state.holdDelayTimeout = 0;
286 if (state.activeKey !== key) {
287 return;
288 }
289
290 startHoldScroll(key, container);
291 }, PAGE_SCROLL_HOLD_DELAY_MS);
292 },
293 handleKeyUp(key: string) {
294 if (state.activeKey !== key) {
295 return;
296 }
297
298 stop({ cancelDiscreteAnimation: state.holdActive });
299 },
300 releaseActiveKey() {
301 stop({ cancelDiscreteAnimation: state.holdActive });
302 },
303 dispose() {
304 stop({ cancelDiscreteAnimation: true });
305 },
306 };
307}