fix: Improve mobile layout and touch handling in reader interface
Some checks failed
test / lint-openapi (push) Has been cancelled
build / binary (push) Has been cancelled
build / container (push) Has been cancelled
test / test (push) Has been cancelled

* Adjusted safe area padding, touch target sizes, and fullscreen behavior across
mobile devices.
* Measured reader bar height dynamically to prevent UI overlaps during
orientation changes.
* Refined pointer and tap gesture tracking to prevent accidental triggers.

Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
This commit is contained in:
Chmouel Boudjnah 2026-10-02 11:55:18 +02:00
parent 14d5837ecd
commit d8000293ff
4 changed files with 96 additions and 25 deletions

View file

@ -5,6 +5,9 @@
justify-content: space-between;
gap: 1rem;
padding: 1rem max(1.25rem, calc((100% - 68rem) / 2));
padding-top: max(1rem, env(safe-area-inset-top, 0px));
padding-left: max(1.25rem, calc((100% - 68rem) / 2), env(safe-area-inset-left, 0px));
padding-right: max(1.25rem, calc((100% - 68rem) / 2), env(safe-area-inset-right, 0px));
border-bottom: 1px solid var(--line);
background: var(--surface);
}
@ -13,6 +16,9 @@
max-width: 68rem;
margin: 0 auto;
padding: 3rem 1.25rem;
padding-left: max(1.25rem, env(safe-area-inset-left, 0px));
padding-right: max(1.25rem, env(safe-area-inset-right, 0px));
padding-bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
}
.offline-shell .muted { font-size: .9rem; }

View file

@ -106,6 +106,17 @@ const promptFallback = document.getElementById("reader-prompt-fallback");
const promptFallbackText = document.getElementById("reader-prompt-fallback-text");
const promptFallbackCopy = document.getElementById("reader-prompt-fallback-copy");
// The bar grows with touch controls, safe areas and browser text sizing.
// Reserve its measured height so rotating an iPad cannot cover the book.
const readerBar = document.querySelector(".reader-bar");
if (readerBar) {
const sizeBar = () => document.body.style.setProperty(
"--reader-bar-height", `${readerBar.getBoundingClientRect().height}px`,
);
sizeBar();
new ResizeObserver(sizeBar).observe(readerBar);
}
let chapterLoadingTimer = null;
let chapterLoadingGeneration = 0;
const CHAPTER_LOADING_MIN_MS = 250;
@ -2956,28 +2967,42 @@ if (settingsForm) {
const MAXIMIZE_PATH = "M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3m10 0h3a2 2 0 002-2v-3";
const MINIMIZE_PATH = "M4 14h3a2 2 0 012 2v3m6 0v-3a2 2 0 012-2h3M20 10h-3a2 2 0 01-2-2V5m-6 0v3a2 2 0 01-2 2H4";
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {
flash("This browser would not switch to full screen.");
});
} else {
document.exitFullscreen();
const fullscreenRoot = document.documentElement;
const enterFullscreen = fullscreenRoot.requestFullscreen || fullscreenRoot.webkitRequestFullscreen;
const exitFullscreen = document.exitFullscreen || document.webkitExitFullscreen;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement;
const fullscreenAvailable = !!(enterFullscreen && exitFullscreen) &&
(document.fullscreenEnabled ?? document.webkitFullscreenEnabled ?? true);
async function toggleFullscreen() {
if (!fullscreenAvailable) {
flash("Full screen is unavailable. Add Liseur to your Home Screen for a full-screen reader.");
return;
}
try {
// Older iPad Safari exposes prefixed methods returning void, not a Promise.
if (fullscreenElement()) await exitFullscreen.call(document);
else await enterFullscreen.call(fullscreenRoot);
} catch (err) {
flash("This browser would not switch to full screen.");
}
}
if (fullscreenBtn) {
fullscreenBtn.hidden = !fullscreenAvailable;
fullscreenBtn.addEventListener("click", toggleFullscreen);
document.addEventListener("fullscreenchange", () => {
const updateFullscreen = () => {
const p = fullscreenBtn.querySelector("path");
if (document.fullscreenElement) {
if (fullscreenElement()) {
p.setAttribute("d", MINIMIZE_PATH);
fullscreenBtn.title = "Exit full screen (f)";
} else {
p.setAttribute("d", MAXIMIZE_PATH);
fullscreenBtn.title = "Full screen (f)";
}
});
};
document.addEventListener("fullscreenchange", updateFullscreen);
document.addEventListener("webkitfullscreenchange", updateFullscreen);
}
// ---------------------------------------------- chrome and tap zones
@ -2995,6 +3020,7 @@ if (fullscreenBtn) {
const CHROME_IDLE_MS = 2200;
const CHROME_TOUCH_MS = 4000;
const TAP_SLOP_PX = 10;
const TAP_MAX_MS = 500;
let chromePinned = false;
let chromeTimer = null;
@ -3204,7 +3230,13 @@ function overText(doc, x, y) {
// finger, a cancelled pointer or a stolen capture ends it: what happens
// next is a pinch, a scroll or the browser's business, never a tap.
function newGesture() {
return { id: null, x: 0, y: 0, spoiled: true, hadSelection: false };
return { id: null, x: 0, y: 0, startedAt: 0, spoiled: true, hadSelection: false };
}
function trackGesture(gesture, e) {
if (gesture.id !== e.pointerId) return;
if (Math.abs(e.clientX - gesture.x) > TAP_SLOP_PX ||
Math.abs(e.clientY - gesture.y) > TAP_SLOP_PX) gesture.spoiled = true;
}
function wireChapterPointer(doc) {
@ -3236,6 +3268,7 @@ function wireChapterPointer(doc) {
doc.addEventListener(
"pointermove",
(e) => {
trackGesture(gesture, e);
pointerMoved(...toViewport(doc, e.clientX, e.clientY), e.pointerType);
},
{ passive: true },
@ -3257,6 +3290,7 @@ function wireChapterPointer(doc) {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
startedAt: performance.now(),
spoiled: false,
hadSelection: selected(),
};
@ -3268,6 +3302,7 @@ function wireChapterPointer(doc) {
};
doc.addEventListener("pointercancel", spoil, { passive: true });
doc.addEventListener("lostpointercapture", spoil, { passive: true });
doc.addEventListener("contextmenu", () => { gesture.spoiled = true; }, { passive: true });
// A finger is answered here rather than on the click that should
// follow it: the engine snaps the page on every touchend, and a
// scroll between touchend and the synthesized click cancels that
@ -3288,6 +3323,7 @@ function wireChapterPointer(doc) {
gesture.id = null;
if (e.pointerType !== "touch" && e.pointerType !== "pen") return;
touchAt = Date.now();
if (performance.now() - gesture.startedAt > TAP_MAX_MS) gesture.spoiled = true;
if (tocPanel && !tocPanel.hidden) {
toggleTOC(false); // the drawer cannot hear a tap inside the book
gesture.spoiled = true;
@ -3783,6 +3819,7 @@ stageArea.addEventListener(
id: e.pointerId,
x: e.clientX,
y: e.clientY,
startedAt: performance.now(),
spoiled: false,
hadSelection: bookHasSelection(),
};
@ -3795,6 +3832,8 @@ const spoilStage = (e) => {
};
stageArea.addEventListener("pointercancel", spoilStage, { passive: true });
stageArea.addEventListener("lostpointercapture", spoilStage, { passive: true });
stageArea.addEventListener("pointermove", (e) => trackGesture(stageGesture, e), { passive: true });
stageArea.addEventListener("contextmenu", () => { stageGesture.spoiled = true; }, { passive: true });
stageArea.addEventListener("pointerup", (e) => {
noteActivity();
if (stageGesture.id !== null && stageGesture.id !== e.pointerId) {
@ -3816,6 +3855,7 @@ stageArea.addEventListener("pointerup", (e) => {
return;
}
if (!clean) return;
if (performance.now() - stageGesture.startedAt > TAP_MAX_MS) return;
if (stageGesture.hadSelection || bookHasSelection()) return;
if (!stageSurface(e.target)) return;
tapAt(e.clientX, e.clientY);

View file

@ -409,7 +409,7 @@ export class ReaderEngine extends HTMLElement {
normal: { optimalLineLength: 90, minimalLineLength: 60, maximalLineLength: 110 },
wide: { optimalLineLength: 65, minimalLineLength: 40, maximalLineLength: 80 },
}[settings.margin] || { optimalLineLength: 90, minimalLineLength: 60, maximalLineLength: 110 };
this.renderer.style.inset = `0 0 ${margin}px`;
this.renderer.style.inset = `0 env(safe-area-inset-right, 0px) calc(${margin}px + env(safe-area-inset-bottom, 0px)) env(safe-area-inset-left, 0px)`;
this.preferences = {
scroll: false,
columnCount: settings.columns === "auto" ? null : Number(settings.columns),

View file

@ -1353,7 +1353,7 @@ button.linkish:hover, button.linkish:focus-visible {
.reader-body[data-reader-margin="normal"]{--reader-margin:48px}
.reader-body[data-reader-margin="wide"]{--reader-margin:72px}
.reader-bar{position:fixed;top:3px;left:0;right:0;z-index:50;
display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr);
display:grid;grid-template-columns:minmax(max-content,1fr) minmax(0,1fr) minmax(max-content,1fr);
align-items:center;gap:.6rem;
padding:calc(.35rem + env(safe-area-inset-top, 0px)) max(.75rem, env(safe-area-inset-right, 0px)) .35rem max(.75rem, env(safe-area-inset-left, 0px));
border-bottom:1px solid var(--line);
@ -1370,7 +1370,7 @@ button.linkish:hover, button.linkish:focus-visible {
.reader-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
text-align:center;font-size:.95rem;letter-spacing:.005em}
.reader-title strong{font-weight:600}
.reader-actions{display:flex;align-items:center;justify-self:end;gap:.35rem;min-width:0}
.reader-actions{position:relative;display:flex;align-items:center;justify-self:end;gap:.35rem;min-width:0}
/* One shape for every control on the bar: TOC, Aa, full screen. */
.reader-icon-btn{display:inline-flex;align-items:center;justify-content:center;
box-sizing:border-box;width:2rem;height:2rem;padding:0;
@ -1389,7 +1389,7 @@ button.linkish:hover, button.linkish:focus-visible {
than its viewport grows off both edges at once, and the edge that
matters is the one carrying the title. */
#reader-prompt-fallback textarea{
box-sizing:border-box;width:100%;max-height:40dvh;font:inherit;font-size:.9rem;
box-sizing:border-box;width:100%;max-height:40vh;max-height:40dvh;font:inherit;font-size:.9rem;
margin:.6rem 0;padding:.5rem;border:1px solid var(--line);border-radius:8px;
background:var(--surface-2);color:inherit}
.reader-prompt-fallback-actions{margin-top:.2rem;display:flex;gap:.5rem;
@ -1550,7 +1550,7 @@ button.linkish:hover, button.linkish:focus-visible {
100%{transform:translateX(320%)}
}
.reader-chapter-loader{
position:fixed;z-index:60;top:3.8rem;left:50%;
position:fixed;z-index:60;top:calc(var(--reader-bar-height,3rem) + .8rem);left:50%;
transform:translate(-50%,-4px) scale(.96);
display:inline-flex;align-items:center;gap:.65rem;
padding:.45rem 1.05rem;font-size:.82rem;font-weight:500;
@ -1561,7 +1561,7 @@ button.linkish:hover, button.linkish:focus-visible {
box-shadow:0 4px 20px rgba(0,0,0,.16),0 1px 3px rgba(0,0,0,.08);
pointer-events:none;opacity:0;
transition:top .2s cubic-bezier(.16,1,.3,1),opacity .2s cubic-bezier(.16,1,.3,1),transform .2s cubic-bezier(.16,1,.3,1)}
.reader-body[data-reader-chrome="hidden"] .reader-chapter-loader{top:1.1rem}
.reader-body[data-reader-chrome="hidden"] .reader-chapter-loader{top:calc(1.1rem + env(safe-area-inset-top, 0px))}
.reader-chapter-loader:not([hidden]){
opacity:1;transform:translate(-50%,0) scale(1)}
.reader-chapter-loader[hidden]{display:none}
@ -1628,7 +1628,7 @@ button.linkish:hover, button.linkish:focus-visible {
border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.45);padding:1.1rem 1.3rem;
box-sizing:border-box;
min-width:min(18rem,calc(100vw - 2rem));max-width:calc(100vw - 2rem);
max-height:calc(100dvh - 2rem);overflow:auto;font-size:.9rem}
max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow:auto;font-size:.9rem}
#reader-prompt-fallback{width:min(60ch,calc(100vw - 2rem))}
.reader-goto::backdrop{background:rgba(0,0,0,.55)}
.reader-goto h2{margin:0 0 .7rem;font-size:1rem}
@ -1645,7 +1645,8 @@ button.linkish:hover, button.linkish:focus-visible {
them, and a comparison that scrolls is not one. */
.reader-sync-dialog{background:var(--surface);color:inherit;border:1px solid var(--line);
border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.45);padding:1.1rem 1.3rem;
width:min(32rem,calc(100vw - 2rem));font-size:.9rem}
box-sizing:border-box;width:min(32rem,calc(100vw - 2rem));
max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow:auto;font-size:.9rem}
.reader-sync-dialog::backdrop{background:rgba(0,0,0,.55)}
.reader-sync-dialog h2{margin:0 0 .7rem;font-size:1rem}
.reader-sync-summary{margin:0 0 .9rem}
@ -1671,6 +1672,10 @@ button.linkish:hover, button.linkish:focus-visible {
.reader-catchup-detail[hidden],.reader-catchup-excerpt[hidden]{display:none}
.reader-toc{position:fixed;top:0;bottom:0;left:0;z-index:60;
width:min(22rem,85vw);overflow-y:auto;padding:1rem 1.1rem;
box-sizing:border-box;overscroll-behavior:contain;
padding-top:max(1rem,env(safe-area-inset-top, 0px));
padding-bottom:max(1rem,env(safe-area-inset-bottom, 0px));
padding-left:max(1.1rem,env(safe-area-inset-left, 0px));
background:var(--surface);border-right:1px solid var(--line);
box-shadow:0 0 32px rgba(0,0,0,.45);font-size:.9rem}
.reader-toc h2{margin:0 0 .6rem;font-size:1rem}
@ -1702,11 +1707,11 @@ button.linkish:hover, button.linkish:focus-visible {
.ann-color-purple{border-left:4px solid #ba68c8}
.ann-color-orange{border-left:4px solid #ffb74d}
.reader-stage{flex:1;display:flex;min-height:0;position:relative;
box-sizing:border-box;padding-top:3rem}
.reader-body[data-reader-autohide="true"] .reader-stage{padding-top:0}
box-sizing:border-box;padding-top:var(--reader-bar-height,calc(3rem + env(safe-area-inset-top, 0px)))}
.reader-body[data-reader-autohide="true"] .reader-stage{padding-top:env(safe-area-inset-top, 0px)}
#reader-view{flex:1;min-width:0;height:100%;background:var(--surface);overflow:hidden;position:relative}
readium-view .readium-navigator-iframe{width:100%;height:100%;border:0;display:block}
.reader-turn{position:absolute;top:0;bottom:var(--reader-margin,48px);z-index:6;width:2.75rem;
.reader-turn{position:absolute;top:0;bottom:calc(var(--reader-margin,48px) + env(safe-area-inset-bottom, 0px));z-index:6;width:2.75rem;
border:0;background:transparent;cursor:pointer;font-size:2rem;
padding:0;opacity:.35;color:inherit;transition:opacity .18s ease}
#reader-prev{left:0}
@ -1716,12 +1721,18 @@ readium-view .readium-navigator-iframe{width:100%;height:100%;border:0;display:b
/* Reader appearance settings (ADR-0012): a details popover, no script
needed to open it and none to style it. The stage colour tracks the
chosen theme so page gaps match the book instead of flashing white. */
.reader-settings{position:relative}
.reader-settings>summary{list-style:none;user-select:none}
/* Anchor the popover to the actions' right edge; anchoring it to Aa
pushes its left edge off screen in Split View. */
.reader-settings{position:static}
.reader-settings>summary{list-style:none;user-select:none;-webkit-user-select:none}
.reader-settings>summary::-webkit-details-marker{display:none}
.reader-settings[open]>summary{background:var(--surface-3)}
.reader-settings>form{position:absolute;right:0;top:calc(100% + .55rem);z-index:40;
width:19rem;max-height:min(75vh,34rem);overflow:auto;
box-sizing:border-box;width:21rem;
max-width:calc(100vw - 2rem - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
max-height:min(75vh,34rem);
max-height:min(34rem,calc(100dvh - var(--reader-bar-height,3rem) - 1.5rem - env(safe-area-inset-bottom, 0px)));
overflow:auto;overscroll-behavior:contain;
background:var(--surface);border:1px solid var(--line);border-radius:10px;
box-shadow:0 12px 32px rgba(0,0,0,.45);padding:.9rem;
display:grid;gap:.7rem;font-size:.85rem;white-space:normal}
@ -1751,6 +1762,20 @@ readium-view .readium-navigator-iframe{width:100%;height:100%;border:0;display:b
#reader-settings-reset{border:1px solid var(--line);border-radius:6px;
background:transparent;color:inherit;cursor:pointer;padding:.35rem .6rem}
#reader-settings-reset:hover{background:var(--surface-3)}
/* Match capabilities, including iPads with a trackpad attached. Keep
native selection and pinch zoom available in the publication. */
@media (any-pointer:coarse){
.reader-icon-btn,.reader-size-step{width:44px;height:44px;flex-shrink:0}
.reader-back{box-sizing:border-box;min-width:44px;min-height:44px;justify-content:center}
.reader-settings .chip,.reader-toc a,.reader-toc button,
.reader-goto button,.reader-sync-dialog button,#reader-settings-reset{
box-sizing:border-box;min-height:44px}
.reader-toc a{padding-top:.65rem;padding-bottom:.65rem}
.reader-settings input[type="range"]{min-height:44px}
/* Small form text makes Safari zoom the whole page on focus. */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,textarea,.reader-settings select,#reader-prompt-fallback textarea{font-size:16px}
}
.reader-body[data-reader-theme="light"] #reader-view{background:#ffffff}
.reader-body[data-reader-theme="sepia"] #reader-view{background:#f6ecd9}
.reader-body[data-reader-theme="dark"] #reader-view{background:#202124}