Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/web/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@
html { min-width: 320px; background: var(--shell-background); scroll-behavior: smooth; scrollbar-gutter: stable; }
/* A modal dialog does not stop the page behind it from scrolling, so a wheel
or trackpad flick slides the background out from under the dialog. The
gutter reserved above means freezing the document costs no sideways nudge. */
gutter reserved above means freezing the document costs no sideways nudge.
iOS Safari ignores overflow on the root for touch, so app.js also refuses
touch scrolls that would reach the page, and scrollers inside a modal keep
their overscroll to themselves. */
html:has(dialog[open]) { overflow: hidden; }
dialog[open] * { overscroll-behavior: contain; }
body { min-width: 320px; min-height: 100vh; margin: 0; background: var(--background); color: var(--foreground); font-size: 14px; }
body:has(.app-shell) { background: var(--shell-background); }
a { color: inherit; text-decoration: none; }
Expand Down
39 changes: 39 additions & 0 deletions internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3922,6 +3922,45 @@
syncSidebarToggleState();
});

// The page behind a modal stays put under a finger. The stylesheet's
// overflow: hidden on the root covers wheels and trackpads, but iOS Safari
// still scrolls the document by touch, and once it does the toolbar
// collapses and the drawer slides away with the page. A touch move is
// refused unless it lands in something inside the modal that can scroll
// along the gesture; those scrollers contain their own overscroll.
(() => {
const scrolls = (element, vertical) => {
const style = getComputedStyle(element);
const overflow = vertical ? style.overflowY : style.overflowX;
if (overflow !== "auto" && overflow !== "scroll") return false;
return vertical ? element.scrollHeight > element.clientHeight + 1 : element.scrollWidth > element.clientWidth + 1;
};
const ownsScroll = (target, dialog, vertical) => {
for (let element = target; element && element !== dialog.parentElement; element = element.parentElement) {
if (scrolls(element, vertical)) return true;
if (element === dialog) break;
}
return false;
};
let start = null;
document.addEventListener("touchstart", (event) => {
const touch = event.touches[0];
start = touch ? { x: touch.clientX, y: touch.clientY } : null;
}, { passive: true });
document.addEventListener("touchmove", (event) => {
if (!event.cancelable || event.touches.length !== 1 || !start) return;
const modal = [...document.querySelectorAll("dialog[open]")].find((dialog) => dialog.matches(":modal"));
if (!modal) return;
const target = event.target instanceof Element ? event.target : null;
const inside = target && modal.contains(target) ? target : null;
// Text fields and range inputs own their drags.
if (inside?.closest("input, textarea, select, [contenteditable]")) return;
const touch = event.touches[0];
const vertical = Math.abs(touch.clientY - start.y) >= Math.abs(touch.clientX - start.x);
if (!inside || !ownsScroll(inside, modal, vertical)) event.preventDefault();
}, { passive: false });
})();

// On phones a drag to the right anywhere on the page pulls the drawer out
// with the finger, as Slack and Discord do. It never claims the screen
// edge: iOS Safari plays its back gesture there before the page hears of
Expand Down
35 changes: 35 additions & 0 deletions internal/web/modal_scroll_lock_browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//go:build browser

package web

import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"testing"

webassets "github.com/drudge/sable/internal/web/assets"
"github.com/drudge/sable/internal/web/pages"
)

// The page behind an open modal does not scroll under a finger, while
// scrollers inside the modal keep their own touch scrolling.
func TestBrowserModalScrollLock(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("/assets/", webassets.Handler())
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
view := pages.CachePageView{Console: pages.DashboardView{CSRFToken: "fixture-csrf"}, Entries: 1000}
_ = pages.CachePage(view).Render(r.Context(), w)
})
server := httptest.NewServer(secureHeaders(mux, false))
defer server.Close()
command := exec.Command("node", "../../scripts/browser/modal-scroll-lock.cjs", server.URL)
command.Env = os.Environ()
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("browser modal scroll lock: %v\n%s", err, output)
} else {
t.Log(string(output))
}
}
70 changes: 70 additions & 0 deletions scripts/browser/modal-scroll-lock.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const assert = require('node:assert/strict');
const {chromium} = require('playwright');

// Same real-touch helper as nav-swipe.cjs: the browser decides on scrolling
// the way a phone does.
const drag = async (cdp, from, to, steps = 12) => {
const at = ([x, y]) => [{x, y, id: 1, radiusX: 4, radiusY: 4, force: 1}];
let timestamp = Date.now() / 1000;
await cdp.send('Input.dispatchTouchEvent', {type: 'touchStart', touchPoints: at(from), timestamp});
for (let i = 1; i <= steps; i++) {
timestamp += 0.016;
await cdp.send('Input.dispatchTouchEvent', {type: 'touchMove', touchPoints: at([from[0] + (to[0] - from[0]) * i / steps, from[1] + (to[1] - from[1]) * i / steps]), timestamp});
}
await cdp.send('Input.dispatchTouchEvent', {type: 'touchEnd', touchPoints: [], timestamp: timestamp + 0.008});
// Let any fling finish: Chromium will not let a page cancel touches mid-fling.
await new Promise(resolve => setTimeout(resolve, 1200));
};
// Whether the page refused the last touch move: a window listener runs after
// the document-level guard has had its say.
const refused = page => page.evaluate(() => window.lastTouchRefused);

(async () => {
const browser = await chromium.launch({headless: true, ...(process.env.SABLE_TEST_BROWSER ? {executablePath: process.env.SABLE_TEST_BROWSER} : {})});
try {
const context = await browser.newContext({viewport: {width: 390, height: 844}, hasTouch: true, isMobile: true});
const page = await context.newPage();
await page.goto(process.argv[2]);
await page.evaluate(() => {
document.documentElement.style.scrollBehavior = 'auto';
const filler = document.createElement('div');
filler.style.height = '4000px';
document.getElementById('main-content').append(filler);
window.addEventListener('touchmove', event => { window.lastTouchRefused = event.defaultPrevented; }, {passive: true});
});
const cdp = await context.newCDPSession(page);

// With no modal open the page scrolls freely.
await drag(cdp, [195, 700], [195, 300]);
assert.equal(await refused(page), false, 'touch scrolls reach the page without a modal');

// A finger on the backdrop or on a part of the modal that cannot scroll
// moves nothing.
await page.evaluate(() => document.getElementById('cache-flush-dialog').showModal());
await drag(cdp, [195, 830], [195, 300]);
assert.equal(await refused(page), true, 'the backdrop does not scroll the page');
const title = await page.evaluate(() => { const r = document.querySelector('#cache-flush-dialog h2, #cache-flush-dialog [id$=title]').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; });
await drag(cdp, title, [title[0], title[1] - 200]);
assert.equal(await refused(page), true, 'a modal that cannot scroll does not hand the touch to the page');
await page.evaluate(() => document.getElementById('cache-flush-dialog').close());

// A scroller inside the modal keeps its own touch scroll, and contains it.
await page.evaluate(() => {
const dialog = document.getElementById('cache-browser-dialog');
const body = dialog.querySelector('.cache-browser-body');
const tall = document.createElement('div');
tall.style.height = '3000px';
body.append(tall);
dialog.showModal();
});
const inner = await page.evaluate(() => { const r = document.querySelector('#cache-browser-dialog .cache-browser-body').getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; });
await drag(cdp, inner, [inner[0], inner[1] - 150]);
assert.equal(await refused(page), false, 'the modal scroller takes the touch');
await page.waitForTimeout(200);
assert.ok(await page.evaluate(() => document.querySelector('#cache-browser-dialog .cache-browser-body').scrollTop) > 0, 'the modal scroller moves');
assert.equal(await page.evaluate(() => getComputedStyle(document.querySelector('#cache-browser-dialog .cache-browser-body')).overscrollBehaviorY), 'contain');
console.log('modal scroll lock: ok');
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exit(1); });
Loading