| author | |
| committer | |
| log | 5f84efa1ec2fb3cd6f6c54545cc34bb77ac1ddb8 |
| tree | 4bad05776e00dda1d7105d54d1a41407bb44f7c5 |
| parent | 1aa44a071f66bdfd9430356ab824b5a6985fb459 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 685 insertions(+), 0 deletions(-)
apps/web/src/components/ChatView.tsx+37| ... | ... | @@ -63,6 +63,7 @@ import { |
| 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 { |
| 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) { |
| 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) { |
| 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} |
apps/web/src/components/ComposerPromptEditor.tsx+53| ... | ... | @@ -83,6 +83,7 @@ import { |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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> |
apps/web/src/components/chat/ChatComposer.tsx+9| ... | ... | @@ -710,6 +710,9 @@ export interface ChatComposerProps { |
| 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) |
| 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) |
| 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 |
apps/web/src/components/chat/pageScrollController.test.ts created+279| ... | ... | @@ -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 created+307| ... | ... | @@ -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 | } |