Files
buzz/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts
Tyler b0ad2b66dd Retain a stable message window in channel timelines (#1698)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
2026-07-12 21:41:38 -04:00

144 lines
4.4 KiB
TypeScript

import * as React from "react";
const BOUNDARY_EPSILON_PX = 1;
const CONVERSATION_SCROLL_SELECTOR = "[data-buzz-conversation-scroll]";
const SCROLLABLE_OVERFLOW_VALUES = new Set(["auto", "scroll", "overlay"]);
function isHTMLElement(value: EventTarget | null): value is HTMLElement {
return value instanceof HTMLElement;
}
function isDocumentElement(element: HTMLElement) {
return element === document.body || element === document.documentElement;
}
function isScrollableY(element: HTMLElement) {
if (isDocumentElement(element)) {
return false;
}
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW_VALUES.has(style.overflowY)) {
return false;
}
return element.scrollHeight > element.clientHeight + BOUNDARY_EPSILON_PX;
}
function isScrollableX(element: HTMLElement) {
if (isDocumentElement(element)) {
return false;
}
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW_VALUES.has(style.overflowX)) {
return false;
}
return element.scrollWidth > element.clientWidth + BOUNDARY_EPSILON_PX;
}
function canScrollY(element: HTMLElement, deltaY: number) {
if (deltaY < 0) {
return element.scrollTop > BOUNDARY_EPSILON_PX;
}
const maxScrollTop = element.scrollHeight - element.clientHeight;
return element.scrollTop < maxScrollTop - BOUNDARY_EPSILON_PX;
}
function canScrollX(element: HTMLElement, deltaX: number) {
if (deltaX < 0) {
return element.scrollLeft > BOUNDARY_EPSILON_PX;
}
const maxScrollLeft = element.scrollWidth - element.clientWidth;
return element.scrollLeft < maxScrollLeft - BOUNDARY_EPSILON_PX;
}
function isConversationScroller(element: HTMLElement) {
return Boolean(element.closest(CONVERSATION_SCROLL_SELECTOR));
}
/**
* Stops macOS/WKWebView rubber-band gestures from escaping into the viewport.
*
* Buzz is laid out as fixed-height nested panes. On macOS, a wheel/trackpad
* gesture that starts over a non-scrollable pane (or over a scrollable pane at
* its boundary) can still be handed to the WKWebView viewport, which rubber-
* bands the entire app and reveals a blank strip beside the UI. CSS
* `overscroll-behavior` is not enough for all of the empty/header/footer hit
* targets in the webview, so this capture listener consumes only gestures that
* otherwise have nowhere app-local to scroll. Both axes are locked: vertical
* and horizontal pans are each checked against containers that can actually
* move in that direction.
*
* Real scrolling is left alone: if any scroll container under the pointer can
* move in the wheel direction, the browser handles it normally. At vertical
* boundaries, only containers marked with `data-buzz-conversation-scroll` are
* allowed to receive the gesture so their own local elastic affordance can
* remain; every other boundary — including all horizontal ones — is locked and
* cannot chain to the viewport.
*/
export function useWebviewScrollBoundaryLock(enabled = true) {
React.useEffect(() => {
if (!enabled) return;
function handleWheel(event: WheelEvent) {
if (event.defaultPrevented || event.ctrlKey) {
return;
}
const { deltaX, deltaY } = event;
if (deltaX === 0 && deltaY === 0) {
return;
}
const path = event.composedPath();
let firstScrollable: HTMLElement | null = null;
for (const target of path) {
if (!isHTMLElement(target)) {
continue;
}
const scrollableY = deltaY !== 0 && isScrollableY(target);
const scrollableX = deltaX !== 0 && isScrollableX(target);
if (!scrollableY && !scrollableX) {
continue;
}
firstScrollable ??= target;
if (
(scrollableY && canScrollY(target, deltaY)) ||
(scrollableX && canScrollX(target, deltaX))
) {
return;
}
}
// Only the vertical elastic affordance of conversation scrollers is
// preserved; a predominantly horizontal gesture must never pan the
// webview, even over a conversation pane.
if (
firstScrollable &&
isConversationScroller(firstScrollable) &&
Math.abs(deltaY) >= Math.abs(deltaX)
) {
return;
}
event.preventDefault();
event.stopPropagation();
}
window.addEventListener("wheel", handleWheel, {
capture: true,
passive: false,
});
return () => {
window.removeEventListener("wheel", handleWheel, { capture: true });
};
}, [enabled]);
}