返回 DeepSeek-Reasonix
nestedScrollHandoff.ts
根目录 / desktop / frontend / src / lib / nestedScrollHandoff.ts
1 /**
2 * Nested scroll handoff for transcript reading.
3 *
4 * Trackpads latch wheel events to the first overflow≠visible ancestor under the
5 * pointer — including elements that only set overflow-x:auto (CSS then computes
6 * overflow-y to auto). When that ancestor cannot scroll further in the gesture
7 * direction, continued wheels stall instead of moving the outer transcript.
8 *
9 * This helper promotes those edge / non-scrollable vertical wheels to the
10 * parent scroller and latches to the parent for the rest of the gesture.
11 */
12
13 export const NESTED_SCROLL_ATTR = "data-nested-scroll";
14
15 const EDGE_EPSILON_PX = 1;
16 const DEFAULT_LINE_HEIGHT_PX = 16;
17 const DOM_DELTA_PIXEL = 0;
18 const DOM_DELTA_PAGE = 2;
19
20 export type NestedScrollHandoffOptions = {
21 /** Outer reading scroller (`.transcript`). */
22 parent: HTMLElement;
23 /** Called with normalized deltaY when a nested edge wheel is promoted. */
24 onParentScrollIntent?: (deltaY: number) => void;
25 /** Route the final native offset through TranscriptViewportWriter. */
26 writeParentOffset: (top: number) => boolean;
27 /** Latch to the parent after the first edge handoff until this many ms of silence. */
28 latchHoldMs?: number;
29 now?: () => number;
30 };
31
32 export type NestedScrollHandoff = {
33 detach: () => void;
34 };
35
36 /** Normalize WheelEvent line/page deltas before applying them to scrollTop. */
37 export function normalizeWheelDelta(event: Pick<WheelEvent, "deltaX" | "deltaY" | "deltaMode">, viewport: HTMLElement) {
38 const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL;
39 if (deltaMode === DOM_DELTA_PIXEL) {
40 return { x: event.deltaX, y: event.deltaY };
41 }
42 if (deltaMode === DOM_DELTA_PAGE) {
43 const page = Math.max(1, viewport.clientHeight);
44 return { x: event.deltaX * page, y: event.deltaY * page };
45 }
46 const computed = typeof getComputedStyle === "function" ? getComputedStyle(viewport).lineHeight : "";
47 const parsed = Number.parseFloat(computed);
48 const line = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LINE_HEIGHT_PX;
49 return { x: event.deltaX * line, y: event.deltaY * line };
50 }
51
52 function overflowAllowsScroll(value: string): boolean {
53 return value === "auto" || value === "scroll" || value === "overlay";
54 }
55
56 /** True when the element is a CSS scroll container that can still move in deltaY. */
57 export function canElementScrollVertically(el: HTMLElement, deltaY: number): boolean {
58 const style = typeof getComputedStyle === "function" ? getComputedStyle(el) : null;
59 if (style && !overflowAllowsScroll(style.overflowY) && !overflowAllowsScroll(style.overflowX)) {
60 // overflow-x:auto forces overflow-y to compute as auto in CSS; if both are
61 // visible, this is not a scroll container.
62 return false;
63 }
64 const overflow = el.scrollHeight - el.clientHeight;
65 if (overflow <= EDGE_EPSILON_PX) return false;
66 if (deltaY < 0) return el.scrollTop > EDGE_EPSILON_PX;
67 if (deltaY > 0) return el.scrollTop + el.clientHeight < el.scrollHeight - EDGE_EPSILON_PX;
68 return false;
69 }
70
71 /**
72 * Walk from the event target up to (but not including) parent and return the
73 * nearest element that can absorb this vertical delta. Prefers explicitly
74 * marked `[data-nested-scroll]` nodes when they are on the path.
75 */
76 export function findVerticalScrollTarget(
77 target: EventTarget | null,
78 parent: HTMLElement,
79 deltaY: number,
80 ): HTMLElement | null {
81 if (!(target instanceof Element)) return null;
82 let node: Element | null = target;
83 while (node && node !== parent) {
84 if (node instanceof HTMLElement && node !== parent) {
85 if (canElementScrollVertically(node, deltaY)) return node;
86 }
87 node = node.parentElement;
88 }
89 return null;
90 }
91
92 /**
93 * True when the event target sits under a nested overflow container that would
94 * steal vertical trackpad gestures even though it cannot scroll further in Y.
95 * Those are the "sticky table / sticky code" cases from the mac recording.
96 */
97 export function shouldHandoffVerticalWheel(
98 target: EventTarget | null,
99 parent: HTMLElement,
100 deltaY: number,
101 ): boolean {
102 if (!(target instanceof Element) || !parent.contains(target) || target === parent) return false;
103
104 // Explicit markers always participate in handoff at their edge.
105 const marked = target.closest(`[${NESTED_SCROLL_ATTR}]`);
106 if (marked instanceof HTMLElement && parent.contains(marked) && marked !== parent) {
107 return !canElementScrollVertically(marked, deltaY);
108 }
109
110 // Any intermediate overflow container that cannot scroll in this direction
111 // will latch the trackpad; promote those wheels to the parent.
112 let node: Element | null = target;
113 while (node && node !== parent) {
114 if (node instanceof HTMLElement) {
115 const style = typeof getComputedStyle === "function" ? getComputedStyle(node) : null;
116 if (style && (overflowAllowsScroll(style.overflowY) || overflowAllowsScroll(style.overflowX))) {
117 // Found a nested scrollport. If it can still scroll in Y, leave it alone;
118 // otherwise the gesture would stall on this node.
119 return !canElementScrollVertically(node, deltaY);
120 }
121 }
122 node = node.parentElement;
123 }
124 return false;
125 }
126
127 /**
128 * Attach a capture-phase wheel listener on `parent` that handoffs edge wheels
129 * from nested overflow containers to the parent. Returns a detach function.
130 */
131 export function attachNestedScrollHandoff(options: NestedScrollHandoffOptions): NestedScrollHandoff {
132 const {
133 parent,
134 onParentScrollIntent,
135 writeParentOffset,
136 latchHoldMs = 220,
137 now = () => Date.now(),
138 } = options;
139
140 let latchUntil = 0;
141
142 const onWheel = (event: WheelEvent) => {
143 // Pinch-zoom is synthesized as ctrl+wheel on macOS trackpads.
144 if (event.ctrlKey || event.defaultPrevented) return;
145 const delta = normalizeWheelDelta(event, parent);
146 if (delta.y === 0) return;
147 if (Math.abs(delta.x) > Math.abs(delta.y)) return;
148
149 const t = now();
150 const latched = latchUntil > t;
151
152 // After the first edge handoff, keep driving the parent for the rest of
153 // this trackpad gesture so the user does not re-latch into the nested box.
154 if (latched) {
155 event.preventDefault();
156 onParentScrollIntent?.(delta.y);
157 writeParentOffset(parent.scrollTop + delta.y);
158 latchUntil = t + latchHoldMs;
159 return;
160 }
161
162 // Nested scroller can still absorb this delta — let the browser handle it.
163 if (findVerticalScrollTarget(event.target, parent, delta.y)) return;
164
165 if (!shouldHandoffVerticalWheel(event.target, parent, delta.y)) return;
166
167 event.preventDefault();
168 onParentScrollIntent?.(delta.y);
169 writeParentOffset(parent.scrollTop + delta.y);
170 latchUntil = t + latchHoldMs;
171 };
172
173 parent.addEventListener("wheel", onWheel, { capture: true, passive: false });
174 return {
175 detach: () => parent.removeEventListener("wheel", onWheel, { capture: true } as AddEventListenerOptions),
176 };
177 }
178
178 lines TYPESCRIPT