| 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 |