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