73 lines
2.5 KiB
TypeScript
73 lines
2.5 KiB
TypeScript
/**
|
|
* Nested scroll sections favor the innermost one under the cursor.
|
|
*
|
|
* opentui bubbles a wheel event up the renderable tree, so every ancestor
|
|
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
|
|
* the episode-description scrollbox inside a page's list pane) scroll in
|
|
* lockstep. This patches the scrollbox's wheel handler so the innermost
|
|
* scrollbox under the cursor wins instead:
|
|
*
|
|
* • The first scrollbox that can move in the wheel's direction scrolls and
|
|
* stops propagation, so its ancestors don't also scroll.
|
|
* • When it is already at its boundary it lets the next outer scrollbox
|
|
* take over (wheel chaining), matching typical nested-scroll UX.
|
|
*/
|
|
|
|
import { ScrollBoxRenderable } from "@opentui/core";
|
|
import type { MouseEvent } from "@opentui/core";
|
|
|
|
type ScrollDir = "up" | "down" | "left" | "right";
|
|
|
|
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
|
const original = ScrollBoxRenderable.prototype.onMouseEvent;
|
|
|
|
let installed = false;
|
|
|
|
/** True when `sb` has room to move in `dir` from its current position. */
|
|
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
|
|
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
|
|
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
|
|
switch (dir) {
|
|
case "up":
|
|
return sb.scrollTop > 0;
|
|
case "down":
|
|
return sb.scrollTop < maxTop;
|
|
case "left":
|
|
return sb.scrollLeft > 0;
|
|
case "right":
|
|
return sb.scrollLeft < maxLeft;
|
|
}
|
|
}
|
|
|
|
const handleWheel = function (
|
|
this: ScrollBoxRenderable,
|
|
event: MouseEvent,
|
|
): void {
|
|
if (event.type !== "scroll" || !event.scroll?.direction) {
|
|
original.call(this, event);
|
|
return;
|
|
}
|
|
|
|
const dir = event.scroll.direction;
|
|
const effective: ScrollDir = event.modifiers.shift
|
|
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
|
|
: dir;
|
|
|
|
const moves = canScroll(this, effective);
|
|
original.call(this, event);
|
|
// Only claim the wheel when this box actually moved; otherwise let the
|
|
// next outer scrollbox (also under the cursor) take over.
|
|
if (moves) event.stopPropagation();
|
|
};
|
|
|
|
export function installNestedScrollBehavior(): void {
|
|
if (installed || typeof original !== "function") return;
|
|
installed = true;
|
|
// `onMouseEvent` is a well-known protected method; the cast only bypasses
|
|
// TypeScript's protected-access check and trusts the shipped class shape.
|
|
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
|
|
onMouseEvent: typeof handleWheel;
|
|
};
|
|
scrollboxProto.onMouseEvent = handleWheel;
|
|
}
|