diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index bdab47a9..a07ec773 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -19843,8 +19843,39 @@ function keyboardFocus(e) { if ((r.top < b.top || r.bottom > b.bottom) && scrollOnPurpose(e, 400)) focused.scrollIntoView({ block: "nearest" }); })); } -addEventListener("wheel", () => readerScrolls(250), { capture: true, passive: true }); -addEventListener("touchmove", () => readerScrolls(250), { capture: true, passive: true }); +// A wheel or a touch can scroll the view before the page is told of it: the +// browser scrolls off the main thread, and a busy page gets the scroll event +// first and the wheel after (WebKit, a loaded Mac). That scroll was put back +// as no one's, and the reader lost a wheel step. What was put back is kept +// for a moment, and a wheel or touch that came in before it was put back, the +// same way, gives it back to the reader. +let putBack = []; // { v, by: px put back, at }, the last second's +function puttingBack(v, from) { + const by = from - v.scrollTop, at = performance.now(); + putBack = putBack.filter((p) => at - p.at < 1000); + if (Math.abs(by) >= 1) putBack.push({ v, by, at }); +} +// Given back is what was put back after the reader's input was made (its +// timeStamp is when it was made, not when the page was told) and went the +// way it goes: not a scroll by code from before it. +function givesBack(e, dir) { + if (!e.isTrusted || !dir) return; + const now = performance.now(), mine = (p) => p.at >= e.timeStamp - 50 && now - p.at < 1000 && Math.sign(p.by) === dir && !p.v.hidden; + const back = putBack.filter(mine); + putBack = putBack.filter((p) => !mine(p)); + for (const p of back) p.v.scrollTop += p.by; +} +let touchY = null; +addEventListener("wheel", (e) => { readerScrolls(250); givesBack(e, Math.sign(e.deltaY)); }, { capture: true, passive: true }); +addEventListener("touchstart", (e) => { touchY = e.touches[0]?.clientY ?? null; }, { capture: true, passive: true }); +addEventListener("touchmove", (e) => { + readerScrolls(250); + const y = e.touches[0]?.clientY; + if (y == null) return; + // a finger going up scrolls the view down + if (touchY != null) givesBack(e, Math.sign(touchY - y)); + touchY = y; +}, { capture: true, passive: true }); // A flick goes on scrolling after the finger is lifted, with no touch event // to say so: each of its scrolls keeps the next one the reader's, till it // comes to rest. Put back, a phone's page jerked to and fro under the @@ -20036,9 +20067,11 @@ for (const v of document.querySelectorAll(".view")) { v.tabIndex = -1; v.addEventListener("scroll", () => { if (v.hidden) return; - if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); } - else if (held?.v === v) hold(held); + if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); return; } + const from = v.scrollTop; + if (held?.v === v) hold(held); else backToReader(v); + puttingBack(v, from); }, { passive: true }); } diff --git a/internal/gui/tests/click-scroll.test.cjs b/internal/gui/tests/click-scroll.test.cjs index e6e7e2e0..99ec0e0c 100644 --- a/internal/gui/tests/click-scroll.test.cjs +++ b/internal/gui/tests/click-scroll.test.cjs @@ -252,6 +252,44 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert((await page.locator(view).evaluate((v) => v.scrollTop)) > was + 100, "the wheel must scroll the view"); }); + // A busy Mac scrolls the view off the main thread and tells the page of + // the wheel only after the scroll (WebKit under load, panel-arrange's + // wheel to the foot put back to 0). The app's own wheel listener is told + // late here, with the browser's own trusted event, as it is then. + await t.test("a wheel the page hears of after its scroll is still the reader's", async () => { + await context.addInitScript(() => { + const add = EventTarget.prototype.addEventListener; + window.addEventListener = function (type, fn, o) { + if (type !== "wheel") return add.call(this, type, fn, o); + return add.call(this, type, (e) => (window.__lateWheel ? setTimeout(() => fn(e), 60) : fn(e)), o); + }; + }); + await reset(); + await page.locator(view).evaluate((v) => { const s = document.createElement("div"); s.style.cssText = "flex:none;height:1600px"; v.append(s); }); + await page.evaluate(() => { window.__lateWheel = true; }); + const box = await page.locator(view).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + 60); + const scrollTop = () => page.locator(view).evaluate((v) => v.scrollTop); + for (const click of [false, true]) { + // with nothing clicked, and with a click held + if (click) await dayButtons.nth(1).click(); + const was = await scrollTop(); + await page.mouse.wheel(0, 300); + await page.waitForTimeout(400); + assert((await scrollTop()) > was + 100, `the late-heard wheel must scroll the view${click ? " after a click" : ""}`); + } + // a scroll by code is still put back, the wheel that follows it not + // giving it to the reader + const was = await scrollTop(); + await page.locator(view).evaluate((v) => { v.scrollTop -= 200; }); + await page.waitForTimeout(100); + assert.equal(await scrollTop(), was, "code's scroll is put back"); + await page.mouse.wheel(0, -40); + await page.waitForTimeout(400); + assert(Math.abs((await scrollTop()) - (was - 40)) <= 1, "the wheel moves the view by its own step only"); + await page.evaluate(() => { window.__lateWheel = false; }); + }); + assert.deepEqual(errors, [], "page runtime errors"); }); }