Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/web/src/components/chat/timelineMinimapWheel.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { fakeTimers } from "../../../test/render";
import { forwardWheelToTimeline } from "./timelineMinimapWheel";

function mountTimeline() {
const timeline = document.createElement("div");
timeline.style.cssText = "height: 200px; overflow-y: auto;";
const content = document.createElement("div");
content.style.height = "2000px";
timeline.append(content);
document.body.append(timeline);
return Object.assign(timeline, { [Symbol.dispose]: () => timeline.remove() });
}

function wheel(deltaY: number, wheelDeltaY: number): WheelEvent {
const init = { cancelable: true, deltaY, wheelDeltaY };
return new WheelEvent("wheel", init);
}

describe("forwardWheelToTimeline", () => {
it.each([
["macOS", -360],
["Linux and Windows", -120],
])("scrolls %s trackpad deltas immediately", (_platform, wheelDeltaY) => {
using timeline = mountTimeline();
forwardWheelToTimeline(wheel(120, wheelDeltaY), timeline);
expect(timeline.scrollTop).toBe(120);
});

it("glides mouse wheel notches and accumulates them", () => {
using timeline = mountTimeline();
using _timers = fakeTimers();
forwardWheelToTimeline(wheel(100, -120), timeline);
forwardWheelToTimeline(wheel(100, -120), timeline);
expect(timeline.scrollTop).toBe(0);
vi.advanceTimersByTime(75);
expect(timeline.scrollTop).toBeGreaterThan(0);
expect(timeline.scrollTop).toBeLessThan(200);
vi.advanceTimersByTime(100);
expect(timeline.scrollTop).toBe(200);
});

it("stops gliding when the user clicks or navigates", () => {
using timeline = mountTimeline();
using _timers = fakeTimers();
forwardWheelToTimeline(wheel(100, -120), timeline);
vi.advanceTimersByTime(50);
timeline.scrollTop = 1000;
window.dispatchEvent(new PointerEvent("pointerdown"));
vi.advanceTimersByTime(200);
expect(timeline.scrollTop).toBe(1000);
});
});
80 changes: 79 additions & 1 deletion apps/web/src/components/chat/timelineMinimapWheel.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,75 @@
const MOUSE_WHEEL_SCROLL_MS = 150;
const PRECISE_WHEEL_DELTA_RATIOS = [1, 3];

interface WheelGlide {
from: number;
to: number;
start: number;
written: number;
frame: number;
interruptions: AbortController;
}

const glides = new WeakMap<HTMLElement, WheelGlide>();

function isPreciseWheel(event: WheelEvent): boolean {
if (event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return false;
const { wheelDeltaY } = event as WheelEvent & { wheelDeltaY?: number };
return (
wheelDeltaY === undefined ||
PRECISE_WHEEL_DELTA_RATIOS.some((ratio) => Math.abs(wheelDeltaY + ratio * event.deltaY) < 1)
);
}

function stopGlide(timeline: HTMLElement): WheelGlide | undefined {
const glide = glides.get(timeline);
if (!glide) return undefined;
cancelAnimationFrame(glide.frame);
glide.interruptions.abort();
glides.delete(timeline);
return glide;
}

function glideBy(timeline: HTMLElement, distance: number): void {
const current = stopGlide(timeline);
const scrollTop = timeline.scrollTop;
const base = current ? current.to + scrollTop - current.written : scrollTop;
const maxScrollTop = timeline.scrollHeight - timeline.clientHeight;
const glide: WheelGlide = {
from: scrollTop,
to: Math.max(0, Math.min(maxScrollTop, base + distance)),
start: performance.now(),
written: scrollTop,
frame: 0,
interruptions: new AbortController(),
};
for (const type of ["pointerdown", "keydown"]) {
window.addEventListener(type, () => stopGlide(timeline), {
capture: true,
signal: glide.interruptions.signal,
});
}
const step = (time: number) => {
if (!timeline.isConnected) {
stopGlide(timeline);
return;
}
const drift = timeline.scrollTop - glide.written;
glide.from += drift;
glide.to += drift;
const progress = Math.min(1, Math.max(0, (time - glide.start) / MOUSE_WHEEL_SCROLL_MS));
timeline.scrollTop = glide.from + (glide.to - glide.from) * (1 - (1 - progress) ** 3);
glide.written = timeline.scrollTop;
if (progress < 1) {
glide.frame = requestAnimationFrame(step);
} else {
stopGlide(timeline);
}
};
glide.frame = requestAnimationFrame(step);
glides.set(timeline, glide);
}

export function forwardWheelToTimeline(event: WheelEvent, timeline: HTMLElement): void {
if (event.defaultPrevented || event.ctrlKey || event.deltaY === 0) return;
event.preventDefault();
Expand Down Expand Up @@ -25,5 +97,11 @@ export function forwardWheelToTimeline(event: WheelEvent, timeline: HTMLElement)
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? timeline.clientHeight
: 1;
timeline.scrollBy({ top: event.deltaY * pixelsPerUnit });
const distance = event.deltaY * pixelsPerUnit;
if (isPreciseWheel(event) || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
stopGlide(timeline);
timeline.scrollBy({ top: distance });
return;
}
glideBy(timeline, distance);
}
Loading