fix(chat): release live follow on middle-button pan, Shift+Space, and nested wheel (#2107)
fix: release chat autoscroll on explicit navigation intent
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { isFollowReleaseKey, isMiddleButtonPan, nestedScrollableConsumesWheelUp } from './timelineScrollIntent';
|
||||||
|
|
||||||
|
const key = (
|
||||||
|
k: string,
|
||||||
|
modifiers: Partial<Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>> = {},
|
||||||
|
) => ({ key: k, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers });
|
||||||
|
|
||||||
|
describe('isFollowReleaseKey', () => {
|
||||||
|
test('upward navigation keys release follow', () => {
|
||||||
|
for (const k of ['ArrowUp', 'PageUp', 'Home']) expect(isFollowReleaseKey(key(k))).toBe(true);
|
||||||
|
expect(isFollowReleaseKey(key(' ', { shiftKey: true }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downward keys, plain space, and modified shortcuts do not', () => {
|
||||||
|
for (const k of ['ArrowDown', 'PageDown', 'End', ' ', 'Pause', 'Enter']) {
|
||||||
|
expect(isFollowReleaseKey(key(k))).toBe(false);
|
||||||
|
}
|
||||||
|
expect(isFollowReleaseKey(key('Home', { ctrlKey: true }))).toBe(false);
|
||||||
|
expect(isFollowReleaseKey(key('ArrowUp', { metaKey: true }))).toBe(false);
|
||||||
|
expect(isFollowReleaseKey(key('ArrowUp', { altKey: true }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The helpers only use Element#closest, scrollTop, and identity, so a minimal
|
||||||
|
// DOM stand-in built on EventTarget is enough — no renderer or jsdom.
|
||||||
|
class FakeElement extends EventTarget {
|
||||||
|
scrollTop = 0;
|
||||||
|
constructor(private readonly scrollable: boolean, private readonly parent: FakeElement | null = null) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
closest(selector: string): FakeElement | null {
|
||||||
|
if (selector !== '[data-scrollable]') throw new Error(`unexpected selector ${selector}`);
|
||||||
|
if (this.scrollable) return this;
|
||||||
|
return this.parent?.closest(selector) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAFETY: the helpers narrow with `instanceof Element` / `instanceof HTMLElement`;
|
||||||
|
// registering the fakes under those globals keeps the narrowing honest in bun.
|
||||||
|
const installDomGlobals = () => {
|
||||||
|
const previous = { Element: globalThis.Element, HTMLElement: globalThis.HTMLElement };
|
||||||
|
Object.assign(globalThis, { Element: FakeElement, HTMLElement: FakeElement });
|
||||||
|
return () => Object.assign(globalThis, previous);
|
||||||
|
};
|
||||||
|
// With the globals above installed, FakeElement IS the HTMLElement the helpers
|
||||||
|
// narrow to; reading it back through the global bridges the static type without
|
||||||
|
// asserting anything the runtime does not hold.
|
||||||
|
const asRoot = (element: FakeElement): HTMLElement => {
|
||||||
|
if (!(element instanceof globalThis.HTMLElement)) throw new Error('DOM globals not installed');
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('nested scroller handling', () => {
|
||||||
|
test('an upward wheel over a nested scroller with room above stays there', () => {
|
||||||
|
const restore = installDomGlobals();
|
||||||
|
try {
|
||||||
|
const root = new FakeElement(false);
|
||||||
|
const box = new FakeElement(true, root);
|
||||||
|
const inner = new FakeElement(false, box);
|
||||||
|
box.scrollTop = 40;
|
||||||
|
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(true);
|
||||||
|
box.scrollTop = 0;
|
||||||
|
expect(nestedScrollableConsumesWheelUp(asRoot(root), inner)).toBe(false);
|
||||||
|
expect(nestedScrollableConsumesWheelUp(asRoot(root), new FakeElement(false, root))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a middle-button press pans the timeline unless it lands in a nested scroller', () => {
|
||||||
|
const restore = installDomGlobals();
|
||||||
|
try {
|
||||||
|
const root = new FakeElement(false);
|
||||||
|
const row = new FakeElement(false, root);
|
||||||
|
const box = new FakeElement(true, root);
|
||||||
|
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: row })).toBe(true);
|
||||||
|
expect(isMiddleButtonPan(asRoot(root), { button: 1, target: box })).toBe(false);
|
||||||
|
expect(isMiddleButtonPan(asRoot(root), { button: 0, target: row })).toBe(false);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Gesture classification for the chat timeline's follow opt-out.
|
||||||
|
//
|
||||||
|
// The timeline releases live follow on REAL upward gestures only. Wheel and
|
||||||
|
// touch carry their direction; this module answers the same question for the
|
||||||
|
// inputs that do not: which keys mean "scroll up", when a middle-button press
|
||||||
|
// starts a pan, and when an upward wheel belongs to a nested scroller (a tool
|
||||||
|
// output box) that can still consume it. Pure functions, no DOM ownership,
|
||||||
|
// so the rules are testable without a renderer.
|
||||||
|
|
||||||
|
// A nested scroller inside the timeline marks itself with this attribute
|
||||||
|
// (see ToolPart). Wheel-up over it scrolls the box, not the conversation, for
|
||||||
|
// as long as the box has room above.
|
||||||
|
const NESTED_SCROLLABLE_SELECTOR = '[data-scrollable]';
|
||||||
|
|
||||||
|
export const isFollowReleaseKey = (
|
||||||
|
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||||
|
): boolean => {
|
||||||
|
// Modified keys are shortcuts, not navigation.
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return false;
|
||||||
|
if (event.key === ' ') return event.shiftKey;
|
||||||
|
return event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home';
|
||||||
|
};
|
||||||
|
|
||||||
|
const nestedScrollable = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const nested = target.closest(NESTED_SCROLLABLE_SELECTOR);
|
||||||
|
return nested instanceof HTMLElement && nested !== root ? nested : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An upward wheel over a nested scroller that still has content above stays
|
||||||
|
// with that scroller; the timeline must not treat it as leaving the end.
|
||||||
|
export const nestedScrollableConsumesWheelUp = (root: HTMLElement, target: EventTarget | null): boolean => {
|
||||||
|
const nested = nestedScrollable(root, target);
|
||||||
|
return nested !== null && nested.scrollTop > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middle-button press starts the platform's autoscroll pan (Windows/Linux
|
||||||
|
// Chromium); the pan then scrolls without wheel events, so the press itself is
|
||||||
|
// the gesture. Inside a nested scroller the pan belongs to that scroller.
|
||||||
|
export const isMiddleButtonPan = (root: HTMLElement, event: Pick<MouseEvent, 'button' | 'target'>): boolean =>
|
||||||
|
event.button === 1 && nestedScrollable(root, event.target) === null;
|
||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
type TimelineListMeasurementState,
|
type TimelineListMeasurementState,
|
||||||
type TimelineScrollMode,
|
type TimelineScrollMode,
|
||||||
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
|
||||||
|
import {
|
||||||
|
isFollowReleaseKey,
|
||||||
|
isMiddleButtonPan,
|
||||||
|
nestedScrollableConsumesWheelUp,
|
||||||
|
} from '@/components/chat/lib/scroll/timelineScrollIntent';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
// Chat timeline scroll ownership.
|
// Chat timeline scroll ownership.
|
||||||
@@ -772,8 +777,12 @@ export const useChatTimelineScroll = ({
|
|||||||
onManualNavigationRef.current();
|
onManualNavigationRef.current();
|
||||||
};
|
};
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
// Scrolling toward the end is not opting out of follow.
|
// Scrolling toward the end is not opting out of follow, and an
|
||||||
if (event.deltaY < 0 && canScrollUp()) gesture();
|
// upward wheel that a nested scroller still consumes never
|
||||||
|
// reaches the timeline.
|
||||||
|
if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(scrollNode, event.target) && canScrollUp()) {
|
||||||
|
gesture();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Touch mirrors wheel by finger direction, not by having already left
|
// Touch mirrors wheel by finger direction, not by having already left
|
||||||
// the end: while a stream keeps re-pinning the viewport, waiting for
|
// the end: while a stream keeps re-pinning the viewport, waiting for
|
||||||
@@ -797,14 +806,19 @@ export const useChatTimelineScroll = ({
|
|||||||
touchLastY = null;
|
touchLastY = null;
|
||||||
};
|
};
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
// The scrollbar track is the scroll node itself; a tap on a row
|
// A middle-button pan scrolls without wheel events (and is the
|
||||||
// only breaks follow when the viewport already left the end.
|
// only scroll gesture for wheel-less mice), so the press is the
|
||||||
|
// opt-out. Otherwise the scrollbar track is the scroll node
|
||||||
|
// itself; a tap on a row only breaks follow when the viewport
|
||||||
|
// already left the end.
|
||||||
|
if (isMiddleButtonPan(scrollNode, event)) {
|
||||||
|
if (canScrollUp()) gesture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
|
if (isFollowReleaseKey(event) && canScrollUp()) gesture();
|
||||||
gesture();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
queueSave();
|
queueSave();
|
||||||
|
|||||||
Reference in New Issue
Block a user