Reader fullscreen: the topbar gains a fullscreen toggle (icon state + f
shortcut + help row; f is guarded so Ctrl/Cmd+F stays find-in-page).
Enter requests the Fullscreen API with fallbacks (desktop/Android get a
true full display; iOS Safari has no page-fullscreen API and falls back
to the soft visual mode only). Margins: the foliate paginator's closed
shadow root only yields through its observed 'margin' attribute, so
fullscreen fits a comfortable ratio (6% of the short side, floored at
48px) and floors it by the measured env insets — Android reports true
cutout insets in fullscreen, so portrait phones keep text clear of the
camera band; iOS letterboxes are already env-padded chrome. Fixed-layout
renderers skip it (fxl/webtoon pack pages edge-to-edge by design).
Exits the user makes outside the toggle (Esc, browser UI) sync via
fullscreenchange(+webkit) and restore the ordinary 48px margin; a
position-keeping re-render follows the ResizeObserver re-entry path.
Verified on-device: enter -> true fullscreen + icon swap + camera band
clear, exit -> chrome back, margins restored, position kept.
Volume: revert the stacked panel — the popup returns to the liked shape
(mute button left, horizontal dragger right, dialogue above the strip);
one-line flex restore of the pre-89665fb layout.
Manga badges: MediaBadgeKind makes the library type authoritative for
the comic/manga split — manga volumes ship as fixed_layout scans as
often as comic_archive (and manga_type is unreliable across sources:
unknown/NULL on real rows), so the old comic_archive+manga mapping left
the scan volumes unbadged. Anything in a manga library reads manga;
anything in a comics library reads comic. Verified: /dashboard lands 24
title="Manga" cover badges for the Manga library and the sections API
reports badge=manga for a fixed_layout item.
Ghost sidebar: the >=1024px media block applied body
padding-left:var(--sidebar-w) unconditionally, so pages that never
render the sidebar (logged-out pages, the audio player, the reader
itself) carried a phantom 16rem left margin that pushed content off
center on desktop widths. The body:has(.app-sidebar) rule below was
already the correct conditional source — it is now the only one.
Verified in the built stylesheet: exactly one such rule remains.
Infra: main.js ?v=3 stamp bump (reader.ts changed; hashed chunks churn
their URLs themselves).
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.
toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.
Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.
Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).
The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).
Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.
Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:
1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
dot's pointermove listener passed raw e.clientX/clientY PIXELS.
Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
picks the bottom-right-most glyph — the extent leaped to the page's
end and never moved again (stuck). Engine-independent: failed
identically on Chrome/Brave/Firefox desktops; my synthetic tests
masked it by passing pre-converted fractions. handleMove now maps
host px → fractions internally (one contract).
2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
rect with the host iframe rect. Whether that combination is valid
depends on the engine's cross-document getBoundingClientRect
semantics. Replaced with the page WRAPPER element (the iframe's
parent — the same box the fork's rect-annotation overlay fills),
measured in a single host context. Handles now align with the
painted highlights by construction.
3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
gap tall). Dedupe at a full box height ate the line-below char in
the same x-column (silent character loss); clustering at full box
height never split lines (merged 0.45-tall "line" boxes). Dedupe
now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
the char's own height (inter-line gap ≈ 1.0× breaks, baseline
variance ≈ 0.1× stays).
Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.
The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.
Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.
Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.
Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.
CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.
Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.
The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.
Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.
- startAnyAdjust routes the popover button by format; startPdfAdjust
runs a rect-edge session: 4 grips per rect (all edges, one rect per
drag), live preview through the renderer's own addRectAnnotation
(replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
tokens (start edge takes the word start, end edge the word end), each
punctuation glyph is its own token with both edges grabbable (a period
or line-break hyphen can be taken or shed precisely). Horizontal edges
snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
rects (reading order, lines joined with spaces) so the stored text
stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
iframe docs are mapped through a WeakMap filled by the view's load
events. Pointer mapping goes through the canvas's post-transform rect
(the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
span set into the same .textLayer without clearing, and undeduped
glyphs doubled the recomputed text.
Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
The drawer was the only top-bar tool without a keyboard shortcut, so the
shortcuts & gestures help modal had to describe it as '📝 button'. Bind
plain 'a' to toggleBookmarks(), following the same pattern as the
existing t/s/b letter shortcuts.
- reader.ts: new 'a' branch in handleKeydown, guarded against
Ctrl/Cmd/Alt so Select All (a common reader action when selecting
text to highlight/copy) is never intercepted; typing guard already
protects note textareas, search box, and selects. Works in both the
host page and book-content iframes since both keydown listeners route
through handleKeydown.
- reader.templ: help modal row now shows <kbd>a</kbd> like the other
letter shortcuts; 📝 button tooltip gains '(a)' to match its
siblings (b), (/), (s), (?).
- reader_templ.go: regenerated.
Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.
Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.
Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.
The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.
Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).
Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.
Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.
Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:
- Live character counter under both note textareas (annotations
drawer "Add a note" and the selection popover note editor),
formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
note) disable while over the limit, with a toast fallback in
addNote / createHighlight / saveHighlightChanges so the guard
holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
internal/handlers/media.go) so client and server stay linked
Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.
The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
PDFs shelved in comics or manga libraries (scanned manga, official manga
PDFs with text layers) now get comic reader treatment: bookmarks only —
no text-selection highlights, no annotations, no in-book search. Items
in ebooks libraries are completely unaffected, and EPUBs keep their
existing behavior everywhere.
- handlers: new exported ShouldTreatAsComic(libraryType, formatGroup) —
true for fixed_layout/comic_archive in manga/comics libraries; the
reader JSON API also exposes it as treat_as_comic
- router: the SSR reader page fetches the library type and passes
TreatAsComic through ReaderMetadata into the init config
- reader: when treatAsComic is set, the PDF textLayer selection listener
(mouse and touch paths) never attaches so no highlight popover can
open; pointer mode is forced to pan (Smart/Text segment hidden);
search button and toggleSearch are disabled; previously-created PDF
highlights stop rendering
- bookmarks are unchanged (already page-index based for fixed layout),
and device sync / OPDS / format classification are untouched since
format_group stays fixed_layout
Cancel only collapsed the editor section, and edit-mode saves only set
noteOpen=false — the minimized popover (colors/pencil/copy/delete) stayed
up. Explicit Save and Cancel now call hideSelectionPopover; color tweaks
with the editor open still keep it open.
- The in-book pointerdown dismiss listener lived in the reflowable-only
block, so PDF taps never closed the selection popover — hoist it so
every content doc (EPUB, PDF, comics) dismisses on tap.
- createHighlight hardcoded note_text: '' — 'Highlight with note' saved
the highlight but silently dropped the note. Send p.note (and pass it
to the EPUB overlayer).
- saveHighlightChanges collapsed the note editor on every save; color
dots in create mode created a highlight outright. With the editor
open, color dots now just recolor (create) or recolor-and-save with
the editor kept open (edit).
Chromium's touch selection takeover swallows pointerup in the fx iframe,
so the PDF popover's only trigger never fired on real phones — it only
appeared when timing happened to deliver the event. Mirror the EPUB
path: a selectionchange debounce (400ms settle) opens the popover, with
the pointerup path kept for desktop plus the quick-tap word-select guard.
Also place the popover BELOW the selection on coarse pointers (+90px
clearing the native selection menu and drag handles), matching the
reflowable path; desktop keeps above-placement.
pokeChrome's anySelection gate only prevents the chrome from being
raised — if it was already visible when the selection began, it
stayed up. noteSelectionActivity now actively hides it the moment
a selection appears.
- Custom drag handles (start/end) for post-lift selection adjustment:
the native handles are disabled with the rest of the native touch
selection controller; these are positioned at the selection's
boundary carets and driven through the same clamped caret mapping
- Copy button falls back to execCommand via a transient textarea on
plain HTTP (navigator.clipboard is unavailable on LAN addresses);
both paths clear the selection and dismiss the popover
- Only dismiss the selection popover on actual position changes in
relocate (detail.section is a fresh object on every relocate, so
reference comparison always saw a move); a tap's no-op relocate
was closing the just-opened highlight edit popover
- Pin foliate-js f872a01 (synthetic click dispatch on quick taps)
and 422e8e0 (don't snap taps that never panned)
- tap zones: map in-iframe taps into the visible page slice (the
iframe is laid out at full section width; every tap previously
computed as the left margin) and shrink the default zone size to 12%
- hyphens: manual on coarse pointers: Chrome's touch word-selection
walks hyphen fragments, re-anchoring line-start drags and
overshooting line ends into the next column
- selection popover: opens for settled touch selections (the gesture
takeover swallows pointerup), positioned below the selection with
on-screen clamping; creating a highlight clears the selection so
the browser's own menu follows
- settle-time recovery: return the view to the selection's anchor
page and clamp the selection to the visible page after the
browser's selection auto-scroll wanders off the page grid
- overscroll-behavior: none on the reader page (pull-to-refresh
during downward drags)
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.
Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:
- The relocate handler records the latest CFI (lastCfi), and the
baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
view.init() resolves — i.e. the restored position, or the start of
the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
CFI comparison for reflowable books, fraction comparison (1e-4
epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
bfcache-resurrected page re-baselines to its frozen position, so the
anti-clobber property survives: a displayed/restored position can
still never overwrite a newer device push.
The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
Clicks on reader chrome already dismissed the selection popover via the
host-document outside-click handler, but clicks inside the book happen
in content iframes whose events never bubble to the host document — the
host handler never sees them. The only iframe-side dismissal ran through
the selection tracker's collapsed check, which hides the popover solely
in create mode: once the popover was open in EDIT mode (clicked a
highlight, writing a note), clicking anywhere in the book did nothing
and Esc was the only way out.
Each content iframe now gets a pointerdown listener that dismisses the
popover in any mode. Clicking a painted highlight still opens the edit
popover: this hides first, then foliate's show-annotation re-opens it.
The reader page embedded a snapshot of reading state (position,
bookmarks) server-side at render time. Browsers may reuse that HTML
(heuristic caching, bfcache), so opening a book could restore a stale
position — and worse, the restore's relocate auto-saved it back,
overwriting a newer device push minutes later. A KOReader sync followed
by opening the web reader would silently revert the row to the old web
position; the row's source and the rendered page disagreed.
The web reader is intrinsically tied to the server, so it has no business
preserving reading state client-side:
- The rendered page now carries only immutable book metadata. The reader
fetches progress fresh (cache: no-store) from the existing progress
API at open and restores with the same priority as before (page for
fixed-layout, CFI, percentage, fresh start); a failed fetch opens at
the start and writes nothing. Initial bookmarks likewise come from
their endpoint instead of the embed; annotations already did.
- Progress saves are gated on deliberate navigation only (page turns,
keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each
marks the session as user-moved). Restores and section-load
relocations never write, so displaying a position can no longer
clobber a newer one. A bfcache-resurrected page resets the flag and
cannot write its frozen position either. This replaces the old
five-second post-init suppression, which a stale page bypassed.
- The server-rendered initial progress badges render a neutral
placeholder until the first relocate fills them (sub-second).
No API, schema, or sync-engine changes. Normal reading saves exactly as
before — the first save now simply waits for the first real page turn.
Selecting text near the left edge on a touch browser could page back
instead: a long-press released just inside the 500ms tap window (Android
selection engages at ~400-500ms, right at the guard boundary) or landing
on a margin resolved as a tap, armed the 280ms debounce, and nothing
ever cancelled it. Four hardenings in the tap-zone pipeline:
- contextmenu (Android's long-press-engaged signal) suppresses the
matching pointerup from counting as a tap, closing the duration race
- pointercancel (the browser taking over the gesture) now resets the
tracked pointer so stale state can never match a later touch
- selectionchange on the host document and every content iframe cancels
an armed tap action: a selection appearing right after finger-lift
means the 'tap' was a long-press selection engaging
- the host-viewport pointerup honors the tracked any-selection flag,
closing the blind spot where selections in iframes or the host's own
fixed-layout text layer were invisible to the host surface (the
per-surface check only ran for iframe docs)
Purely touch-path (coarse pointer) changes; desktop behavior untouched.
handleKeydown computed a 'typing' guard for the event target but only
applied it to the drawer-shortcut block (t/s/b/?//). The vi-style page
turns (h/l), arrows, and zoom keys (+/−/0) fired regardless, so typing
into the note textarea hijacked the keys: 'Wh' turned back a page on the
h, arrows moved pages instead of the caret, and digits/minus zoomed.
Return early for INPUT/SELECT/TEXTAREA/contentEditable targets, keeping
Escape live so popovers and drawers stay dismissable from the keyboard
mid-note. Covers the selection-popover note field, the notes drawer
textarea, bookmark rename, and the search box. The now-unreachable
'!typing' condition on the shortcut block is dropped.
Device-synced highlights paint with a synthesized range CFI (renderCfi,
built by toRangeCfi from the stored point CFIs and selection text) while
the stored locator stays a point CFI. Three follow-ons from that split:
- show-annotation (click-to-edit) matched the clicked value against the
stored point cfi only, so clicking a device-created highlight never
opened the edit popover — it listed in the drawer but was uneditable.
Match either the stored cfi or the renderCfi the overlay was added by.
- deleteHighlightById removed the overlay with the stored point cfi,
which never matched the painted value; the highlight box lingered
until reload. Delete with the value it was added by.
- saveHighlightChanges re-added the overlay without removing the old
value; an edit that changes the synthesized range (note/text edits
change the UTF-16 length it derives from) would ghost the old paint
beside the new one. Remove the previous overlay value first when the
edit changed it.
Web-created highlights are unaffected: their stored CFI is already a
native range, so renderCfi === cfi for them.
- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
shared verbatim across clients; same-title bookmarks on different pages
now coexist instead of 500ing (deleting over a tombstone no longer
blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
falling back to 'Bookmark'; adds bookmark rename in the drawer
Device-synced highlights stored POINT CFIs (epubcfi(.../8/1:1)); the
overlayer resolves those to a collapsed range and paints nothing, so
KOReader-made highlights were listed in the drawer but invisible on
the page. mapHighlightRow now builds a renderCfi: a proper RANGE CFI
(epubcfi(base,/start,/end)) synthesized from the stored start/end
points. It also repairs stale rows: missing ends (old web highlights)
and degenerate document-start ends (the old converter fallback) are
derived from the start offset plus the selection text's UTF-16
length. All overlay drawing, navigation (showAnnotation), and the
post-create/post-edit re-adds use renderCfi. Verified in-browser
against live device-synced rows: the paginator's overlayer paints
the highlight rects after the fix.
Web highlights stored only epubcfi_start, so devices received
degenerate pos0 == pos1 (zero-length) highlight ranges. The reader
now collapses the selection range to its end point for a second CFI
and stores it as epubcfi_end (PDF rect anchors reuse the JSON anchor
for both ends).
Phase 4 of the reader redesign (foliate-js ea268df):
- Webtoon mode for comics: continuous vertical scroll of all pages
(900px centered column on wide screens), lazy-loaded with a 150%
IntersectionObserver margin, far pages unloaded to bound memory
with stable aspect-ratio placeholders so the scrollbar never jumps.
Chosen per book (Paged | Webtoon segmented control in Settings →
Layout & Display; stored in localStorage per media item since a
webtoon title and a paged manga volume want different flows).
Toggling reloads the reader — the renderer is chosen at open time —
and progress restores from the saved page. Relocate events flow
through the same pipeline, so the slider, progress saving, back
stack, tap zones, and edge zones all work unchanged. Zoom/fit/
magnifier/spread controls hide in webtoon (natural-width scroll).
- Display filters for fixed-layout: brightness (30-130%) and
contrast (70-130%) sliders with live preview, plus Night Mode
(invert) — also a quick row in the ⋯ tools menu. One --fx-filter
CSS var drives everything: ::part(filter) on foliate-view iframes
(forwarded via the new exportparts attribute) and the webtoon
page images alike. Persisted as fx_brightness/fx_contrast/fx_invert
(types + defaults both sides); Restore Defaults resets them.
Help menu (the reader had a growing shortcut/gesture vocabulary with
no discoverability): a ? topbar button, the '?' key, and F1 open a
glass modal listing navigation, zoom/pan, highlight, and touch
gesture reference — format-aware (fixed-layout/PDF rows appear only
where they apply), Esc closes it first in the dismiss chain.
Desktop edge zones: clickable page-turn strips on the left/right
viewport edges (8% width, 44-72px), desktop only (hover+fine-pointer
media query — touch devices use tap zones, avoiding double paging).
Hovering reveals a chevron arrow and a subtle edge gradient. Zones
disable (pointer-events pass-through) while a fixed-layout page is
zoomed so edge clicks belong to content: panning, selection,
highlight editing. fxZoomed tracks zoom state via the renderer zoom
event, reset/fit actions, and init.
The 🏷️ bookmark button (and the 'b' shortcut) saved silently — an
accidental click gave no reaction at all. addBookmark() now shows a
short success toast ('Bookmark added — <progress>') using the
existing toast system, which the reader bundle hadn't been importing.
Importing it also activates the shared fetch interceptor, so failed
reader API calls (incl. bookmark saves) surface error toasts instead
of being swallowed.
Investigation: the contents drawer read book.toc, which makePDF
builds from pdf.getOutline() — verified against the real library PDF
(Head First SQL) through the exact vendored pdf.js build AND the exact
range transport the browser uses: 18 chapter entries come back. So
the source is right; manga-scan PDFs and CBZs simply have no embedded
outline, which made Contents look broken exactly where users expect
page-based navigation.
- TOC now populates eagerly right after the book opens (toggle-time
lazy population removed), so an existing outline can never silently
miss due to timing; the drawer keeps the honest empty-state text
for books without outlines.
- New 'Pages' tab in the contents drawer for fixed-layout books:
a Kavita-style thumbnail grid (3-up, current page highlighted and
scrolled into view, click to jump — recorded on the back-to-
location stack). Thumbnails render client-side: PDFs via the
in-memory pdf.js document (small viewport render, Alpine.raw
unwrap); comics via the page's image blob drawn down to a 110px
canvas, then unloading the full-size blob so thumbnailling doesn't
hoard page images. Lazy via IntersectionObserver scoped to the
drawer's scroll container (200px margin), canvases cached at module
level so revisits are instant; failures warn in console and allow
retry. The backend /readers/thumbnails endpoint turned out to be an
empty stub, so nothing server-side was worth wiring.
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.
- Back-to-location moves from the topbar (where it sat between Back
and the title, too subtle and disconnected from navigation) into
both bottom-bar rows, beside the page-back arrow — the natural
'go back' cluster. New icon: a location pin, clearly distinct from
the back arrow and page controls. Appears only when the stack has
a return target; Alt+← unchanged.
- New recenter button in the fixed-layout row (crosshair icon, next
to zoom): resets pan offsets while keeping the current zoom —
backed by foliate's new recenter() (1c812e8), which zeroes the
wrapper translate and re-syncs the spread side.
- Both bottom-bar rows wrap gracefully on narrow windows instead of
overflowing/h-scrolling: controls are grouped (paging+back | slider |
fit+zoom+magnifier+recenter | pointer mode | spread | progress+TOC)
so groups flow to a second line at small widths; the slider shrinks
first (grow + min-width), everything else stays whole. Fixed-layout
row drops its overflow-x-auto.
Highlights landed on the right line but shifted right and oversized
on any display with devicePixelRatio != 1. Cause: selection fractions
divided the textLayer span rects by documentElement's screen rect,
but pdf.js scales the iframe's <html> by 1/dpr — that rect is dpr×
smaller than the visible page, inflating every x/w fraction by dpr
(on a 2× display a highlight started twice as far right and was twice
as wide). dpr=1 displays were coincidentally correct, which is why
the geometry looked sound when written.
The denominator is now the rendered canvas (#canvas canvas), whose
post-transform rect IS the visible page and shares the textLayer's
transform space — the dpr scaling cancels exactly. Comics keep the
img denominator; a viewport fallback covers any page without either.
The popover-placement scale factors (frame/denominator) become 1 for
PDFs as a side effect, fixing popover drift too. The fork's click
hit-test (86e234d) gets the same canvas-aware denominator so clicking
highlights opens the editor at the right spot.
Highlights saved before this fix stored dpr-inflated fractions and
will still render misplaced — delete and re-create them.
PDF search diagnosis: extraction and matching were proven correct
against the real 609-page library PDF (pdfjs 5.5.207, incl. the exact
range-transport setup makePDF uses — 841 hits for 'SELECT'), and the
served bundle had every piece. The failure was Alpine's reactivity:
this.book is a plain object, so reading .pdf through component state
returns a reactive Proxy around the PDFDocumentProxy — and pdf.js
v5 uses #private fields, so getPage() through the proxy throws
'cannot read private member', which the empty catch rendered as a
silent empty result set. runPdfSearch now unwraps via Alpine.raw
(falls back to the raw read), and search failures surface in the
drawer ('Search failed — see console') plus console.warn instead of
masquerading as 'No matches'.
Back-to-location stack (research/footnote workflow): the current
position is recorded before every programmatic jump — search-result
clicks, TOC entries, bookmark and highlight jumps — and on every
internal link click (footnotes, cross-references) via foliate's
'link' event. A ↩ button appears in the topbar once a return target
exists; Alt+← works everywhere. Ordinary paging never pollutes the
stack (max depth 50, consecutive duplicates collapse).
PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:
- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
page's textContent with item geometry (progress-reported, cached
after first search). PDF text items often omit inter-word spaces
(gaps are positional), so pages are joined gap-aware — baseline
changes, hasEOL, or horizontal gaps past a font-size threshold
become spaces — recording a char→item map. searchPdfPages() does
case-insensitive matching over the joined text and maps each hit
back to the page-fraction rects of the items it spans, with
ellipsized pre/match/post excerpts. Pure functions, unit-sanity
checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
the extracted pages, group hits per page ('Page 12'), and render
on-page hit rectangles through the existing fraction-rect overlay
(addRectAnnotation) — which re-render automatically when pages
revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
appear for PDFs too (comics remain without searchable text).
Wires foliate's search engine into the new drawer system:
- 🔍 topbar button (reflowable-only; PDF/comic sections have no
searchable text documents) and the '/' keyboard shortcut open a
Search drawer: query input (Enter to run), live progress while
scanning (per-section percent), match count, and results grouped
by section with TOC labels.
- Each result shows pre/match/post excerpt rendered as three text
nodes (no x-html — book content never enters the DOM as markup);
the match is styled with a translucent <mark>. Clicking jumps to
the hit's CFI and closes the drawer.
- Hits are drawn on the page through foliate's overlayer (outline
style) and persist across page turns — the engine re-applies
search results when a section's overlay is created. Clearing the
query removes the outlines.
- A generation counter discards results and progress from superseded
searches (rapid re-query), and starting a new search clears the
previous one server-side via view.clearSearch().
- Search integrates with the drawer system: scrim, Esc-to-close,
one-drawer-at-a-time, / focuses the input via .
Two bugs broke the Phase 3b PDF highlight flow end to end:
1. Selection capture never attached: reader.ts read renderer.isPDF
before view.init() rendered the first spread, but the renderer
only sets that flag once frames exist (PDF frames carry pdf.js
onZoom). The stale undefined copy gated the pointerup selection
listener off, so selecting PDF text did nothing. The listener now
gates structurally on the loaded document having a .textLayer
(true for every PDF page, false for comics), and isPDF is re-read
after init — which also finally makes the Smart|Pan|Text control
and the saved pointer mode apply on PDFs.
2. Highlights rendered invisibly: the overlay SVG lived inside the
page iframe, whose <html> pdf.js scales by 1/devicePixelRatio —
shrinking the overlay into the top-left corner on any dpr != 1
display. The fork (1c0ebf3) now renders annotation rects
host-side, inside the frame wrapper element, positioned in
percentages of the visible page box — immune to the html
transform, zoom re-renders, comic iframe scaling, and pan/zoom.
Phase 3b of the reader redesign — highlighting for fixed-layout PDFs:
- Select text on a PDF page → same glass popover as EPUBs (colors,
note, copy). The selection's client rects are normalized to
page-fraction quads using a transform-inclusive denominator so
pdf.js's devicePixelRatio scaling on <html> cancels out, then
stored as a JSON anchor {page, rects} in epubcfi_start.
- Rendering goes through the fork's new rect-annotation pipeline
(foliate-js aba68d8): a full-bleed viewBox-0-100 SVG inside the
page iframe, so highlights stay aligned through pan/zoom, iframe
CSS-scaling, and PDF hi-res re-renders with zero re-anchoring.
Frames carry their page index and re-render annotations when
recreated on spread changes.
- Clicking an existing highlight hit-tests in fraction space and
opens the edit popover (recolor, note, copy, delete) at the
host-space click position; drag-selecting text never triggers it.
- Annotations drawer: PDF highlights jump by page index; notes and
recolors round-trip through the same LWW/dedup sync path as EPUBs
(same dedup key derivation on the JSON anchor).
- Comics keep bookmark-only highlighting (no text layer) by design.
Phase 3 (EPUB half) of the reader redesign:
- Select text in a reflowable book → floating glass popover at the
selection (5 colors, note, copy). Clicking a color creates the
highlight via POST /api/media-items/:id/highlights, anchored by the
foliate range CFI (epubcfi_start) with percentage position.
- Highlights render through foliate's overlayer pipeline: draw-
annotation draws Overlayer.highlight with the stored color,
create-overlay re-adds persisted highlights as sections load,
show-annotation opens the edit popover when a highlight is clicked
(recolor, edit note, copy, delete).
- Backend: highlight create/update accept epubcfi_start/end,
note_text, and percentage fields; position validation relaxed
(CFIs exceed the old 100-char cap); PUT routes through
AnnotationService.SaveHighlight so edits get dedup/LWW treatment
and actually persist note_text (the plain query can't).
- Bookmarks drawer becomes the Annotations drawer with tabs:
Highlights (color-bar list, note previews, jump/edit/delete),
Notes (add note at current position, list, delete — backed by the
existing notes API), and Bookmarks (unchanged behavior).
- Popover dismissed on outside click, collapsed selection, page
navigation, or Esc (new top-priority Esc branch).
Phase 2 of the reader redesign:
- Fixed-layout touch engine (foliate-js e9e61d8): pinch-zoom around
the midpoint, two-finger pan, single-finger pan while zoomed,
horizontal swipe page-turn at fit (RTL-aware via next()/prev()),
and double-tap to zoom 2.5x / reset. Touch events forwarded from
page iframes with converted coordinates; preventDefault only when
the engine consumes the gesture, so PDF text selection and native
taps stay intact. touch-action: none on the host and in comic/pdf
page documents keeps the browser from fighting the engine.
- Tap zones (Kindle-style) for touch devices: tap the outer margins
to page, center to toggle chrome. Size configurable (10-50%) via
the revived tap_zone_size setting; toggle via new tap_zones_enabled
(Behavior section of the settings drawer). Pointer-based + passive
so drags/swipes/selection never trigger; attached both to the
viewport and inside every page document (iframe events don't
bubble); debounced 280ms so double-tap zoom doesn't also page; no
zone actions while a fixed-layout page is zoomed.
- Drawers become full-width sheets on screens <= 640px.