feat(web): reader chrome UX — intent-based reveal, reading actions dismiss (handoff item 7)
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.
This commit is contained in:
1 parent
2517baedea
commit
f2fd4db81c
1 file changed
+77
-28
+77
-28
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user