feat(reader): mobile-pattern toolbar — compact row + ⋯ overflow menu under 768px
Wrapping alone isn't how polished mobile readers work. Adopt the standard pattern (Kindle/Apple Books/Mihon) responsively: - >= 768px: the full fixed-layout toolbar stays (wrap still absorbs mid-size widths) — power users keep one-click zoom/fit/spread. - < 768px: single-line compact row — page back, back-to-location pin, slider, page forward, progress, and a ⋯ overflow button. No wrapping, no horizontal scroll. - ⋯ opens a glass menu anchored above the bar with LABELED rows (Zoom −/%/+, Fit, Page position/Recenter, Magnifier, Pointer Smart/Pan/Text, Double page, Contents) — labels beat mystery icons on touch. Pointer row hides for comics; menu scrolls if tall. - Dismissal: Esc, outside click (⋯ button exempt so it re-toggles cleanly), opening any drawer or TOC closes it; hides with the chrome. Compact slider registered in progressSliders() so all three stay in sync with relocate events.
This commit is contained in:
+110
-1
@@ -305,7 +305,9 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fixed-layout row (comics, PDFs, manga) -->
|
||||
<div x-show="isFixedLayout" class="flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
|
||||
<div x-show="isFixedLayout">
|
||||
<!-- Full toolbar: desktop/tablet (>= 768px). Wraps gracefully in between. -->
|
||||
<div class="fx-tools-full flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left">
|
||||
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
|
||||
@@ -398,6 +400,113 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
||||
</div>
|
||||
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Compact row (phone-sized windows): single line + ⋯ overflow.
|
||||
Standard mobile-reader pattern: paging, slider, progress stay;
|
||||
power tools move into a labeled menu. -->
|
||||
<div class="fx-compact items-center gap-1 px-1.5 py-1.5">
|
||||
<button @click="goLeft()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left">
|
||||
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
|
||||
<path d="M 15 6 L 9 12 L 15 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
|
||||
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
|
||||
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
|
||||
<circle cx="12" cy="9.5" r="2"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
id="progress-slider-fx-compact"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="any"
|
||||
@input="goToFraction($event.target.value)"
|
||||
class="grow min-w-[3rem]"
|
||||
/>
|
||||
<button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
|
||||
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
|
||||
<path d="M 9 6 L 15 12 L 9 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3rem] text-center cursor-pointer truncate shrink-0">
|
||||
<span x-text="progressMain">0/0</span>
|
||||
</div>
|
||||
<button
|
||||
data-tools-toggle
|
||||
@click="toggleTools()"
|
||||
class="p-1.5 rounded-lg hover:bg-gray-700"
|
||||
:title="toolsOpen ? 'Close tools' : 'More tools'"
|
||||
aria-label="More tools"
|
||||
:aria-expanded="toolsOpen ? 'true' : 'false'"
|
||||
>
|
||||
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="1.5" fill="currentColor"></circle>
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor"></circle>
|
||||
<circle cx="19" cy="12" r="1.5" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tools overflow menu: labeled rows (mobile-reader pattern —
|
||||
labels beat mystery icons). Anchored above the bottom bar. -->
|
||||
<div
|
||||
x-show="toolsOpen"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-2"
|
||||
class="reader-tools-popover"
|
||||
role="menu"
|
||||
aria-label="Reader tools"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="reader-tools-row">
|
||||
<span>Zoom</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="zoomOut()" class="reader-popover-btn" title="Zoom out">−</button>
|
||||
<button @click="resetZoom()" class="text-xs px-1 min-w-[3rem] text-center" title="Reset zoom"><span x-text="zoomPercent + '%'">100%</span></button>
|
||||
<button @click="zoomIn()" class="reader-popover-btn" title="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reader-tools-row">
|
||||
<span>Fit</span>
|
||||
<select
|
||||
@change="applyFitMode($event.target.value)"
|
||||
class="reader-select text-xs px-1.5 py-1 rounded-lg"
|
||||
aria-label="Fit mode"
|
||||
>
|
||||
<option value="fit-page">Fit Page</option>
|
||||
<option value="fit-width">Fit Width</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="reader-tools-row">
|
||||
<span>Page position</span>
|
||||
<button @click="recenterView()" class="reader-popover-btn text-xs px-2" title="Reset pan, keep zoom">Recenter</button>
|
||||
</div>
|
||||
<div class="reader-tools-row">
|
||||
<span>Magnifier</span>
|
||||
<button @click="toggleMagnifier()" class="reader-popover-btn text-xs px-2" :class="magnifierEnabled ? 'text-blue-400' : ''" x-text="magnifierEnabled ? 'On' : 'Off'">Off</button>
|
||||
</div>
|
||||
<div class="reader-tools-row" x-show="isPDF">
|
||||
<span>Pointer</span>
|
||||
<div class="reader-seg" role="group" aria-label="Pointer mode">
|
||||
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
|
||||
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')">Pan</button>
|
||||
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')">Text</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reader-tools-row">
|
||||
<span>Double page</span>
|
||||
<button @click="toggleSpread()" class="reader-popover-btn text-xs px-2" :class="doublePageSpread ? 'text-blue-400' : ''" x-text="doublePageSpread ? 'On' : 'Off'">Off</button>
|
||||
</div>
|
||||
<div class="reader-tools-row">
|
||||
<span>Contents</span>
|
||||
<button @click="toggleTOC()" class="reader-popover-btn text-xs px-2">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -389,6 +389,7 @@ document.addEventListener("alpine:init", () => {
|
||||
chromeVisible: true,
|
||||
chromeBehavior: "auto-hide" as string,
|
||||
hideTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
toolsOpen: false,
|
||||
tapZonesEnabled: true as boolean,
|
||||
tapZoneSize: 30 as number,
|
||||
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
@@ -879,12 +880,19 @@ document.addEventListener("alpine:init", () => {
|
||||
document.addEventListener("pointermove", () => this.pokeChrome(), {
|
||||
passive: true,
|
||||
});
|
||||
// Dismiss the selection popover on clicks outside it (iframe clicks
|
||||
// are covered by the selection tracker's collapsed check).
|
||||
// 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).
|
||||
document.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
if (!this.selectionPopover.open) return;
|
||||
if ((e.target as HTMLElement)?.closest?.("#selection-popover")) return;
|
||||
this.hideSelectionPopover();
|
||||
const target = e.target as HTMLElement;
|
||||
if (this.selectionPopover.open &&
|
||||
!target?.closest?.("#selection-popover")) {
|
||||
this.hideSelectionPopover();
|
||||
}
|
||||
if (this.toolsOpen &&
|
||||
!target?.closest?.(".reader-tools-popover, [data-tools-toggle]")) {
|
||||
this.toolsOpen = false;
|
||||
}
|
||||
}, { passive: true });
|
||||
if (this.chromeBehavior === "always-visible") {
|
||||
this.chromeVisible = true;
|
||||
@@ -1488,6 +1496,9 @@ document.addEventListener("alpine:init", () => {
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleTools() {
|
||||
this.toolsOpen = !this.toolsOpen;
|
||||
},
|
||||
anyDrawerOpen(): boolean {
|
||||
return this.tocOpen || this.settingsOpen || this.bookmarksOpen || this.searchOpen;
|
||||
},
|
||||
@@ -1496,6 +1507,7 @@ document.addEventListener("alpine:init", () => {
|
||||
this.settingsOpen = false;
|
||||
this.bookmarksOpen = false;
|
||||
this.searchOpen = false;
|
||||
this.toolsOpen = false;
|
||||
},
|
||||
// ----- in-book search (reflowable) -----
|
||||
// view.search() is an async generator: it progressively scans sections,
|
||||
@@ -1977,7 +1989,11 @@ document.addEventListener("alpine:init", () => {
|
||||
// The reflowable and fixed-layout bottom rows each have their own slider
|
||||
// (only one is displayed); helpers below target whichever exists.
|
||||
progressSliders(): HTMLInputElement[] {
|
||||
const ids = ["progress-slider", "progress-slider-fx"];
|
||||
const ids = [
|
||||
"progress-slider",
|
||||
"progress-slider-fx",
|
||||
"progress-slider-fx-compact",
|
||||
];
|
||||
return ids
|
||||
.map((id) => document.getElementById(id) as HTMLInputElement | null)
|
||||
.filter((el): el is HTMLInputElement => !!el);
|
||||
@@ -2137,6 +2153,8 @@ document.addEventListener("alpine:init", () => {
|
||||
else if (k === "Escape") {
|
||||
if (this.selectionPopover.open) {
|
||||
this.hideSelectionPopover();
|
||||
} else if (this.toolsOpen) {
|
||||
this.toolsOpen = false;
|
||||
} else if (this.anyDrawerOpen()) {
|
||||
this.closeDrawers();
|
||||
} else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
|
||||
|
||||
@@ -991,6 +991,55 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Fixed-layout toolbar responsiveness:
|
||||
- >= 768px: full toolbar (flex-wrap absorbs mid-size widths)
|
||||
- < 768px: compact single line + ⋯ overflow menu (mobile-reader
|
||||
pattern: paging/slider/progress stay, power tools get a labeled menu) */
|
||||
.fx-compact {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.fx-tools-full {
|
||||
display: none;
|
||||
}
|
||||
.fx-compact {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.reader-tools-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
min-width: 16rem;
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: min(26rem, 65vh);
|
||||
overflow-y: auto;
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 88%, transparent);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.35);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.reader-tools-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.reader-tools-row + .reader-tools-row {
|
||||
border-top: 1px solid
|
||||
color-mix(in srgb, var(--border) 45%, transparent);
|
||||
}
|
||||
.reader-tools-row:hover {
|
||||
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
||||
}
|
||||
|
||||
/* In-book search */
|
||||
.reader-search-bar {
|
||||
display: flex;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user