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) =>
|
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||||
this.handleKeydown(ev),
|
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).
|
// (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,
|
passive: true,
|
||||||
});
|
});
|
||||||
// Tap zones inside the page document (iframe events never bubble
|
// Tap zones inside the page document (iframe events never bubble
|
||||||
@@ -918,6 +924,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderAllHighlights();
|
this.renderAllHighlights();
|
||||||
});
|
});
|
||||||
this.view.addEventListener("relocate", (e: any) => {
|
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 } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
e.detail;
|
||||||
// Only dismiss the popover on an actual position change — the
|
// Only dismiss the popover on an actual position change — the
|
||||||
@@ -1036,12 +1046,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.setupTapZones();
|
this.setupTapZones();
|
||||||
},
|
},
|
||||||
// Chrome visibility: bars overlay the edge-to-edge reading surface.
|
// Chrome visibility: bars overlay the edge-to-edge reading surface.
|
||||||
// 'auto-hide' keeps them up while the pointer is active and fades them
|
// 'auto-hide' reveals them ONLY from explicit UI intent — cursor in
|
||||||
// out after a quiet period; 'always-visible' pins them.
|
// 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() {
|
setupChrome() {
|
||||||
document.addEventListener("pointermove", () => this.pokeChrome(), {
|
// Band check via screenY so it works identically for the host
|
||||||
passive: true,
|
// 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
|
// Dismiss the selection popover and tools menu on clicks outside them
|
||||||
// (iframe clicks are covered by the selection tracker's collapsed
|
// (iframe clicks are covered by the selection tracker's collapsed
|
||||||
// check; the ⋯ toggle is exempt so its own click re-toggles cleanly).
|
// check; the ⋯ toggle is exempt so its own click re-toggles cleanly).
|
||||||
@@ -1060,9 +1079,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.chromeVisible = true;
|
this.chromeVisible = true;
|
||||||
return;
|
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
|
// Never raise the chrome while a touch selection is live — the
|
||||||
// selection popover owns the screen until the selection is
|
// selection popover owns the screen until the selection is
|
||||||
// consumed or dismissed.
|
// consumed or dismissed.
|
||||||
@@ -1073,7 +1093,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.hideTimer = setTimeout(() => {
|
this.hideTimer = setTimeout(() => {
|
||||||
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
|
if (this.anyDrawerOpen() || this.chromeHasFocus()) return;
|
||||||
this.chromeVisible = false;
|
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 {
|
chromeHasFocus(): boolean {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
@@ -1182,7 +1213,31 @@ document.addEventListener("alpine:init", () => {
|
|||||||
(e: PointerEvent) => {
|
(e: PointerEvent) => {
|
||||||
if (e.pointerId !== downId) return;
|
if (e.pointerId !== downId) return;
|
||||||
downId = -1;
|
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;
|
if (!this.tapZonesEnabled) return;
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
@@ -1235,27 +1290,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.tapZoneTimer = setTimeout(() => {
|
this.tapZoneTimer = setTimeout(() => {
|
||||||
this.tapZoneTimer = null;
|
this.tapZoneTimer = null;
|
||||||
// Touch pages by swipe only (the paginator's pan + snap); a tap
|
// Touch: taps are fully inert — paging is swipe-only and the
|
||||||
// anywhere just toggles the chrome. The edge zones remain for
|
// chrome is flick-down/up territory (handoff item 7). A stray
|
||||||
// mouse-driven taps.
|
// tap must never flash the UI.
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) {
|
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||||||
this.toggleChromeManually();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const zone = this.tapZoneSize / 100;
|
const zone = this.tapZoneSize / 100;
|
||||||
if (relX < zone) this.goLeft();
|
if (relX < zone) this.goLeft();
|
||||||
else if (relX > 1 - zone) this.goRight();
|
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);
|
}, 280);
|
||||||
},
|
},
|
||||||
toggleChromeManually() {
|
|
||||||
if (this.chromeVisible) {
|
|
||||||
this.chromeVisible = false;
|
|
||||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
|
||||||
} else {
|
|
||||||
this.pokeChrome();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
applyTapZoneSettings() {
|
applyTapZoneSettings() {
|
||||||
saveSettings({
|
saveSettings({
|
||||||
tap_zones_enabled: this.tapZonesEnabled,
|
tap_zones_enabled: this.tapZonesEnabled,
|
||||||
@@ -3194,9 +3239,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goLeft() {
|
goLeft() {
|
||||||
this.view?.goLeft?.();
|
this.view?.goLeft?.();
|
||||||
|
// A page turn is reading intent: dismiss the chrome.
|
||||||
|
this.hideChromeNow();
|
||||||
},
|
},
|
||||||
goRight() {
|
goRight() {
|
||||||
this.view?.goRight?.();
|
this.view?.goRight?.();
|
||||||
|
this.hideChromeNow();
|
||||||
},
|
},
|
||||||
nextPage() {
|
nextPage() {
|
||||||
this.view?.next?.();
|
this.view?.next?.();
|
||||||
@@ -4076,7 +4124,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
t?.tagName === "SELECT" ||
|
t?.tagName === "SELECT" ||
|
||||||
t?.tagName === "TEXTAREA" ||
|
t?.tagName === "TEXTAREA" ||
|
||||||
!!t?.isContentEditable;
|
!!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 (typing && k !== "Escape") return;
|
||||||
if (k === "ArrowLeft" || k === "h") {
|
if (k === "ArrowLeft" || k === "h") {
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
|
|||||||
Reference in new issue
Block a user