From f2fd4db81ce893293a607ad2efd80debc3cec929 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 15 Oct 2026 13:44:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20reader=20chrome=20UX=20=E2=80=94?= =?UTF-8?q?=20intent-based=20reveal,=20reading=20actions=20dismiss=20(hand?= =?UTF-8?q?off=20item=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chrome previously woke on ANY activity: pointermove (mouse drift and, worse, the touch drag of a page turn itself), every keypress, and every touch tap toggled it. Now reveal requires explicit UI intent and reading actions suppress: - Desktop: cursor into the top 10% / bottom 8% band raises the chrome (screenY-based so content iframes classify identically); content pointer movement never does. Mouse center-clicks no longer toggle. - Touch: a vertical flick (dominant + fast) reveals; while visible, flick down OR up dismisses. Plain taps are fully inert — a stray tap can never flash the UI. - Keyboard: page keys never poke; turning (keys, mouse edge zones, or a touch swipe via the snap-relocate) actively dismisses visible chrome. - Highlight saves return to a clean screen: the selection flow owns the screen and the post-save pointer/keyboard pokes are gone. - Chrome starts hidden; always-visible pin and auto-hide timer remain (bands use a shorter 1.2s fade). Pairs with the foliate-js 0.5.4 paginator change (scrolled layouts scroll manually so the flick gesture is observable in webtoon); the fork pin bump follows once the gitea mirror shows f622841. --- web/src/reader/reader.ts | 105 ++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 4cc4965c..3a2c212a 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -758,9 +758,15 @@ document.addEventListener("alpine:init", () => { doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); - // Pointer activity inside page iframes should keep the chrome awake + // Edge bands work inside page documents too — screenY keeps the + // check viewport-relative even when the iframe is content-sized // (fixed-layout pages and EPUB sections are separate documents). - doc.addEventListener("pointermove", () => this.pokeChrome(), { + doc.addEventListener("pointermove", (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + const vy = e.screenY - window.screenY; + if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) + this.pokeChrome(1200); + }, { passive: true, }); // Tap zones inside the page document (iframe events never bubble @@ -918,6 +924,10 @@ document.addEventListener("alpine:init", () => { this.renderAllHighlights(); }); this.view.addEventListener("relocate", (e: any) => { + // A touch swipe page turn ends in a snap-relocate: reading + // intent — dismiss the chrome (guards keep drawers, selections, + // and pinned mode up). + if (e.detail?.reason === "snap") this.hideChromeNow(); const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; // Only dismiss the popover on an actual position change — the @@ -1036,12 +1046,21 @@ document.addEventListener("alpine:init", () => { this.setupTapZones(); }, // Chrome visibility: bars overlay the edge-to-edge reading surface. - // 'auto-hide' keeps them up while the pointer is active and fades them - // out after a quiet period; 'always-visible' pins them. + // 'auto-hide' reveals them ONLY from explicit UI intent — cursor in + // the top/bottom edge bands (desktop) or the flick-down gesture + // (touch) — and every reading action (page turns, selections, + // highlight saves) dismisses or never raises them. 'always-visible' + // pins them. setupChrome() { - document.addEventListener("pointermove", () => this.pokeChrome(), { - passive: true, - }); + // Band check via screenY so it works identically for the host + // document and content iframes (iframe clientY is content-space, + // which is unrelated to the viewport in scrolled layouts). + document.addEventListener("pointermove", (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + const vy = e.screenY - window.screenY; + if (vy <= window.innerHeight * 0.1 || vy >= window.innerHeight * 0.92) + this.pokeChrome(1200); + }, { passive: true }); // Dismiss the selection popover and tools menu on clicks outside them // (iframe clicks are covered by the selection tracker's collapsed // check; the ⋯ toggle is exempt so its own click re-toggles cleanly). @@ -1060,9 +1079,10 @@ document.addEventListener("alpine:init", () => { this.chromeVisible = true; return; } - this.pokeChrome(); + // Chrome starts hidden: reveal is an explicit gesture (edge band / + // flick down), never a side effect of loading. }, - pokeChrome() { + pokeChrome(hideAfterMs = 2500) { // Never raise the chrome while a touch selection is live — the // selection popover owns the screen until the selection is // consumed or dismissed. @@ -1073,7 +1093,18 @@ document.addEventListener("alpine:init", () => { this.hideTimer = setTimeout(() => { if (this.anyDrawerOpen() || this.chromeHasFocus()) return; this.chromeVisible = false; - }, 2500); + }, hideAfterMs); + }, + // Reading intent: dismiss the chrome immediately. Respects pinned + // mode and anything that owns the screen. + hideChromeNow() { + if (this.chromeBehavior !== "auto-hide") return; + if (this.anyDrawerOpen() || this.anySelection) return; + this.chromeVisible = false; + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = null; + } }, chromeHasFocus(): boolean { const el = document.activeElement; @@ -1182,7 +1213,31 @@ document.addEventListener("alpine:init", () => { (e: PointerEvent) => { if (e.pointerId !== downId) return; downId = -1; - if (moved || longPressed || Date.now() - downT > 500) return; + // Flick-down/up chrome gesture (touch only): vertical-dominant + // and fast. Down reveals a hidden chrome; while visible, down + // OR up dismisses (up reads as "push it away"). Reading + // gestures never look like this: side swipes (page turns) are + // horizontal, long-press selection engages slowly, and zoomed + // pages already bail out of tap routing. + const dy = e.clientY - downY; + const dx = e.clientX - downX; + const dt = Date.now() - downT; + if ( + e.pointerType !== "mouse" && + !this.anySelection && + dt < 400 && + Math.abs(dy) > 48 && + Math.abs(dy) > Math.abs(dx) * 1.8 + ) { + if (this.tapZoneTimer) { + clearTimeout(this.tapZoneTimer); + this.tapZoneTimer = null; + } + if (!this.chromeVisible) this.pokeChrome(); + else this.hideChromeNow(); + return; + } + if (moved || longPressed || dt > 500) return; if (!this.tapZonesEnabled) return; const target = e.target as HTMLElement | null; if ( @@ -1235,27 +1290,17 @@ document.addEventListener("alpine:init", () => { } this.tapZoneTimer = setTimeout(() => { this.tapZoneTimer = null; - // Touch pages by swipe only (the paginator's pan + snap); a tap - // anywhere just toggles the chrome. The edge zones remain for - // mouse-driven taps. - if (window.matchMedia("(pointer: coarse)").matches) { - this.toggleChromeManually(); - return; - } + // Touch: taps are fully inert — paging is swipe-only and the + // chrome is flick-down/up territory (handoff item 7). A stray + // tap must never flash the UI. + if (window.matchMedia("(pointer: coarse)").matches) return; const zone = this.tapZoneSize / 100; if (relX < zone) this.goLeft(); else if (relX > 1 - zone) this.goRight(); - else this.toggleChromeManually(); + // Mouse taps in the content are content (links, selection) — + // they never raise the chrome; the edge bands own that. }, 280); }, - toggleChromeManually() { - if (this.chromeVisible) { - this.chromeVisible = false; - if (this.hideTimer) clearTimeout(this.hideTimer); - } else { - this.pokeChrome(); - } - }, applyTapZoneSettings() { saveSettings({ tap_zones_enabled: this.tapZonesEnabled, @@ -3194,9 +3239,12 @@ document.addEventListener("alpine:init", () => { }, goLeft() { this.view?.goLeft?.(); + // A page turn is reading intent: dismiss the chrome. + this.hideChromeNow(); }, goRight() { this.view?.goRight?.(); + this.hideChromeNow(); }, nextPage() { this.view?.next?.(); @@ -4076,7 +4124,8 @@ document.addEventListener("alpine:init", () => { t?.tagName === "SELECT" || t?.tagName === "TEXTAREA" || !!t?.isContentEditable; - this.pokeChrome(); + // NOTE: no chrome poke here — keyboard page turns are reading + // actions and must leave the chrome alone (handoff item 7). if (typing && k !== "Escape") return; if (k === "ArrowLeft" || k === "h") { if (event.altKey) {