History

feat(web): add PageUp/PageDown chat navigation (#9315)

Yash Singh committed 2 months ago

Commit 5f84efa1 parent 1aa44a05 files · +685 −0Browse files

Unified · Split

5 files
  1. apps/web/src/components/ChatView.tsx +37 −0
  2. apps/web/src/components/ComposerPromptEditor.tsx +53 −0
  3. apps/web/src/components/chat/ChatComposer.tsx +9 −0
  4. apps/web/src/components/chat/pageScrollController.test.ts +279 −0
  5. apps/web/src/components/chat/pageScrollController.ts +307 −0
apps/web/src/components/ChatView.tsx+37−0
@@ -63,6 +63,7 @@ import { · 62 unmodified lines
6363 Suspense,
6464 useCallback,
6565 useEffect,
66+ useEffectEvent,
6667 useLayoutEffect,
6768 useMemo,
6869 useRef,
@@ -291,6 +292,7 @@ import { · 222 unmodified lines
291292 } from "../state/entities";
292293 import { environmentShell } from "../state/shell";
293294 import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
295+import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
294296 import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
295297 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
296298 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
@@ -4314,6 +4316,38 @@ function ChatViewContent(props: ChatViewProps) { · 4017 unmodified lines
43144316 },
43154317 [composerOverlayHeight],
43164318 );
4319+ const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(
4320+ null,
4321+ );
4322+ const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => {
4323+ if (key === "PageUp" && timelineRealContentOverflowsViewport()) {
4324+ cancelTimelineLiveFollowForUserNavigation();
4325+ }
4326+ });
4327+ useEffect(() => {
4328+ const controller = createPageScrollController({
4329+ getContainer: () => legendListRef.current?.getScrollableNode() ?? null,
4330+ getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0,
4331+ onScrollStart: handlePageScrollStart,
4332+ });
4333+ pageScrollControllerRef.current = controller;
4334+
4335+ return () => {
4336+ controller.dispose();
4337+ if (pageScrollControllerRef.current === controller) {
4338+ pageScrollControllerRef.current = null;
4339+ }
4340+ };
4341+ }, [composerOverlayElement]);
4342+ const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => {
4343+ pageScrollControllerRef.current?.handleKeyDown(key);
4344+ }, []);
4345+ const onComposerPageScrollKeyUp = useCallback((key: string) => {
4346+ pageScrollControllerRef.current?.handleKeyUp(key);
4347+ }, []);
4348+ const onComposerPageScrollRelease = useCallback(() => {
4349+ pageScrollControllerRef.current?.releaseActiveKey();
4350+ }, []);
43174351 // Live-follow stays active after send/thread-open until an actual list scroll
43184352 // gesture opts out.
43194353 const scrollToEnd = useCallback((animated = false) => {
@@ -7637,6 +7671,9 @@ function ChatViewContent(props: ChatViewProps) { · 3317 unmodified lines
76377671 composerFilesRef={composerFilesRef}
76387672 composerTerminalContextsRef={composerTerminalContextsRef}
76397673 composerElementContextsRef={composerElementContextsRef}
7674+ onPageScrollKeyDown={onComposerPageScrollKeyDown}
7675+ onPageScrollKeyUp={onComposerPageScrollKeyUp}
7676+ onPageScrollRelease={onComposerPageScrollRelease}
76407677 onSend={onSend}
76417678 onInterrupt={onInterrupt}
76427679 onImplementPlanInNewThread={onImplementPlanInNewThread}
apps/web/src/components/ComposerPromptEditor.tsx+53−0
@@ -83,6 +83,7 @@ import { · 82 unmodified lines
8383 } from "./composerInlineChip";
8484 import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
8585 import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
86+import { getTimelinePageScrollKey } from "./chat/pageScrollController";
8687 import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
8788 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
8889 import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
@@ -916,6 +917,9 @@ interface ComposerPromptEditorProps { · 827 unmodified lines
916917 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
917918 event: KeyboardEvent,
918919 ) => boolean;
920+ onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
921+ onPageScrollKeyUp?: (key: string) => void;
922+ onPageScrollRelease?: () => void;
919923 onPaste: React.ClipboardEventHandler<HTMLElement>;
920924 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
921925 }
@@ -1557,6 +1561,9 @@ function ComposerPromptEditorInner({ · 635 unmodified lines
15571561 onRemoveTerminalContext,
15581562 onChange,
15591563 onCommandKeyDown,
1564+ onPageScrollKeyDown,
1565+ onPageScrollKeyUp,
1566+ onPageScrollRelease,
15601567 onPaste,
15611568 editorRef,
15621569 }: ComposerPromptEditorProps) {
@@ -1829,6 +1836,46 @@ function ComposerPromptEditorInner({ · 266 unmodified lines
18291836 data-testid="composer-editor"
18301837 aria-placeholder={placeholder}
18311838 placeholder={<span />}
1839+ onKeyDown={(event) => {
1840+ if (
1841+ event.key === "Control" ||
1842+ event.key === "Meta" ||
1843+ event.key === "Alt" ||
1844+ event.key === "Shift"
1845+ ) {
1846+ onPageScrollRelease?.();
1847+ }
1848+
1849+ if (event.key !== "PageUp" && event.key !== "PageDown") {
1850+ return;
1851+ }
1852+
1853+ const pageScrollKey = getTimelinePageScrollKey({
1854+ altKey: event.altKey,
1855+ clientHeight: event.currentTarget.clientHeight,
1856+ ctrlKey: event.ctrlKey,
1857+ defaultPrevented: event.defaultPrevented,
1858+ isComposing: event.nativeEvent.isComposing,
1859+ key: event.key,
1860+ keyCode: event.keyCode,
1861+ metaKey: event.metaKey,
1862+ scrollHeight: event.currentTarget.scrollHeight,
1863+ scrollTop: event.currentTarget.scrollTop,
1864+ shiftKey: event.shiftKey,
1865+ });
1866+ if (!pageScrollKey) {
1867+ onPageScrollRelease?.();
1868+ return;
1869+ }
1870+ if (!onPageScrollKeyDown) {
1871+ return;
1872+ }
1873+
1874+ event.preventDefault();
1875+ onPageScrollKeyDown(pageScrollKey);
1876+ }}
1877+ onKeyUp={(event) => onPageScrollKeyUp?.(event.key)}
1878+ onBlur={onPageScrollRelease}
18321879 onPaste={onPaste}
18331880 />
18341881 }
@@ -1868,6 +1915,9 @@ export function ComposerPromptEditor({ · 33 unmodified lines
18681915 onRemoveTerminalContext,
18691916 onChange,
18701917 onCommandKeyDown,
1918+ onPageScrollKeyDown,
1919+ onPageScrollKeyUp,
1920+ onPageScrollRelease,
18711921 onPaste,
18721922 editorRef,
18731923 }: ComposerPromptEditorProps) {
@@ -1912,6 +1962,9 @@ export function ComposerPromptEditor({ · 38 unmodified lines
19121962 onPaste={onPaste}
19131963 editorRef={editorRef}
19141964 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
1965+ {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})}
1966+ {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
1967+ {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
19151968 {...(className ? { className } : {})}
19161969 />
19171970 </LexicalComposer>
apps/web/src/components/chat/ChatComposer.tsx+9−0
@@ -710,6 +710,9 @@ export interface ChatComposerProps { · 709 unmodified lines
710710 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
711711 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
712712 composerRef: React.RefObject<ChatComposerHandle | null>;
713+ onPageScrollKeyDown: (key: "PageUp" | "PageDown") => void;
714+ onPageScrollKeyUp: (key: string) => void;
715+ onPageScrollRelease: () => void;
713716
714717 // Callbacks
715718 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
@@ -801,6 +804,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 85 unmodified lines
801804 composerFilesRef,
802805 composerTerminalContextsRef,
803806 composerElementContextsRef,
807+ onPageScrollKeyDown,
808+ onPageScrollKeyUp,
809+ onPageScrollRelease,
804810 onSend,
805811 onInterrupt,
806812 onImplementPlanInNewThread,
@@ -4187,6 +4193,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3380 unmodified lines
41874193 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
41884194 onChange={onPromptChange}
41894195 onCommandKeyDown={onComposerCommandKey}
4196+ onPageScrollKeyDown={onPageScrollKeyDown}
4197+ onPageScrollKeyUp={onPageScrollKeyUp}
4198+ onPageScrollRelease={onPageScrollRelease}
41904199 onPaste={onComposerPaste}
41914200 placeholder={
41924201 isComposerApprovalState
apps/web/src/components/chat/pageScrollController.test.ts+279−0
@@ -0,0 +1,279 @@
1+import { describe, expect, test } from "vite-plus/test";
2+
3+import {
4+ createPageScrollController,
5+ getTimelinePageScrollKey,
6+ getPageScrollDistancePx,
7+ getPageScrollMultiplier,
8+ getPageScrollVelocityPxPerMs,
9+ PAGE_SCROLL_ACCELERATION_MS,
10+ PAGE_SCROLL_ANIMATION_MS,
11+ PAGE_SCROLL_MAX_MULTIPLIER,
12+} from "./pageScrollController";
13+
14+class TestClock {
15+ private currentTime = 0;
16+ private nextHandle = 1;
17+ private animationFrames = new Map<number, FrameRequestCallback>();
18+ private timeouts = new Map<number, { at: number; callback: () => void }>();
19+
20+ readonly env = {
21+ now: () => this.currentTime,
22+ requestAnimationFrame: (callback: FrameRequestCallback) => {
23+ const handle = this.nextHandle;
24+ this.nextHandle += 1;
25+ this.animationFrames.set(handle, callback);
26+ return handle;
27+ },
28+ cancelAnimationFrame: (handle: number) => {
29+ this.animationFrames.delete(handle);
30+ },
31+ setTimeout: (callback: () => void, delay: number) => {
32+ const handle = this.nextHandle;
33+ this.nextHandle += 1;
34+ this.timeouts.set(handle, { at: this.currentTime + delay, callback });
35+ return handle;
36+ },
37+ clearTimeout: (handle: number) => {
38+ this.timeouts.delete(handle);
39+ },
40+ };
41+
42+ advanceBy(ms: number, frameMs = 16) {
43+ const target = this.currentTime + ms;
44+
45+ while (this.currentTime < target) {
46+ this.currentTime = Math.min(target, this.currentTime + frameMs);
47+ this.flushTimeouts();
48+ this.flushAnimationFrames();
49+ }
50+ }
51+
52+ private flushTimeouts() {
53+ let hasDueTimeouts = true;
54+ while (hasDueTimeouts) {
55+ hasDueTimeouts = false;
56+
57+ for (const [handle, timeout] of this.timeouts) {
58+ if (timeout.at > this.currentTime) {
59+ continue;
60+ }
61+
62+ this.timeouts.delete(handle);
63+ timeout.callback();
64+ hasDueTimeouts = true;
65+ }
66+ }
67+ }
68+
69+ private flushAnimationFrames() {
70+ if (this.animationFrames.size === 0) {
71+ return;
72+ }
73+
74+ const frames = [...this.animationFrames.values()];
75+ this.animationFrames.clear();
76+
77+ for (const callback of frames) {
78+ callback(this.currentTime);
79+ }
80+ }
81+}
82+
83+describe("page scroll helpers", () => {
84+ const composerPageScrollEvent = (
85+ overrides: Partial<Parameters<typeof getTimelinePageScrollKey>[0]> = {},
86+ ) => ({
87+ altKey: false,
88+ clientHeight: 200,
89+ ctrlKey: false,
90+ defaultPrevented: false,
91+ isComposing: false,
92+ key: "PageDown",
93+ keyCode: 34,
94+ metaKey: false,
95+ scrollHeight: 200,
96+ scrollTop: 0,
97+ shiftKey: false,
98+ ...overrides,
99+ });
100+
101+ test("leaves page keys to IME composition", () => {
102+ expect(getTimelinePageScrollKey(composerPageScrollEvent({ isComposing: true }))).toBeNull();
103+ expect(getTimelinePageScrollKey(composerPageScrollEvent({ keyCode: 229 }))).toBeNull();
104+ });
105+
106+ test("leaves page keys to an overflowing composer until it reaches the boundary", () => {
107+ expect(
108+ getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 0 })),
109+ ).toBeNull();
110+ expect(
111+ getTimelinePageScrollKey(
112+ composerPageScrollEvent({
113+ key: "PageUp",
114+ keyCode: 33,
115+ scrollHeight: 600,
116+ scrollTop: 400,
117+ }),
118+ ),
119+ ).toBeNull();
120+
121+ expect(
122+ getTimelinePageScrollKey(
123+ composerPageScrollEvent({
124+ key: "PageUp",
125+ keyCode: 33,
126+ scrollHeight: 600,
127+ scrollTop: 0,
128+ }),
129+ ),
130+ ).toBe("PageUp");
131+ expect(
132+ getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 400 })),
133+ ).toBe("PageDown");
134+ });
135+
136+ test("hands off page keys within a fractional pixel of the composer boundary", () => {
137+ expect(
138+ getTimelinePageScrollKey(
139+ composerPageScrollEvent({
140+ key: "PageUp",
141+ keyCode: 33,
142+ scrollHeight: 600,
143+ scrollTop: 0.5,
144+ }),
145+ ),
146+ ).toBe("PageUp");
147+ expect(
148+ getTimelinePageScrollKey(composerPageScrollEvent({ scrollHeight: 600, scrollTop: 399.5 })),
149+ ).toBe("PageDown");
150+ });
151+
152+ test("ramps multiplier over time and caps at the max velocity", () => {
153+ expect(getPageScrollMultiplier(0)).toBe(1);
154+ expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS / 2)).toBeCloseTo(1.5);
155+ expect(getPageScrollMultiplier(PAGE_SCROLL_ACCELERATION_MS * 5)).toBe(
156+ PAGE_SCROLL_MAX_MULTIPLIER,
157+ );
158+ });
159+
160+ test("derives the hold velocity from page size and acceleration", () => {
161+ expect(
162+ getPageScrollVelocityPxPerMs({
163+ holdElapsedMs: 0,
164+ pageScrollDistancePx: 600,
165+ }),
166+ ).toBeCloseTo(4);
167+ expect(
168+ getPageScrollVelocityPxPerMs({
169+ holdElapsedMs: PAGE_SCROLL_ACCELERATION_MS * 5,
170+ pageScrollDistancePx: 600,
171+ }),
172+ ).toBeCloseTo(8);
173+ });
174+});
175+
176+describe("createPageScrollController", () => {
177+ test("keeps a single page scroll when the key is tapped", () => {
178+ const clock = new TestClock();
179+ const container = {
180+ clientHeight: 600,
181+ scrollHeight: 1_800,
182+ scrollTop: 0,
183+ getBoundingClientRect: () => ({ height: 600 }),
184+ };
185+ const controller = createPageScrollController({
186+ getContainer: () => container,
187+ getScrollPaddingBottomPx: () => 24,
188+ env: clock.env,
189+ });
190+
191+ controller.handleKeyDown("PageDown");
192+ controller.handleKeyUp("PageDown");
193+ clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
194+
195+ expect(container.scrollTop).toBeCloseTo(
196+ getPageScrollDistancePx({
197+ containerHeightPx: 600,
198+ scrollPaddingBottomPx: 24,
199+ }),
200+ 5,
201+ );
202+ });
203+
204+ test("continues scrolling on hold without repeated keydown events and stops on keyup", () => {
205+ const clock = new TestClock();
206+ const container = {
207+ clientHeight: 600,
208+ scrollHeight: 4_000,
209+ scrollTop: 0,
210+ getBoundingClientRect: () => ({ height: 600 }),
211+ };
212+ const controller = createPageScrollController({
213+ getContainer: () => container,
214+ getScrollPaddingBottomPx: () => 24,
215+ env: clock.env,
216+ });
217+ controller.handleKeyDown("PageDown");
218+ clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);
219+
220+ const afterHoldStarts = container.scrollTop;
221+ clock.advanceBy(200);
222+
223+ expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);
224+
225+ const stoppedAt = container.scrollTop;
226+ controller.handleKeyUp("PageDown");
227+ clock.advanceBy(250);
228+
229+ expect(container.scrollTop).toBe(stoppedAt);
230+ });
231+
232+ test("notifies once when a page scroll starts", () => {
233+ const clock = new TestClock();
234+ const started: string[] = [];
235+ const controller = createPageScrollController({
236+ getContainer: () => ({
237+ clientHeight: 600,
238+ scrollHeight: 1_800,
239+ scrollTop: 600,
240+ getBoundingClientRect: () => ({ height: 600 }),
241+ }),
242+ getScrollPaddingBottomPx: () => 24,
243+ onScrollStart: (key) => started.push(key),
244+ env: clock.env,
245+ });
246+
247+ controller.handleKeyDown("PageUp");
248+ controller.handleKeyDown("PageUp");
249+
250+ expect(started).toEqual(["PageUp"]);
251+ });
252+
253+ test("does not start a page scroll at the timeline boundary", () => {
254+ const clock = new TestClock();
255+ const started: string[] = [];
256+ const container = {
257+ clientHeight: 600,
258+ scrollHeight: 1_800,
259+ scrollTop: 0.5,
260+ getBoundingClientRect: () => ({ height: 600 }),
261+ };
262+ const controller = createPageScrollController({
263+ getContainer: () => container,
264+ getScrollPaddingBottomPx: () => 24,
265+ onScrollStart: (key) => started.push(key),
266+ env: clock.env,
267+ });
268+
269+ controller.handleKeyDown("PageUp");
270+ clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2);
271+
272+ container.scrollTop = container.scrollHeight - container.clientHeight - 0.5;
273+ controller.handleKeyDown("PageDown");
274+ clock.advanceBy(PAGE_SCROLL_ANIMATION_MS * 2);
275+
276+ expect(started).toEqual([]);
277+ expect(container.scrollTop).toBe(1_199.5);
278+ });
279+});
apps/web/src/components/chat/pageScrollController.ts+307−0
@@ -0,0 +1,307 @@
1+export const PAGE_SCROLL_ANIMATION_MS = 150;
2+export const PAGE_SCROLL_ACCELERATION_MS = 400;
3+export const PAGE_SCROLL_MAX_MULTIPLIER = 2;
4+
5+const PAGE_SCROLL_ALIGNMENT_OFFSET_PX = 36;
6+const PAGE_SCROLL_BOUNDARY_EPSILON_PX = 1;
7+const PAGE_SCROLL_HOLD_DELAY_MS = PAGE_SCROLL_ANIMATION_MS;
8+
9+export type PageScrollKey = "PageUp" | "PageDown";
10+
11+type PageScrollMetrics = {
12+ clientHeight: number;
13+ scrollHeight: number;
14+ scrollTop: number;
15+};
16+
17+function 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+
28+export 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+
72+type PageScrollContainer = PageScrollMetrics & {
73+ getBoundingClientRect: () => {
74+ height: number;
75+ };
76+};
77+
78+type 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+
86+function 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+
96+export 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+
101+export 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+
111+export 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+
121+function getDirection(key: PageScrollKey): -1 | 1 {
122+ return key === "PageUp" ? -1 : 1;
123+}
124+
125+function 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+
148+export 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+}