mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
fix(desktop): lock horizontal webview pan (Magic Mouse side-scroll) (#1480)
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -25,6 +25,19 @@ function isScrollableY(element: HTMLElement) {
|
||||
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;
|
||||
@@ -34,6 +47,15 @@ function canScrollY(element: HTMLElement, deltaY: number) {
|
||||
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));
|
||||
}
|
||||
@@ -44,21 +66,29 @@ function isConversationScroller(element: HTMLElement) {
|
||||
* 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 above/below the UI. CSS
|
||||
* 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.
|
||||
* 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 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 is locked and cannot chain to the viewport.
|
||||
* 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() {
|
||||
React.useEffect(() => {
|
||||
function handleWheel(event: WheelEvent) {
|
||||
if (event.defaultPrevented || event.deltaY === 0 || event.ctrlKey) {
|
||||
if (event.defaultPrevented || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { deltaX, deltaY } = event;
|
||||
if (deltaX === 0 && deltaY === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,17 +96,33 @@ export function useWebviewScrollBoundaryLock() {
|
||||
let firstScrollable: HTMLElement | null = null;
|
||||
|
||||
for (const target of path) {
|
||||
if (!isHTMLElement(target) || !isScrollableY(target)) {
|
||||
if (!isHTMLElement(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scrollableY = deltaY !== 0 && isScrollableY(target);
|
||||
const scrollableX = deltaX !== 0 && isScrollableX(target);
|
||||
if (!scrollableY && !scrollableX) {
|
||||
continue;
|
||||
}
|
||||
|
||||
firstScrollable ??= target;
|
||||
if (canScrollY(target, event.deltaY)) {
|
||||
if (
|
||||
(scrollableY && canScrollY(target, deltaY)) ||
|
||||
(scrollableX && canScrollX(target, deltaX))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstScrollable && isConversationScroller(firstScrollable)) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,20 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* The app is a fixed-height shell — the document itself must never scroll
|
||||
* or rubber-band. `overscroll-behavior: none` stops scroll chaining from
|
||||
* inner containers reaching the viewport on both axes (the Magic Mouse
|
||||
* horizontal pan escaped through here), and `overflow: hidden` removes any
|
||||
* document-level scrollability. useWebviewScrollBoundaryLock handles the
|
||||
* wheel-event side; this is the CSS backstop.
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
async function dispatchWheelPrevented(
|
||||
page: import("@playwright/test").Page,
|
||||
selector: string,
|
||||
deltaY: number,
|
||||
deltas: { deltaX?: number; deltaY?: number },
|
||||
) {
|
||||
return page.evaluate(
|
||||
({ selector, deltaY }) => {
|
||||
({ selector, deltaX, deltaY }) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element for selector: ${selector}`);
|
||||
@@ -17,12 +17,13 @@ async function dispatchWheelPrevented(
|
||||
const event = new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaX,
|
||||
deltaY,
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
},
|
||||
{ selector, deltaY },
|
||||
{ selector, deltaX: deltas.deltaX ?? 0, deltaY: deltas.deltaY ?? 0 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,23 +39,68 @@ test("locks viewport rubber-band outside conversation scrollers", async ({
|
||||
await expect(page.getByTestId("message-timeline")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', -120),
|
||||
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', -120),
|
||||
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
dispatchWheelPrevented(
|
||||
page,
|
||||
'[data-testid="app-sidebar-scroll-anchor"]',
|
||||
-120,
|
||||
),
|
||||
dispatchWheelPrevented(page, '[data-testid="app-sidebar-scroll-anchor"]', {
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="chat-title"]', -120),
|
||||
dispatchWheelPrevented(page, '[data-testid="chat-title"]', {
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', -120),
|
||||
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("locks horizontal viewport pan everywhere", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("message-timeline")).toBeVisible();
|
||||
|
||||
for (const deltaX of [-120, 120]) {
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
|
||||
deltaX,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
|
||||
deltaX,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="chat-title"]', { deltaX }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// Unlike vertical, horizontal pans over the conversation pane are locked
|
||||
// too — there is no horizontal elastic affordance.
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
|
||||
deltaX,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
|
||||
// A predominantly vertical gesture with slight horizontal drift still
|
||||
// reaches the conversation scroller.
|
||||
await expect(
|
||||
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
|
||||
deltaX: -10,
|
||||
deltaY: -120,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user