From b8c3326f935c92d946dcbc087b95a571ce3c36e9 Mon Sep 17 00:00:00 2001 From: juliefuller Date: Tue, 9 Jun 2026 12:13:10 -0500 Subject: [PATCH] E-ink reader UX: status bar, navigation zones, and toolbar improvements. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add clock and battery status bar options, configurable page-turn and toolbar zones, jump-to-% panel fixes, auto-hide toolbar dismiss, and peek-mode compatibility — without changing KOReader sync timing. Co-authored-by: Cursor --- public/css/reader.css | 164 +++++++-- public/images/battery.svg | 3 + public/images/battery_bw.svg | 3 + public/js/offline.js | 16 + public/js/reader_v4.js | 677 +++++++++++++++++++++++++---------- public/js/sw-register.js | 26 ++ public/locales/de.json | 25 +- public/locales/en.json | 32 +- public/locales/es.json | 25 +- public/locales/fr.json | 25 +- public/locales/it.json | 25 +- public/locales/pt.json | 25 +- public/locales/sl.json | 23 +- public/login.html | 6 +- public/readerv4.html | 74 +++- public/sw.js | 2 +- 16 files changed, 899 insertions(+), 252 deletions(-) create mode 100644 public/images/battery.svg create mode 100644 public/images/battery_bw.svg create mode 100644 public/js/sw-register.js diff --git a/public/css/reader.css b/public/css/reader.css index 86979b4..ad5c15f 100644 --- a/public/css/reader.css +++ b/public/css/reader.css @@ -49,7 +49,7 @@ html, body { #header-sensor { position: absolute; top: 0; left: 0; right: 0; - height: calc(26px + var(--sat)); + height: calc(var(--header-reveal-zone-pct, 12%) + var(--sat)); z-index: 19; pointer-events: none; } @@ -73,6 +73,7 @@ html, body { transform: translateY(0); opacity: 1; pointer-events: auto; + z-index: 21; } /* Reader main fills full height when header is out of flow */ .reader-layout.autohide-header .reader-main { @@ -82,7 +83,7 @@ html, body { /* ── Header ──────────────────────────────────────────────────────────────────── */ .reader-header { - min-height: 44px; + min-height: var(--reader-header-min-height, 44px); flex-shrink: 0; background: var(--reader-header-bg, var(--color-surface)); backdrop-filter: blur(10px); @@ -111,8 +112,8 @@ html, body { /* Uniform icon buttons — fixed square, glyph centred, no size drift between emoji */ .reader-header .btn-icon { font-size: 1.1rem; - width: 36px; - height: 36px; + width: var(--reader-header-btn-size, 36px); + height: var(--reader-header-btn-size, 36px); padding: 0; display: flex; align-items: center; @@ -122,7 +123,10 @@ html, body { color: var(--reader-header-text-muted, var(--color-text-muted)); border-color: var(--reader-header-border, var(--color-border)); } -.reader-header .btn-icon .nav-icon { width: 1.2rem; height: 1.2rem; } +.reader-header .btn-icon .nav-icon { + width: var(--reader-header-icon-size, 1.2rem); + height: var(--reader-header-icon-size, 1.2rem); +} /* Prevent icon images from being the event target — the button itself should always receive pointer events */ .reader-header .btn-icon img { pointer-events: none; } @@ -140,10 +144,24 @@ html, body { [data-reader-eink] .nav-icon-find { content: url('/images/find_bw.svg'); } [data-reader-eink] .nav-icon-percentage { content: url('/images/percentage_bw.svg'); } [data-reader-eink] .nav-icon-settings { content: url('/images/settings_bw.svg'); } +[data-reader-eink] .nav-icon-sync { content: url('/images/sync_bw.svg'); } +[data-reader-eink] .nav-icon-sb-battery { content: url('/images/battery_bw.svg'); } [data-reader-eink] .nav-icon-back { content: url('/images/back_bw.svg'); } [data-reader-eink] .nav-icon-fullscreen { content: url('/images/fullscreen_bw.svg'); } [data-reader-eink] img[src="/images/fullscreen_exit.svg"] { content: url('/images/fullscreen_exit_bw.svg'); } +/* Manual sync button states */ +@keyframes br-spin { to { transform: rotate(360deg); } } +#btn-sync.btn-sync-busy .nav-icon-sync { animation: br-spin 0.75s linear infinite; } +#btn-sync.btn-sync-done { + background: var(--color-accent) !important; + border-radius: 6px; +} +#btn-sync.btn-sync-done .nav-icon-sync { filter: brightness(0) invert(1) !important; } +[data-reader-eink] #btn-sync.btn-sync-busy .nav-icon-sync { animation: none; opacity: .5; } +[data-reader-eink] #btn-sync.btn-sync-done { background: none !important; } +[data-reader-eink] #btn-sync.btn-sync-done .nav-icon-sync { filter: none !important; } + /* E-ink reader: swap status bar stat icons */ [data-reader-eink] .nav-icon-sb-chapter-page { content: url('/images/chapter_page_bw.svg'); } [data-reader-eink] .nav-icon-sb-book-page { content: url('/images/book_page_bw.svg'); } @@ -259,6 +277,7 @@ html, body { transform: translateY(100%); transition: transform .22s ease; } #annot-toolbar.open { transform: translateY(0); } + .annot-toolbar-colors { display: flex; gap: .45rem; } .annot-color-btn { width: 28px; height: 28px; border-radius: 50%; @@ -409,7 +428,7 @@ html, body { flex: 1; position: relative; overflow: hidden; - touch-action: pan-y; /* tell iOS we own horizontal swipes; vertical stays native */ + touch-action: pan-y; /* iOS: we own horizontal swipes; overridden below for Android */ /* Edge inset: shrinks viewer away from curved screen edges. --sab is set by JS after safe-area probe. On iOS 26 PWA this starts at 0 and updates to ~34px after 500-1000ms. The status bar overlay sits inside this gap. */ @@ -470,15 +489,49 @@ body.page-gap-shadow-on .dict-popup { position: absolute; top: 0; bottom: 0; - width: 14%; z-index: 5; display: flex; align-items: center; - /* Zone itself never captures pointer events — iframe text selection works at edges */ - pointer-events: none; + cursor: pointer; + pointer-events: auto; + background: transparent; + -webkit-tap-highlight-color: transparent; + tap-highlight-color: transparent; + user-select: none; + touch-action: manipulation; +} +.nav-zone:hover, +.nav-zone:active, +.nav-zone:focus { + background: transparent; + outline: none; +} +.nav-zone-prev { + left: 0; + width: var(--nav-zone-left-pct, 20%); + justify-content: flex-start; + padding-left: .5rem; +} +.nav-zone-next { + right: 0; + width: var(--nav-zone-right-pct, 20%); + justify-content: flex-end; + padding-right: .5rem; +} + +/* Touch devices: nav zones sit above the iframe and steal long-press / drag-select. + Pass touches through to the book; page turns use swipe inside the iframe. */ +@media (pointer: coarse) { + #epub-viewer { + touch-action: auto; + } + .nav-zone { + pointer-events: none; + } + .nav-arrow { + pointer-events: auto; + } } -.nav-zone-prev { left: 0; justify-content: flex-start; padding-left: .5rem; } -.nav-zone-next { right: 0; justify-content: flex-end; padding-right: .5rem; } /* KOSync tap hot zones */ .kosync-hotzone { @@ -512,6 +565,18 @@ body.page-gap-shadow-on .dict-popup { } .nav-arrow:hover { opacity: 1; } +/* Touch / e-ink: hide hover-only arrows — they flash and repaint the whole zone */ +@media (hover: none), (pointer: coarse) { + .nav-arrow { display: none !important; } +} +[data-reader-eink] .nav-zone, +[data-reader-eink] .nav-zone:hover, +[data-reader-eink] .nav-zone:active { + background: transparent !important; + -webkit-tap-highlight-color: transparent !important; +} +[data-reader-eink] .nav-arrow { display: none !important; } + /* ── Progress bar (bottom) ───────────────────────────────────────────────────── */ .reader-progressbar { height: 0; /* hidden — use 'Napredek knjige (trak)' overlay instead */ @@ -606,6 +671,7 @@ body.page-gap-shadow-on .dict-popup { .toc-item.active { color: var(--color-accent); font-weight: 600; + background: rgba(233,69,96,.1); /* fallback for browsers without color-mix() */ background: color-mix(in srgb, var(--color-accent) 10%, transparent); border-left: 3px solid var(--color-accent); padding-left: calc(1.25rem - 3px); @@ -989,6 +1055,7 @@ body.page-gap-shadow-on .dict-popup { .font-picker-dropdown { position: fixed; z-index: 9999; + background: var(--color-surface); /* solid fallback for older WebViews (e-ink tablets, etc.) */ background: color-mix(in srgb, var(--color-surface) 94%, var(--color-bg) 6%); border: 1px solid var(--color-border); border-radius: 12px; @@ -998,6 +1065,12 @@ body.page-gap-shadow-on .dict-popup { overscroll-behavior: contain; max-height: 320px; } +[data-reader-eink] .font-picker-dropdown { + background: var(--color-surface); + box-shadow: none; + border: 2px solid var(--color-text); + border-radius: 4px; +} .font-picker-option { width: 100%; border: none; @@ -1341,8 +1414,10 @@ input[type="range"]::-moz-range-thumb { /* ── Jump-to-% panel: chapter nav buttons ─────────────── */ .sb-chap-nav-btn { flex-shrink: 0; - width: 28px; - height: 32px; + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; background: none; border: 1px solid var(--color-border); border-radius: 4px; @@ -1365,6 +1440,8 @@ input[type="range"]::-moz-range-thumb { } .jump-pct-track-wrap input[type="range"] { width: 100%; + pointer-events: auto; + touch-action: none; } .sb-chap-marker { position: absolute; @@ -1388,7 +1465,8 @@ input[type="range"]::-moz-range-thumb { -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--reader-header-border, var(--color-border)); flex-shrink: 0; - z-index: 15; + z-index: 23; + pointer-events: auto; } .jump-pct-row { display: flex; @@ -1400,31 +1478,71 @@ input[type="range"]::-moz-range-thumb { flex: 1; min-width: 0; } -#jump-pct-value { +.jump-pct-value-input { min-width: unset; - width: 2.4rem; + width: 2.75rem; text-align: center; flex-shrink: 0; + padding: .15rem .25rem; + font-size: inherit; + font-family: inherit; + color: inherit; + background: var(--color-surface2, var(--color-surface)); + border: 1px solid var(--reader-header-border, var(--color-border)); + border-radius: 4px; + -moz-appearance: textfield; +} +.jump-pct-value-input:focus { + outline: 2px solid var(--color-accent); + outline-offset: 1px; } +.jump-pct-value-input::-webkit-outer-spin-button, +.jump-pct-value-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +/* Tap-outside dismiss for auto-hidden toolbar */ +.header-dismiss-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 20; + background: transparent; + pointer-events: auto; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} +.header-dismiss-backdrop.visible { display: block; } + +.jump-pct-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 21; + background: transparent; + pointer-events: auto; +} +.jump-pct-backdrop.visible { display: block; } /* In auto-hide mode the header is out of flow — panel must float just below it */ .autohide-header #jump-pct-panel { position: fixed; - top: calc(44px + var(--sat)); + top: calc(var(--reader-header-min-height, 44px) + var(--sat, 0px)); left: 0; right: 0; + z-index: 23; } /* ── Mobile ──────────────────────────────────────────────────────────────────── */ @media (max-width: 640px) { - .reader-header { padding: 0 .4rem; gap: .25rem; min-height: 44px; } - /* Larger icon buttons on mobile — 44 px touch targets, still 6 buttons fit on 320 px */ - .reader-header .btn-icon { font-size: 1.3rem; width: 44px; height: 44px; } + .reader-header { padding: 0 .4rem; gap: .25rem; min-height: var(--reader-header-min-height, 44px); } + /* Larger icon buttons on mobile — 44 px touch targets unless user overrides */ + .reader-header .btn-icon { + font-size: 1.3rem; + width: var(--reader-header-btn-size, 44px); + height: var(--reader-header-btn-size, 44px); + } #btn-fullscreen { display: none; } .nav-zone { display: block; - top: calc(44px + var(--sat)); - /* pointer-events: none (inherited) — taps fall through to epub iframe; - navigation is swipe-based on mobile. */ + top: 0; + pointer-events: none; /* phone: arrows hidden — don't block selection; swipe to turn */ } .nav-arrow { display: none; } /* Larger tap targets for small screens */ diff --git a/public/images/battery.svg b/public/images/battery.svg new file mode 100644 index 0000000..cd2d19e --- /dev/null +++ b/public/images/battery.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/battery_bw.svg b/public/images/battery_bw.svg new file mode 100644 index 0000000..fc7aaa6 --- /dev/null +++ b/public/images/battery_bw.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/js/offline.js b/public/js/offline.js index a077e0d..af5b249 100644 --- a/public/js/offline.js +++ b/public/js/offline.js @@ -85,6 +85,21 @@ export async function removeBook(bookId) { }); } +const BOOKS_CACHE_NAME = 'codexa-books-v2'; + +/** Load a previously downloaded EPUB from the service-worker books cache. */ +export async function fetchOfflineBookFile(bookId) { + if (!('caches' in window)) return null; + try { + const cache = await caches.open(BOOKS_CACHE_NAME); + const res = await cache.match(`/offline/books/${Number(bookId)}/epub`); + if (!res?.ok) return null; + return res.arrayBuffer(); + } catch { + return null; + } +} + export async function isBookDownloaded(bookId) { try { const meta = await getBookMeta(Number(bookId)); @@ -107,6 +122,7 @@ export async function setDownloadStatus(bookId, status) { if (isOfflineSupported) { navigator.serviceWorker.addEventListener('message', e => { const { type, bookId, message } = e.data || {}; + console.log('[offline] SW message received:', type, bookId); if (type === 'CACHE_BOOK_DONE' || type === 'CACHE_BOOK_ERROR') { const handlers = _pending.get(bookId); _pending.delete(bookId); diff --git a/public/js/reader_v4.js b/public/js/reader_v4.js index 3d9981f..3bac2fb 100644 --- a/public/js/reader_v4.js +++ b/public/js/reader_v4.js @@ -2,9 +2,11 @@ import { apiFetch, requireAuth, getToken } from './api.js'; import { toast } from './ui.js'; import { t, initI18n, applyTranslations, getCurrentLang } from './i18n.js'; import ePub from './flow/index.js'; -import { isBookDownloaded, downloadBook, getBookMeta, saveBookMeta } from './offline.js'; +import { isBookDownloaded, downloadBook, fetchOfflineBookFile, getBookMeta, saveBookMeta } from './offline.js'; +const READER_BUILD = 'br-v51'; const _i18nReady = initI18n(); +console.log('[codexa] reader build', READER_BUILD); function onTap(el, handler) { if (!el) return; @@ -24,9 +26,9 @@ if (!Promise.allSettled) { if (!requireAuth()) throw new Error('not authenticated'); const params = new URLSearchParams(window.location.search); +const isPeekMode = params.get('peek') === '1'; const bookId = params.get('id'); if (!bookId) { window.location.href = '/'; throw new Error(); } -const isPeekMode = params.get('peek') === '1'; const BIONIC_RELOAD_KEY = 'br_bionic_reload_state_v1'; const SESSION_KEY = 'br_interrupted_session_v1'; const RESUME_STATE_KEY = 'br_resume_state_v1'; @@ -178,6 +180,7 @@ const DEFAULT_STATUS_BAR = { showIcons: {}, // { [statId]: false } to hide icon; default = show all bookProgressBar: { show: false, position: 'bottom', thickness: 3 }, chapProgressBar: { show: false, position: 'bottom', thickness: 2 }, + clockFormat: '24h', // '24h' | '12h' }; // Stat definitions — id, icon, translated label (use function to get current lang) @@ -195,6 +198,7 @@ function getStatusStats() { { id: 'bookTitle', icon: '/images/book_title.svg', label: t('reader.sb_book_title') }, { id: 'bookAuthor', icon: '/images/book_author.svg', label: t('reader.sb_book_author') }, { id: 'chapterTitle', icon: '/images/chapter_title.svg', label: t('reader.sb_chap_title') }, + { id: 'battery', icon: '/images/battery.svg', label: t('reader.sb_battery') }, ]; } @@ -243,6 +247,10 @@ const DEFAULT_PREFS = { dictionaries: [], // enabled dict IDs in priority order; null = all disabled; empty = use all dictionaryOrder: [], // all dict IDs in user's display order (including disabled ones) edgePadding: { top: 0, bottom: 0, left: 0, right: 0 }, // px inset for curved screens + navZoneLeftPct: 20, // % of screen width — tap/click to go back + navZoneRightPct: 20, // % of screen width — tap/click to go forward + headerRevealZonePct: 12, // % of screen height — tap reveals auto-hidden toolbar + headerButtonScalePct: 100, // % of default responsive size (36 desktop / 44 mobile) statusBar: null, // deep-merged in loadPrefs() }; @@ -258,6 +266,7 @@ let lastKnownXPointer = null; // precise xpointer last received from KOReader/se let lastChapterHref = null; // chapter-boundary save tracking let lastSentChapterHref = null; // last chapter for which remote progress was pushed let availableDicts = null; // cached GET /api/dictionary response +let _batteryMgr = null; // BatteryManager from navigator.getBattery(), null if unsupported let pendingNavDirection = null; // 'next' or 'prev' tracking for chapter jump corrections let pendingWasChapterEnd = true; // whether goNext() was called from the last page let deferredNextPending = false; // true while a mid-repagination NEXT is deferred @@ -339,9 +348,11 @@ const searchStatusEl = document.getElementById('search-status'); const searchResultsEl = document.getElementById('search-results'); const searchBackBtn = document.getElementById('btn-search-back'); const searchAcceptBtn = document.getElementById('btn-search-accept'); -const jumpPctPanel = document.getElementById('jump-pct-panel'); -const jumpPctSlider = document.getElementById('jump-pct-slider'); -const jumpPctValue = document.getElementById('jump-pct-value'); +const jumpPctPanel = document.getElementById('jump-pct-panel'); +const jumpPctBackdrop = document.getElementById('jump-pct-backdrop'); +const headerDismissBackdrop = document.getElementById('header-dismiss-backdrop'); +const jumpPctSlider = document.getElementById('jump-pct-slider'); +const jumpPctValue = document.getElementById('jump-pct-value'); const fullscreenBtn = document.getElementById('btn-fullscreen'); const bookmarksSidebar = document.getElementById('bookmarks-sidebar'); const bookmarksListEl = document.getElementById('bookmarks-list'); @@ -360,9 +371,22 @@ function loadPrefs() { const s = localStorage.getItem('br_reader_prefs'); const saved = s ? JSON.parse(s) : {}; const sb = saved.statusBar || {}; + const legacyRevealPx = typeof saved.headerRevealZone === 'number' ? saved.headerRevealZone : null; + const legacyBtnPx = typeof saved.headerButtonSize === 'number' ? saved.headerButtonSize : null; + const btnBase = window.matchMedia('(max-width: 640px)').matches ? 44 : 36; return { ...DEFAULT_PREFS, ...saved, + headerRevealZonePct: saved.headerRevealZonePct ?? ( + legacyRevealPx != null + ? Math.min(40, Math.max(2, Math.round(legacyRevealPx / 8))) + : DEFAULT_PREFS.headerRevealZonePct + ), + headerButtonScalePct: saved.headerButtonScalePct ?? ( + legacyBtnPx != null && legacyBtnPx > 0 + ? Math.min(225, Math.max(75, Math.round(legacyBtnPx / btnBase * 100))) + : DEFAULT_PREFS.headerButtonScalePct + ), edgePadding: { ...DEFAULT_PREFS.edgePadding, ...(saved.edgePadding || {}) }, statusBar: { ...DEFAULT_STATUS_BAR, @@ -371,6 +395,7 @@ function loadPrefs() { showIcons: { ...DEFAULT_STATUS_BAR.showIcons, ...sb.showIcons }, bookProgressBar: { ...DEFAULT_STATUS_BAR.bookProgressBar, ...sb.bookProgressBar }, chapProgressBar: { ...DEFAULT_STATUS_BAR.chapProgressBar, ...sb.chapProgressBar }, + clockFormat: sb.clockFormat || DEFAULT_STATUS_BAR.clockFormat, }, }; } catch { return { ...DEFAULT_PREFS, statusBar: { ...DEFAULT_STATUS_BAR } }; } @@ -876,7 +901,6 @@ img { margin-right: auto !important; mix-blend-mode: multiply !important; } -/* Keep native selection/callout enabled so iOS long-press and selection behave naturally. */ ${prefs.eink ? buildEinkCss(theme) : ''} ${prefs.paraIndent ? `p { text-indent: ${(prefs.paraIndentSize / 10).toFixed(1)}em !important; }` : 'p { text-indent: 0 !important; }'} ${prefs.paraSpacing > 0 ? `p { margin-bottom: ${(prefs.paraSpacing / 10).toFixed(1)}em !important; }` : ''} @@ -1010,7 +1034,7 @@ async function releaseWakeLock() { // Re-acquire after tab becomes visible again (wake lock is auto-released on hide) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') acquireWakeLock(); - if (document.visibilityState === 'hidden') writeInterruptedSession(); + if (document.visibilityState === 'hidden') writeInterruptedSession(); }); // ── Host page background ────────────────────────────────────────────────────── @@ -1115,11 +1139,22 @@ function applyPageShadow() { } // ── FIX: Forward iframe keydown events to host ──────────────────────────────── +const IFRAME_NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', ' ', 'PageDown', 'PageUp']); + function attachIframeKeyboard(contents) { if (!contents?.window) return; + // Capture phase + preventDefault stops epub.js from also turning the page when we + // handle navigation keys here (avoids double page-turn on e-ink Page Up/Down). contents.window.addEventListener('keydown', (e) => { + if (IFRAME_NAV_KEYS.has(e.key)) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (e.key === 'ArrowLeft' || e.key === 'PageUp') goPrev(); + else goNext(); + return; + } document.dispatchEvent(new KeyboardEvent('keydown', { key: e.key, bubbles: true })); - }); + }, true); // Forward wheel events from iframe so mouseWheelNav works when cursor is over text contents.window.addEventListener('wheel', (e) => { if (!prefs.mouseWheelNav) return; @@ -1281,8 +1316,8 @@ function attachIframeDictionary(contents) { } } -// Detect whether an element inside the epub iframe is a footnote/endnote reference. -// Pure function — runs in the iframe context, no epub.js API needed. + + function isFootnoteLink(anchor) { const rawHref = anchor.dataset?.footnoteHref || anchor.dataset?.brLinkHref || anchor.getAttribute('href') || ''; if (!rawHref.includes('#')) return false; @@ -1872,7 +1907,7 @@ function openAnnotations() { settingsPanel.classList.remove('open'); bookmarksSidebar.classList.remove('open'); panelBackdrop.classList.add('visible'); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); } // ── Status bar engine ───────────────────────────────────────────────────────── @@ -1958,7 +1993,10 @@ function computeStatValue(id) { return formatEta(estimateBookPagesLeft()); case 'currentTime': { const now = new Date(); - return now.toLocaleTimeString('sl-SI', { hour: '2-digit', minute: '2-digit' }); + if (prefs.statusBar?.clockFormat === '12h') { + return now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); + } + return now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false }); } case 'bookTitle': return currentBook?.title || ''; @@ -1966,6 +2004,11 @@ function computeStatValue(id) { return currentBook?.author || ''; case 'chapterTitle': return chapterLabelFromHref(currentHref); + case 'battery': { + if (!_batteryMgr) return ''; + const pct = Math.round(_batteryMgr.level * 100); + return (_batteryMgr.charging ? '⚡\u202F' : '') + pct + '%'; + } default: return ''; } @@ -2062,6 +2105,50 @@ function refreshStatusBarTime() { } setInterval(refreshStatusBarTime, 30000); +function getHeaderRevealZonePct() { + return prefs.headerRevealZonePct ?? 12; +} + +function getHeaderRevealZonePx() { + return window.innerHeight * getHeaderRevealZonePct() / 100; +} + +function inNavZone(x) { + const w = window.innerWidth; + const leftPct = prefs.navZoneLeftPct ?? 20; + const rightPct = prefs.navZoneRightPct ?? 20; + if (leftPct > 0 && x < w * leftPct / 100) return 'prev'; + if (rightPct > 0 && x > w - w * rightPct / 100) return 'next'; + return null; +} + +function inHeaderRevealZone(y) { + return y < getHeaderRevealZonePx(); +} + +function applyNavZones() { + const root = document.documentElement; + root.style.setProperty('--nav-zone-left-pct', (prefs.navZoneLeftPct ?? 20) + '%'); + root.style.setProperty('--nav-zone-right-pct', (prefs.navZoneRightPct ?? 20) + '%'); +} + +function applyHeaderRevealZone() { + document.documentElement.style.setProperty('--header-reveal-zone-pct', getHeaderRevealZonePct() + '%'); +} + +function getHeaderButtonBasePx() { + return window.matchMedia('(max-width: 640px)').matches ? 44 : 36; +} + +function applyHeaderButtonSize() { + const root = document.documentElement; + const scale = prefs.headerButtonScalePct ?? 100; + const size = Math.round(getHeaderButtonBasePx() * scale / 100); + root.style.setProperty('--reader-header-btn-size', size + 'px'); + root.style.setProperty('--reader-header-icon-size', Math.max(14, Math.round(size * 0.33)) + 'px'); + root.style.setProperty('--reader-header-min-height', (size + 8) + 'px'); +} + // Apply CSS vars for edge inset (curved phone screens) function applyEdgePadding() { const p = prefs.edgePadding; @@ -2199,10 +2286,49 @@ function buildChapterMarkers() { } // ── Auto-hide header ────────────────────────────────────────────────────────── + +// Timestamp of the last header-reveal so we can suppress accidental button clicks +// that arrive during/just-after the slide-in animation (pointer-events go live +// immediately when header-peek is added, before the 250ms transition completes). +let _headerRevealTs = 0; +const HEADER_REVEAL_GUARD_MS = 350; // must be ≥ the 250ms transition + +function syncHeaderDismissBackdrop() { + if (!headerDismissBackdrop) return; + const show = prefs.autoHideHeader + && readerLayout.classList.contains('header-peek') + && !isJumpPanelOpen() + && !tocSidebar.classList.contains('open'); + headerDismissBackdrop.classList.toggle('visible', show); +} + +function forceHideAutoHeader() { + if (!readerLayout.classList.contains('header-peek')) return; + isMouseOverHeader = false; + readerLayout.classList.remove('header-peek'); + syncHeaderDismissBackdrop(); +} + +function revealHeader() { + if (!readerLayout.classList.contains('header-peek')) { + _headerRevealTs = Date.now(); // record reveal time for click-guard + } + readerLayout.classList.add('header-peek'); + syncHeaderDismissBackdrop(); +} + +function hideAutoHeader() { + if (!prefs.autoHideHeader) return; + if (!readerLayout.classList.contains('header-peek')) return; + if (tocSidebar.classList.contains('open') || isJumpPanelOpen()) return; + if (isMouseOverHeader) return; + forceHideAutoHeader(); +} + function applyAutoHide() { readerLayout.classList.toggle('autohide-header', prefs.autoHideHeader); if (!prefs.autoHideHeader) { - readerLayout.classList.remove('header-peek'); + forceHideAutoHeader(); } // Resize rendition since available height changes if (rendition) { @@ -2215,27 +2341,48 @@ function applyAutoHide() { // Show header when mouse enters the thin sensor zone at very top of page document.getElementById('header-sensor').addEventListener('mouseenter', () => { if (!prefs.autoHideHeader) return; - readerLayout.classList.add('header-peek'); + revealHeader(); }); document.getElementById('header-sensor').addEventListener('touchstart', () => { if (!prefs.autoHideHeader) return; - readerLayout.classList.add('header-peek'); + revealHeader(); }, { passive: true }); +// Prevent the synthetic click that follows touchend from hitting a header button +document.getElementById('header-sensor').addEventListener('touchend', (e) => { + if (!prefs.autoHideHeader) return; + e.preventDefault(); +}, { passive: false }); document.getElementById('header-sensor').addEventListener('click', () => { if (!prefs.autoHideHeader) return; - readerLayout.classList.add('header-peek'); + revealHeader(); }); + +// Capture-phase guard: swallow any click that lands on the header within +// HEADER_REVEAL_GUARD_MS of a reveal, so the tap-to-show gesture never +// accidentally activates a button underneath the user's finger. +document.querySelector('.reader-header').addEventListener('click', (e) => { + if (prefs.autoHideHeader && Date.now() - _headerRevealTs < HEADER_REVEAL_GUARD_MS) { + e.stopImmediatePropagation(); + e.preventDefault(); + } +}, true); // capture:true — runs before individual button handlers // Track whether the mouse is currently inside the header bar let isMouseOverHeader = false; document.querySelector('.reader-header').addEventListener('mouseenter', () => { isMouseOverHeader = true; }); // Hide header as soon as mouse leaves the header bar itself document.querySelector('.reader-header').addEventListener('mouseleave', () => { isMouseOverHeader = false; - if (!prefs.autoHideHeader) return; - if (!tocSidebar.classList.contains('open') && jumpPctPanel.style.display === 'none') { - readerLayout.classList.remove('header-peek'); - } + hideAutoHeader(); }); +function onHeaderDismissBackdrop(e) { + if (!prefs.autoHideHeader || !readerLayout.classList.contains('header-peek')) return; + e.preventDefault(); + e.stopPropagation(); + forceHideAutoHeader(); +} +headerDismissBackdrop?.addEventListener('pointerdown', onHeaderDismissBackdrop); +headerDismissBackdrop?.addEventListener('touchstart', onHeaderDismissBackdrop, { passive: false }); +headerDismissBackdrop?.addEventListener('click', onHeaderDismissBackdrop); // ── TOC ─────────────────────────────────────────────────────────────────────── function buildTocRecursive(toc, depth, fragment) { @@ -2346,6 +2493,76 @@ function chapterLabelFromHref(href) { return match?.label || ''; } +function tocHrefNorm(h) { + return decodeURIComponent(h || '') + .split('#')[0].split('?')[0] + .replace(/\\/g, '/') + .replace(/_split_\d+(\.\w+)$/, '$1') + .toLowerCase() + .replace(/^\//, ''); +} + +function findCurrentTopLevelChapIdx() { + const tops = tocFlatItems.filter(t => t.depth === 0); + if (!tops.length) return { tops, idx: -1 }; + + const href = currentHref || rendition?.currentLocation?.()?.start?.href || ''; + if (!href) return { tops, idx: -1 }; + + const n = tocHrefNorm(href); + const base = n.split('/').pop(); + let matchFlatIdx = -1; + tocFlatItems.forEach((t, i) => { + const tn = tocHrefNorm(t.href); + const tb = tn.split('/').pop(); + const matches = !!(base && tb && (base === tb || base.includes(tb) || tb.includes(base))) + || tn === n + || (n.endsWith('/' + tn) || tn.endsWith('/' + n)); + if (matches) matchFlatIdx = i; + }); + + if (matchFlatIdx >= 0) { + for (let i = matchFlatIdx; i >= 0; i--) { + if (tocFlatItems[i].depth === 0) { + return { tops, idx: tops.indexOf(tocFlatItems[i]) }; + } + } + } + + const curSpine = rendition?.currentLocation?.()?.start?.index; + if (curSpine != null) { + let bestIdx = -1; + tops.forEach((t, ti) => { + const spineItem = findSpineItemForHref((t.href || '').split('#')[0]); + if (spineItem?.index != null && spineItem.index <= curSpine) bestIdx = ti; + }); + return { tops, idx: bestIdx }; + } + + return { tops, idx: -1 }; +} + +async function navigateToTocChapter(target) { + if (!target?.href || !rendition) return; + const [hrefBase, anchor] = (target.href || '').split('#'); + const spineItem = findSpineItemForHref(hrefBase); + const displayTarget = spineItem?.index != null + ? (anchor && target.depth > 1 ? `${spineItem.href}#${anchor}` : spineItem.href) + : target.href; + console.log(`[nav] CHAP | depth=${target.depth} → ${JSON.stringify(displayTarget)}`); + try { + await rendition.display(displayTarget); + scheduleBionicPrefetchAround(rendition.currentLocation?.()); + } catch { + if (target.href && displayTarget !== target.href) { + try { + await rendition.display(target.href); + scheduleBionicPrefetchAround(rendition.currentLocation?.()); + } catch { /* ignore */ } + } + } +} + // ── Panels ──────────────────────────────────────────────────────────────────── function openToc() { const centerActiveTocItem = () => { @@ -2364,7 +2581,7 @@ function openToc() { tocSidebar.classList.add('open'); settingsPanel.classList.remove('open'); panelBackdrop.classList.add('visible'); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); // Fallback recenter after slide-in in case TOC updates while opening. setTimeout(() => { centerActiveTocItem(); @@ -2386,7 +2603,7 @@ function openSettings() { tocSidebar.classList.remove('open'); bookmarksSidebar.classList.remove('open'); panelBackdrop.classList.add('visible'); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); activateSettingsTab(localStorage.getItem('settingsTab') || 'theme'); renderDictSettings(); } @@ -2395,13 +2612,37 @@ function openBookmarks() { tocSidebar.classList.remove('open'); settingsPanel.classList.remove('open'); panelBackdrop.classList.add('visible'); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); +} +function isJumpPanelOpen() { + return jumpPctPanel && jumpPctPanel.style.display !== 'none'; +} + +function openJumpPanel() { + if (!jumpPctPanel) return; + const pct = String(Math.round(currentPct * 100)); + jumpPctSlider.value = pct; + jumpPctValue.value = pct; + jumpPctPanel.style.display = ''; + jumpPctBackdrop?.classList.add('visible'); + jumpPctBackdrop?.removeAttribute('hidden'); + if (prefs.autoHideHeader) { + readerLayout.classList.add('header-peek'); + syncHeaderDismissBackdrop(); + } } + function closeJumpPanel() { + if (!jumpPctPanel) return; jumpPctPanel.style.display = 'none'; + jumpPctBackdrop?.classList.remove('visible'); + jumpPctBackdrop?.setAttribute('hidden', ''); + jumpPctValue?.blur(); // Re-evaluate auto-hide: hide header unless something else is keeping it open if (prefs.autoHideHeader && !tocSidebar.classList.contains('open') && !bookmarksSidebar.classList.contains('open') && !isMouseOverHeader) { - readerLayout.classList.remove('header-peek'); + forceHideAutoHeader(); + } else { + syncHeaderDismissBackdrop(); } } function closePanels() { @@ -2416,7 +2657,7 @@ function closePanels() { panelBackdrop.classList.remove('visible'); closeJumpPanel(); if (searchHadFocus && typeof activeEl.blur === 'function') activeEl.blur(); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); } function hasOpenPanel() { @@ -2488,7 +2729,7 @@ function openSearch() { tocSidebar.classList.remove('open'); settingsPanel.classList.remove('open'); panelBackdrop.classList.add('visible'); - if (prefs.autoHideHeader) readerLayout.classList.remove('header-peek'); + if (prefs.autoHideHeader) forceHideAutoHeader(); setTimeout(() => searchInput.focus(), 280); } @@ -3038,6 +3279,22 @@ function syncSettingsUi() { if (el) el.value = prefs.edgePadding[side]; if (vl) vl.textContent = prefs.edgePadding[side] + 'px'; }); + const navLeftEl = document.getElementById('nav-zone-left-slider'); + const navLeftVl = document.getElementById('nav-zone-left-value'); + if (navLeftEl) navLeftEl.value = prefs.navZoneLeftPct ?? 20; + if (navLeftVl) navLeftVl.textContent = (prefs.navZoneLeftPct ?? 20) + '%'; + const navRightEl = document.getElementById('nav-zone-right-slider'); + const navRightVl = document.getElementById('nav-zone-right-value'); + if (navRightEl) navRightEl.value = prefs.navZoneRightPct ?? 20; + if (navRightVl) navRightVl.textContent = (prefs.navZoneRightPct ?? 20) + '%'; + const headerRevealEl = document.getElementById('header-reveal-zone-slider'); + const headerRevealVl = document.getElementById('header-reveal-zone-value'); + if (headerRevealEl) headerRevealEl.value = prefs.headerRevealZonePct ?? 12; + if (headerRevealVl) headerRevealVl.textContent = (prefs.headerRevealZonePct ?? 12) + '%'; + const headerBtnEl = document.getElementById('header-button-size-slider'); + const headerBtnVl = document.getElementById('header-button-size-value'); + if (headerBtnEl) headerBtnEl.value = prefs.headerButtonScalePct ?? 100; + if (headerBtnVl) headerBtnVl.textContent = (prefs.headerButtonScalePct ?? 100) + '%'; // Paragraph options const piEl = document.getElementById('para-indent-toggle'); if (piEl) piEl.checked = prefs.paraIndent; @@ -3236,6 +3493,11 @@ function syncStatusBarSettings() { if (chapProgPos) chapProgPos.value = sb.chapProgressBar.position; if (chapProgThickSlider) chapProgThickSlider.value = sb.chapProgressBar.thickness; if (chapProgThickValue) chapProgThickValue.textContent = sb.chapProgressBar.thickness + 'px'; + + // Clock format + const clockFmt = sb.clockFormat || '24h'; + document.getElementById('sb-clock-12h')?.classList.toggle('active', clockFmt === '12h'); + document.getElementById('sb-clock-24h')?.classList.toggle('active', clockFmt === '24h'); } function populateSbFontSelect() { @@ -3346,6 +3608,16 @@ function initStatusBarSettings() { document.getElementById('sb-chap-prog-thick-value').textContent = prefs.statusBar.chapProgressBar.thickness + 'px'; applyProgressBarLayout(); persistPrefs(); }); + + // Clock format + ['12h', '24h'].forEach(fmt => { + document.getElementById(`sb-clock-${fmt}`)?.addEventListener('click', () => { + prefs.statusBar.clockFormat = fmt; + syncStatusBarSettings(); + updateStatusBar(lastLocation); + persistPrefs(); + }); + }); } function initSliderButtons() { @@ -3590,6 +3862,28 @@ function initSettingsUi() { }); }); + document.getElementById('nav-zone-left-slider')?.addEventListener('input', (e) => { + prefs.navZoneLeftPct = parseInt(e.target.value); + document.getElementById('nav-zone-left-value').textContent = prefs.navZoneLeftPct + '%'; + applyNavZones(); persistPrefs(); + }); + document.getElementById('nav-zone-right-slider')?.addEventListener('input', (e) => { + prefs.navZoneRightPct = parseInt(e.target.value); + document.getElementById('nav-zone-right-value').textContent = prefs.navZoneRightPct + '%'; + applyNavZones(); persistPrefs(); + }); + document.getElementById('header-reveal-zone-slider')?.addEventListener('input', (e) => { + prefs.headerRevealZonePct = parseInt(e.target.value); + document.getElementById('header-reveal-zone-value').textContent = prefs.headerRevealZonePct + '%'; + applyHeaderRevealZone(); persistPrefs(); + }); + document.getElementById('header-button-size-slider')?.addEventListener('input', (e) => { + prefs.headerButtonScalePct = parseInt(e.target.value); + const vl = document.getElementById('header-button-size-value'); + if (vl) vl.textContent = prefs.headerButtonScalePct + '%'; + applyHeaderButtonSize(); persistPrefs(); + }); + // Dictionary popup close document.getElementById('dict-popup-close').addEventListener('click', closeDictPopup); @@ -3625,6 +3919,9 @@ function initSettingsUi() { initStatusBarSettings(); applyStatusBarStyles(); applyEdgePadding(); + applyNavZones(); + applyHeaderRevealZone(); + applyHeaderButtonSize(); applyPageShadow(); initSliderButtons(); } @@ -3835,6 +4132,19 @@ function pushInternalProgress(docKey, xpointer, pct) { }).catch(() => {}); } +// Initialise the Battery Status API once. Triggers a status-bar refresh on any change. +// Silently no-ops on browsers that don't support it (Firefox, Safari, iOS). +async function initBattery() { + if (_batteryMgr || !navigator.getBattery) return; + try { + _batteryMgr = await navigator.getBattery(); + const refresh = () => { if (lastLocation) updateStatusBar(lastLocation); }; + _batteryMgr.addEventListener('levelchange', refresh); + _batteryMgr.addEventListener('chargingchange', refresh); + refresh(); // apply immediately + } catch { /* not available */ } +} + async function saveProgress({ forceRemote = false, allowRemote = true } = {}) { if (!currentBook || !isReady) return; if (isPeekMode) return; @@ -3901,10 +4211,11 @@ function showSyncDialog(best, localPct, localTime) { const fmtTs = (ts) => ts ? new Date(ts * 1000).toLocaleString(getCurrentLang()) : t('reader.sync_dlg_unknown_time'); const rDate = fmtTs(best.timestamp); const lDate = fmtTs(localTime); - const rNewer = (best.timestamp || 0) > (localTime || 0); + const rNewer = (best.percentage || 0) >= localPct; // show the forward position as the highlight backdrop.innerHTML = `