authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-02 23:12:15-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 23:12:15-05:00
log5f84efa1ec2fb3cd6f6c54545cc34bb77ac1ddb8
tree4bad05776e00dda1d7105d54d1a41407bb44f7c5
parent1aa44a071f66bdfd9430356ab824b5a6985fb459
signature Signed by PGP key B5690EEEBB952194

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


5 files changed, 685 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.tsx+37
...@@ -63,6 +63,7 @@ import {...@@ -63,6 +63,7 @@ import {
63 Suspense,63 Suspense,
64 useCallback,64 useCallback,
65 useEffect,65 useEffect,
66 useEffectEvent,
66 useLayoutEffect,67 useLayoutEffect,
67 useMemo,68 useMemo,
68 useRef,69 useRef,
...@@ -291,6 +292,7 @@ import {...@@ -291,6 +292,7 @@ import {
291} from "../state/entities";292} from "../state/entities";
292import { environmentShell } from "../state/shell";293import { environmentShell } from "../state/shell";
293import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";294import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
295import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
294import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";296import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
295import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";297import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
296import { PullRequestThreadDialog } from "./PullRequestThreadDialog";298import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
...@@ -4314,6 +4316,38 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4314,6 +4316,38 @@ function ChatViewContent(props: ChatViewProps) {
4314 },4316 },
4315 [composerOverlayHeight],4317 [composerOverlayHeight],
4316 );4318 );
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 }, []);
4317 // Live-follow stays active after send/thread-open until an actual list scroll4351 // Live-follow stays active after send/thread-open until an actual list scroll
4318 // gesture opts out.4352 // gesture opts out.
4319 const scrollToEnd = useCallback((animated = false) => {4353 const scrollToEnd = useCallback((animated = false) => {
...@@ -7637,6 +7671,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7637,6 +7671,9 @@ function ChatViewContent(props: ChatViewProps) {
7637 composerFilesRef={composerFilesRef}7671 composerFilesRef={composerFilesRef}
7638 composerTerminalContextsRef={composerTerminalContextsRef}7672 composerTerminalContextsRef={composerTerminalContextsRef}
7639 composerElementContextsRef={composerElementContextsRef}7673 composerElementContextsRef={composerElementContextsRef}
7674 onPageScrollKeyDown={onComposerPageScrollKeyDown}
7675 onPageScrollKeyUp={onComposerPageScrollKeyUp}
7676 onPageScrollRelease={onComposerPageScrollRelease}
7640 onSend={onSend}7677 onSend={onSend}
7641 onInterrupt={onInterrupt}7678 onInterrupt={onInterrupt}
7642 onImplementPlanInNewThread={onImplementPlanInNewThread}7679 onImplementPlanInNewThread={onImplementPlanInNewThread}
apps/web/src/components/ComposerPromptEditor.tsx+53
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83} from "./composerInlineChip";83} from "./composerInlineChip";
84import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";84import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
85import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";85import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
86import { getTimelinePageScrollKey } from "./chat/pageScrollController";
86import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";87import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
87import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";88import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
88import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";89import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
...@@ -916,6 +917,9 @@ interface ComposerPromptEditorProps {...@@ -916,6 +917,9 @@ interface ComposerPromptEditorProps {
916 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",917 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
917 event: KeyboardEvent,918 event: KeyboardEvent,
918 ) => boolean;919 ) => boolean;
920 onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void;
921 onPageScrollKeyUp?: (key: string) => void;
922 onPageScrollRelease?: () => void;
919 onPaste: React.ClipboardEventHandler<HTMLElement>;923 onPaste: React.ClipboardEventHandler<HTMLElement>;
920 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;924 editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
921}925}
...@@ -1557,6 +1561,9 @@ function ComposerPromptEditorInner({...@@ -1557,6 +1561,9 @@ function ComposerPromptEditorInner({
1557 onRemoveTerminalContext,1561 onRemoveTerminalContext,
1558 onChange,1562 onChange,
1559 onCommandKeyDown,1563 onCommandKeyDown,
1564 onPageScrollKeyDown,
1565 onPageScrollKeyUp,
1566 onPageScrollRelease,
1560 onPaste,1567 onPaste,
1561 editorRef,1568 editorRef,
1562}: ComposerPromptEditorProps) {1569}: ComposerPromptEditorProps) {
...@@ -1829,6 +1836,46 @@ function ComposerPromptEditorInner({...@@ -1829,6 +1836,46 @@ function ComposerPromptEditorInner({
1829 data-testid="composer-editor"1836 data-testid="composer-editor"
1830 aria-placeholder={placeholder}1837 aria-placeholder={placeholder}
1831 placeholder={<span />}1838 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}
1832 onPaste={onPaste}1879 onPaste={onPaste}
1833 />1880 />
1834 }1881 }
...@@ -1868,6 +1915,9 @@ export function ComposerPromptEditor({...@@ -1868,6 +1915,9 @@ export function ComposerPromptEditor({
1868 onRemoveTerminalContext,1915 onRemoveTerminalContext,
1869 onChange,1916 onChange,
1870 onCommandKeyDown,1917 onCommandKeyDown,
1918 onPageScrollKeyDown,
1919 onPageScrollKeyUp,
1920 onPageScrollRelease,
1871 onPaste,1921 onPaste,
1872 editorRef,1922 editorRef,
1873}: ComposerPromptEditorProps) {1923}: ComposerPromptEditorProps) {
...@@ -1912,6 +1962,9 @@ export function ComposerPromptEditor({...@@ -1912,6 +1962,9 @@ export function ComposerPromptEditor({
1912 onPaste={onPaste}1962 onPaste={onPaste}
1913 editorRef={editorRef}1963 editorRef={editorRef}
1914 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}1964 {...(onCommandKeyDown ? { onCommandKeyDown } : {})}
1965 {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})}
1966 {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
1967 {...(onPageScrollRelease ? { onPageScrollRelease } : {})}
1915 {...(className ? { className } : {})}1968 {...(className ? { className } : {})}
1916 />1969 />
1917 </LexicalComposer>1970 </LexicalComposer>
apps/web/src/components/chat/ChatComposer.tsx+9
...@@ -710,6 +710,9 @@ export interface ChatComposerProps {...@@ -710,6 +710,9 @@ export interface ChatComposerProps {
710 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;710 composerTerminalContextsRef: React.RefObject<TerminalContextDraft[]>;
711 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;711 composerElementContextsRef: React.RefObject<ElementContextDraft[]>;
712 composerRef: React.RefObject<ChatComposerHandle | null>;712 composerRef: React.RefObject<ChatComposerHandle | null>;
713 onPageScrollKeyDown: (key: "PageUp" | "PageDown") => void;
714 onPageScrollKeyUp: (key: string) => void;
715 onPageScrollRelease: () => void;
713716
714 // Callbacks717 // Callbacks
715 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;718 onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
...@@ -801,6 +804,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -801,6 +804,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
801 composerFilesRef,804 composerFilesRef,
802 composerTerminalContextsRef,805 composerTerminalContextsRef,
803 composerElementContextsRef,806 composerElementContextsRef,
807 onPageScrollKeyDown,
808 onPageScrollKeyUp,
809 onPageScrollRelease,
804 onSend,810 onSend,
805 onInterrupt,811 onInterrupt,
806 onImplementPlanInNewThread,812 onImplementPlanInNewThread,
...@@ -4187,6 +4193,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4187,6 +4193,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4187 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}4193 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
4188 onChange={onPromptChange}4194 onChange={onPromptChange}
4189 onCommandKeyDown={onComposerCommandKey}4195 onCommandKeyDown={onComposerCommandKey}
4196 onPageScrollKeyDown={onPageScrollKeyDown}
4197 onPageScrollKeyUp={onPageScrollKeyUp}
4198 onPageScrollRelease={onPageScrollRelease}
4190 onPaste={onComposerPaste}4199 onPaste={onComposerPaste}
4191 placeholder={4200 placeholder={
4192 isComposerApprovalState4201 isComposerApprovalState
apps/web/src/components/chat/pageScrollController.test.ts created+279
...@@ -0,0 +1,279 @@
1import { describe, expect, test } from "vite-plus/test";
2
3import {
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
14class 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
83describe("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
176describe("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 created+307
...@@ -0,0 +1,307 @@
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}