354 Commits
Author SHA1 Message Date
John O'Keefe cd2fbf278e feat(text): plain-text (.txt) renderer
makeTextBook: UTF-8 decode (BOM stripped), blank-line paragraph split,
hard-wrapped lines joined (natural reflow under font-size changes), HTML
escaped, sections capped at ~150KB at paragraph boundaries (small Part N
TOC, avoids one giant DOM). Sections expose load/createDocument/size per
the reflowable interface; positions for txt books are percentage-based
(sections get fake CFIs from index, same as other non-EPUB books).

makeBook: dispatches text/plain or .txt in the final non-zip/non-pdf
branch. EPUB/PDF/CBZ/MOBI/FB2 routing untouched.

Tests: offline node harness (isText, paragraph join/split, escaping,
section capping, TOC shape, dispatch incl. zip-still-routes-to-EPUB).
2026-09-30 15:12:05 -04:00
john-okeefe e16530ad86 Add double-click zoom toggle to the fx renderer
Desktop counterpart of the existing touch double-tap: at fit scale a
double-click zooms to 2.5x at the clicked location; zoomed or panned it
resets to 100% centered. Page iframes forward dblclick with converted
coordinates and real targets; PDF text-layer spans and annotation links
keep native double-click (word selection) in select/text modes. Applies
to all fixed-layout content (comics, manga, PDFs, fixed EPUBs).
2026-09-17 14:17:51 -04:00
John O'Keefe 734ce41a50 chore: bump version to 0.1.0
npm's cache reuse keyed on name@version (stuck at 0.0.0) silently served
stale tarballs to Docker builds; bumping on every deploy-worthy change
forces fresh resolution.
2026-09-17 10:10:24 -04:00
John O'Keefe 1305a5280c Ignore small height-only viewport resizes in the fx renderer
Mobile browser toolbar transitions change the viewport height by a
fraction mid-gesture; re-rendering the PDF for that races with
active text selections and can blank the canvas (the old canvas is
cleared for the re-render, and the async pdf.js render can fail or
get interrupted by the next resize). Only re-render for real layout
changes — same threshold as the paginator.
2026-09-16 17:23:59 -04:00
John O'Keefe 94bb38453d Don't classify selection-extension drags as swipe/pan
The gesture classifier only checked whether the touch STARTED on a
.textLayer span. Chromium's long-press selects the nearest word even
when the finger landed between spans (on the .textLayer div, not a
span inside it), so the classifier saw a non-selectable target and
classified the drag as swipe/pan — preventDefault on the iframe's
touchmove then cancelled the native selection extension mid-drag
and could blank the PDF canvas. If any frame already has a
non-collapsed selection, the drag is extending it.
2026-09-16 10:40:24 -04:00
John O'Keefe 8a203996ef Merge branch 'zoom-control'
# Conflicts:
#	pdf.js
2026-09-15 20:06:16 -04:00
John O'Keefe 422e8e0ec0 Don't snap on taps that never panned
A tap goes through #onTouchEnd's snap, which emits a no-op
relocate; readers treat any relocate as a page turn and dismiss
popovers, so the highlight edit popover opened by the tap's
synthetic click was immediately hidden.
2026-09-15 12:56:08 -04:00
John O'Keefe f872a01d53 Dispatch a synthetic click on quick taps
touchstart's preventDefault suppresses the click events that
foliate's link handling and the overlayer's highlight hit-testing
depend on; dispatch one at the tapped element so internal links
and the highlight edit popover work on touch as on desktop.
2026-09-15 12:43:24 -04:00
John O'Keefe 914ade8a79 Custom touch selection: own the gesture, clamp to the page
Chromium's native touch selection cannot be used in the columned
layout: quick taps select words, the word iterator re-anchors at
page boundaries (jumping to other pages), and the browser
auto-scrolls the container when selections cross pages.
preventDefault on touchstart disables it; long-press selects the
word under the finger, drag extends word by word clamped to the
visible page, and pan/snap stand down while a selection gesture is
active. Native rendering still paints the selection.
2026-09-15 08:54:56 -04:00
John O'Keefe 9cfe266929 Ignore small height-only viewport resizes
Mobile browser toolbar transitions change the viewport height by a
fraction mid-gesture; re-rendering for them re-wraps the whole book
and reads as a page refresh. Re-render only for width changes or
height changes of 25% or more.
2026-09-14 19:45:50 -04:00
John O'Keefe ea34de5bf5 Revert experimental touch selection work back to the minimal auto-paging gate
The custom gesture system and the selection clamp/repair chain
replaced browser behavior with worse approximations. Keep only the
non-touch gate on selection auto-paging (de70103), which fixes
drag-selecting paging away mid-gesture; everything else returns to
upstream behavior.
2026-09-14 08:44:26 -04:00
John O'Keefe 28f1e78855 Replace native touch selection with a custom gesture
Chrome's native touch selection misbehaves in the paginated column
layout: quick taps select words, and dragging from a line-start word
re-anchors at a rendered line break, dropping the word and freezing
the gesture. preventDefault on touchstart disables it entirely; a
long-press timer selects the word under the finger instead and drag
moves extend word by word, clamped to the visible page and identical
for every word. Clean taps clear the selection or follow links
(click events being suppressed by the preventDefault).
2026-09-14 08:08:48 -04:00
John O'Keefe 4cae16013e Repair the anchor word only from the stability-gated deferred path
A selection write at the moment Chrome re-anchors the drag detaches
its touch selection controller; the same writes on a settled
selection are fine. Record the word synchronously, repair only when
the selection has been unchanged for 150ms.
2026-09-13 20:25:40 -04:00
John O'Keefe 1bd7e52417 Repair backward word clipping with extend, not setBaseAndExtent
Rewriting both selection boundaries mid-gesture detaches Chrome's
touch selection controller and the selection stops following the
finger. extend() moves only the focus, which the controller
tolerates.
2026-09-13 20:19:26 -04:00
John O'Keefe 3fcfc30bd0 Keep the long-pressed word covered when Chrome re-anchors a touch drag
Dragging just past a line-start word makes Chrome flip the touch
selection and re-anchor it at a rendered line break, silently
dropping the long-pressed word from the selection. Record the word
at the initial long-press and restore its start boundary whenever
the selection no longer covers it.
2026-09-13 20:16:10 -04:00
John O'Keefe 800fb2fa02 Fix inverted probe collapse in the touch selection clamp 2026-09-13 18:32:58 -04:00
John O'Keefe cae618ac87 Fix selectionIsBackward for iframe selections
The helper created its probe range in the module's own document
(the host, in the bundled web reader), so boundary points in the
content iframe collapsed the range and every selection read as
backward.
2026-09-13 18:30:09 -04:00
John O'Keefe 2e392e80d3 Retry the touch selection clamp after the gesture ends 2026-09-13 18:26:47 -04:00
John O'Keefe 5102fc70dd Clamp by geometry, not the visible range
The last visible range can extend past the visible column on text
nodes that span multiple columns (its end-offset bisect uses union
rects), so comparing DOM boundary points against it misses off-view
selection endpoints. Test the endpoint caret rect against the
visible column bounds instead.
2026-09-13 18:23:52 -04:00
John O'Keefe 2a54b52fdb Keep touchSelecting armed through collapsed selectionchange events
Chrome emits collapsed selectionchange events while the long-press
word selection is being set up; clearing the flag on them disarmed
the clamp before the selection ever became a Range.
2026-09-13 18:18:03 -04:00
John O'Keefe 8ac36efbda Fix clamp coordinates: caretRangeFromPoint needs content-space bounds 2026-09-13 18:14:25 -04:00
John O'Keefe e1fa4698c6 Clamp touch selection endpoints to the visible page
Chrome word-snapping during a touch selection drag can map the
endpoint past the visible column into the next (off-screen) page,
selecting the entire visible page at once. Re-map the endpoint via
caretRangeFromPoint at the same line height inside the visible
column whenever it lands outside the last visible range.
2026-09-13 18:07:36 -04:00
John O'Keefe de70103651 Fix touch selection paging away mid-drag: gate selection auto-paging to non-touch pointers 2026-09-13 17:18:47 -04:00
john-okeefe e448d36641 Fix webtoon pages never loading: drop IntersectionObserver for scroll-driven loading
IO with a shadow-host root never delivered intersections in Chromium
(verified live: pages stayed blank while the section.load→fetch→img
pipeline itself worked fine when probed from outside). Loading is now
driven from the rAF-throttled scroll handler that already tracked the
reading position: a window of [index-2, index+4] loads around the
viewport-center page, with a 'loading' guard against duplicate
in-flight loads. connectedCallback kicks the initial load window.
2026-08-18 08:29:45 -04:00
john-okeefe ea268dffbe Add webtoon (vertical-scroll) renderer for comics
New <foliate-webtoon> custom element: every page stacked in one
scrolling column (max-width 900px centered on wide screens), the
standard long-strip manga reading mode.

- Pages lazy-load through an IntersectionObserver with a 150% root
  margin so they're ready before scrolling into view; placeholders
  carry an estimated min-height until the image loads, then the real
  aspect-ratio — set on the placeholder element — so geometry stays
  stable across unloads and the scrollbar never jumps.
- Pages more than 12 positions from the reading point are unloaded
  (image removed, section blob URL revoked) to bound memory on long
  webtoons; they simply reload if the user scrolls back.
- Relocate events ({reason:'scroll', index, fraction, size:1}) fire on
  rAF-throttled scroll with the viewport-center page as index and the
  visible position within it as fraction — compatible with view.js's
  SectionProgress enrichment, so progress saving/restoring, the
  slider, and the back-stack all work unchanged.
- Renderer contract mirrors fixed-layout: goTo(await {index}) scrolls
  the page into view; next/prev page by ~one screen (view.init()'s
  fresh-start probe is a no-op that reports location); goLeft/goRight
  map to prev/next (vertical flow is direction-independent).
  No zoom/magnifier APIs — the host already gates those on the flow.
- Page images honor --fx-filter from the host for the new
  brightness/contrast/invert controls.

view.js picks the renderer when book.format === 'comic' (now tagged
by makeComicBook) and options.comic.flow === 'webtoon'.
2026-08-18 08:15:59 -04:00
john-okeefe 1c812e8813 Add recenter(): reset pan while keeping the current zoom 2026-08-17 08:40:03 -04:00
john-okeefe 86e234d006 Fix rect hit-test denominator: use the rendered canvas for PDFs
documentElement's screen rect is devicePixelRatio× smaller than the
visible page because pdf.js scales <html> by 1/dpr — using it as the
hit-test denominator made click fractions dpr× too large, so clicks
landed in the wrong place relative to stored (correct) fractions on
any dpr != 1 display. The canvas's post-transform rect is exactly the
visible page and shares the textLayer's transform space.
2026-08-17 08:32:02 -04:00
john-okeefe d0654951b5 Expose the PDF.js document proxy on the book object
book.pdf lets the host drive pdf.js directly (page.getTextContent
for in-book search) without reaching into renderer internals.
2026-08-17 08:02:01 -04:00
john-okeefe 1c0ebf331f Fix rect annotations: host-side overlay instead of in-iframe SVG
The in-iframe SVG overlay was invisible on PDFs for two reasons:

- pdf.js applies transform: scale(1/devicePixelRatio) to the iframe's
  <html>; an overlay inside that document shrinks into the top-left
  corner on any dpr != 1 display. The transform is also applied only
  after the page render, so load-time-injected overlays were sized
  before the scale factor existed.
- Fraction rect geometry itself was correct (selection rects and the
  denominator are both post-transform), but nothing inside the iframe
  can escape its html transform.

Render annotations host-side instead: a div inside the frame's
wrapper element with percentage-positioned children. The wrapper box
always equals the visible page area for both formats — comics (iframe
CSS-scaled inside it) and PDFs (re-rendered at true scale) — so the
overlay is immune to the html transform, zoom re-renders, iframe
scaling, and host pan/zoom, still with zero re-anchoring. The
in-iframe click hit-test is unchanged (it compares fractions against
the same post-transform denominator).
2026-08-17 07:45:52 -04:00
john-okeefe aba68d8723 Rect annotations for fixed-layout: fraction-space overlay + hit-testing
Adds a parallel annotation pipeline for PDF/comic pages (the CFI
overlayer pipeline only exists for the reflowable paginator):

- addRectAnnotation({key, index, rects, color}) /
  removeRectAnnotation(key): rects are page-fraction [x,y,w,h]
  quads, stored per key and rendered into a full-bleed SVG injected
  into the page iframe. The SVG uses viewBox 0 0 100 100 with
  preserveAspectRatio none, so fraction rects stay aligned through
  iframe CSS-zoom (comics), the host transform-based pan/zoom, and
  PDF hi-res re-renders (pdf.js rebuilds canvas/textLayer but not
  body children) — no re-anchoring hooks needed anywhere.
- Frames carry their section index; annotations re-render into each
  freshly created iframe (#showSpread destroys and recreates frames
  on every spread change).
- Clicks are hit-tested against the fraction rects (in iframe
  coordinate space, denominator = img or documentElement bounding
  rect so PDF's devicePixelRatio transform cancels) and emit
  show-rect-annotation with host-space popover coordinates. Skipped
  while a text selection is active so drag-selecting text doesn't
  pop the editor.
- Comic page img is now display:block so the document box equals
  the image box (no inline-baseline gap inflating denominators).
2026-08-16 12:44:22 -04:00
john-okeefe e9e61d885b Touch gestures for fixed-layout: pinch-zoom, pan, swipe paging, double-tap
- Single finger: pan while zoomed; horizontal swipe pages at fit
  (direction mapped through next()/prev() so RTL manga reads
  correctly); drag on PDF text still defers to selection via the
  existing smart-detection (realTarget).
- Two fingers: pinch-zoom around the midpoint (through the shared
  zoom machinery, so PDF re-render scheduling works) plus
  midpoint-pan; lifts back to single-finger pan when one lifts.
- Double-tap: zoom 2.5x at the point / reset to fit.
- Touch events forwarded from page iframes with converted
  coordinates; preventDefault only when the engine actually consumes
  the gesture so native selection/taps stay intact.
- touch-action: none on :host and in comic/pdf page documents so the
  browser does not fight the gesture engine inside iframes.
2026-08-14 15:54:00 -04:00
john-okeefe 29bc958c26 Add 'text' interaction mode for PDF (selection-only, no smart detection)
'select' keeps smart behavior (drag text to select, drag blank to
pan); 'text' always defers to the text/annotation layer so drags
never pan — for users who want pure selection. Shift still force-pans
in both modes; 'pan' remains force-pan.
2026-08-14 14:24:41 -04:00
john-okeefe d4d87a94f8 Avoid redundant render when setting spread before first display 2026-08-13 12:46:11 -04:00
john-okeefe eb9ae699d7 Restore config.pdfjsPath override and options plumbing
Reverts the pdf.js/view.js API change that removed the exported
'config' (pdfjsPath) and the makePDF/open options parameter.
bookhoard overrides config.pdfjsPath to point at its own served
/static/vendor/pdfjs/ assets, so the override + options must stay.
Keep the unrelated external-link href fix in view.js.
2026-08-13 12:43:56 -04:00
john-okeefe d9b1d5d18f Smart PDF pan/select + reactive spread toggle
- In select mode, only defer to the text/annotation layer when the
  pointer is actually on text or a link; otherwise start panning.
  Lets PDF pages be dragged on blank space while keeping text
  selection. Shift and explicit 'pan' mode still force-pan.
- Make the 'spread' attribute reactive: extract #computeSpreads(),
  add #setSpread() which re-groups while preserving the current page.
2026-08-13 12:37:13 -04:00
john-okeefe d164d6f495 export overridable config.pdfjsPath for bundled usage 2026-05-24 19:59:52 -04:00
john-okeefe 832e3d37cc Merge branch 'johnfactotum:main' into main 2026-05-24 19:55:25 -04:00
john-okeefe 74c317d58c Allow explicit PDF.js resource paths for bundler compatibility
Bundlers like Vite cannot resolve directory paths (cmaps/, standard_fonts/)
through the new URL(..., import.meta.url) pattern. Add optional config to
makePDF, makeBook, and View.open so consumers can provide explicit URLs.

When options are not provided, falls back to import.meta.url resolution
(preserving existing behavior for direct module usage).
2026-05-09 21:03:39 -04:00
Francesco Martini 78914aef44 Use original hrefs for external links and add isExternal in fb2.js (#129) 2026-05-01 19:24:25 +00:00
john-okeefe d016b27752 Fix PDF.js vendor path glob and reformat with consistent style
- Change pdfjsPath template from 'vendor/pdfjs/' to
  './vendor/pdfjs/' so the relative glob resolves correctly
  when the module is bundled with Vite
- Reformat entire file: 2-space indentation, double quotes, semicolons,
  trailing commas, parenthesized arrow function parameters, and aligned
  multi-line call arguments for consistency with the rest of the codebase
2026-04-22 16:28:36 -04:00
john-okeefe c38486c296 Fix PDF.js vendor path glob and reformat with consistent style
- Change pdfjsPath template from 'vendor/pdfjs/' to
  './vendor/pdfjs/' so the relative glob resolves correctly
  when the module is bundled with Vite
- Reformat entire file: 2-space indentation, double quotes, semicolons,
  trailing commas, parenthesized arrow function parameters, and aligned
  multi-line call arguments for consistency with the rest of the codebase
2026-04-19 14:11:01 -04:00
john-okeefe 48feeeacad Smooth scroll zoom with rAF accumulation and debounced PDF re-rendering
Previously, each wheel event immediately called #updateFrameScales which
re-renders PDF canvases via onZoom on every tick. This caused visible
jank because canvas re-rendering is expensive and blocks the main thread.

Two improvements:

1. rAF accumulation: wheel events now accumulate zoom deltas and apply
   them once per animation frame via requestAnimationFrame, coalescing
   rapid scroll events into a single transform update. Reduced zoomStep
   from 0.1 to 0.05 for finer granularity.

2. PDF zoom debouncing: during active scroll-zoom, PDF iframes are
   visually scaled via CSS transform without calling onZoom (no canvas
   re-render). After zoom settles (150ms timeout), onZoom is called to
   re-render the canvas at the final resolution for crisp text. The
   #pdfLastRenderedScale field tracks what resolution the canvas was
   last rendered at so CSS scaling is relative to the rendered state.

   Comics continue to call #updateFrameScales every frame since their
   zoom is just a cheap CSS transform with no canvas work.

Timeouts are cleared on page turn, resize, and destroy to prevent
stale re-renders.
2026-04-18 23:52:50 -04:00
john-okeefe fa790c2abb Fix zoom percentage to track relative to fit-page scale
The zoom percentage display had two bugs:
1. Scroll-zoom never updated the status bar percentage because the
   'zoom' event listener was registered inside the magnifier-toggle
   click handler (only added on click, and re-added on each click).
2. After reset (fit-page), the status bar showed '100%' but the
   zoom-in button treated the current scale as 1.0 (absolute), not
   the fit-page scale (e.g. 0.3). This caused a wild zoom jump
   from '100%' to '120%' that was actually a 4x visual change.

Fix by introducing a #baseScale field that captures the fit-page or
fit-width scale whenever the component renders in auto-fit mode. The
new currentScale getter exposes the actual transform scale, and
zoomPercent expresses it as a percentage of baseScale. All zoom-in/
zoom-out handlers (buttons and keyboard) now use currentScale instead
of getAttribute('zoom') || 1, and all percentage displays use
zoomPercent. The zoom event listener is registered once at setup
rather than inside the magnifier toggle handler.

Also removes all diagnostic console.log statements from fixed-layout.js
and reader.js.
2026-04-18 23:04:48 -04:00
john-okeefe 6b39eb3e27 style: reformat epub.js with consistent 2-space indentation, semicolons, and double quotes
No functional changes. Normalizes epub.js to match the formatting style
used throughout the rest of the codebase (2-space indentation, ASI
semicolons, double-quoted strings, trailing commas in multiline).
2026-04-18 23:04:35 -04:00
john-okeefe 285b6711da Fix keyboard 0 not resetting zoom in fixed-layout books
When pressing 0 to reset zoom, the code was calling
removeAttribute('zoom') on the renderer, which removed the DOM
attribute but left the internal zoom/transform state stale. This meant
subsequent zoom operations would compound on the old hidden state.

Replace with the new resetZoom() method that properly clears the
numeric zoom and triggers a clean re-render.
2026-04-18 22:34:10 -04:00
john-okeefe 7ec14df593 Fix panning bugs in FixedLayout zoom/pan mode
Several interrelated fixes for the drag/pan behavior when zoomed in:

- Add dedicated #onResize() handler: numeric zoom rescales frames in
  place (preserving pan position) while fit-page/fit-width does a full
  re-render. Replaces the old bare #render() in the ResizeObserver.

- Add resetZoom() public method that clears the numeric zoom state and
  re-renders, replacing the old removeAttribute('zoom') pattern which
  left stale internal state.

- Guard spread-side sync in #handleMouseUp with !this.#portrait so
  dual-frame logic only runs in landscape spreads, preventing incorrect
  side-switching in portrait mode.

- Guard #updateTransform spread offset with !portrait for the same
  reason — portrait layouts don't have left/right frames.

- Fix #handleMouseDown to return false when declining drag (select mode
  in PDFs) and return true when starting a drag, so the iframe event
  proxy can correctly decide whether to preventDefault.

- Only preventDefault on iframe mousemove when actively dragging or
  magnifier is enabled, allowing normal text selection to work.

- Store zoom level in #zoom during #zoomByRatio and dispatch zoom event
  instead of writing back to the DOM attribute.

- Add debug console.log statements to zoomByRatio and handleWheel for
  tracing transform calculations.
2026-04-18 22:34:03 -04:00
john-okeefe 170352bc71 Fix interaction-mode toggle default icon and tooltip
The toggle button was defaulting to 'Pan Mode' with a plus-icon path,
but the actual default interaction mode is 'select' (the cursor/arrow).
Update the tooltip to 'Select Mode' and replace the SVG path with an
arrow/cursor shape so the icon matches the active mode at startup.
2026-04-18 22:32:44 -04:00
john-okeefe d5b2e3deda Add magnifier, interaction mode toggle, and zoom percentage display
Magnifier (fixed-layout.js):
- Rewrite magnifier to use CSS background-image for comics (blob URLs
  from iframe img elements) and canvas drawImage for PDFs. Replaces
  broken iframe.cloneNode approach that created empty sandboxed clones.
- Detect which iframe is under the cursor by checking bounding rects,
  so magnifier works on both left and right panes.
- Forward mousemove events from iframe documents to handleMagnifierMove
  so the magnifier follows the cursor inside iframes.
- Add background-repeat: no-repeat to lens styles.
- Add zoomMagnifierEnabled getter for external state checks.

Interaction mode toggle (PDF only):
- Add 'interaction-mode' observed attribute with 'select'/'pan' values.
- In select mode (default for PDFs): mouse events pass through for text
  selection; Shift+drag enables panning as a fallback.
- In pan mode: drag-to-pan works as normal.
- Comic books always use pan mode (no text to select).
- Auto-detect PDF via #isPDF flag set in #showSpread from onZoom presence.
- Add isPDF getter so reader.js can show/hide the toggle button.
- Add toggle button in reader.html, hidden by default, shown for PDFs
  via the view 'load' event.
- Escape key dismisses the magnifier.

Zoom percentage display (reader.js):
- Dispatch 'zoom' CustomEvent from #zoomByRatio with the new scale.
- Listener in reader.js updates the percentage display on scroll zoom.

Also includes minor formatting (line wrapping) in #goLeft/#goRight.
2026-04-18 00:39:15 -04:00
john-okeefe 3173cf038d Fix zoom reset (press 0) not disabling frame-to-frame navigation
When removeAttribute('zoom') was called, parseFloat(null) produced NaN,
and typeof NaN === 'number' is true in JS, so #goLeft/#goRight still
entered the zoomed-in dual-pane navigation branch.

Root cause fix: handle null value in attributeChangedCallback by setting
#zoom to undefined before the parseFloat path.

Defense in depth: add isNaN() guards to the zoom checks in #goLeft and
#goRight, matching the pattern already used in #render().
2026-04-17 22:46:04 -04:00
john-okeefe 9c1a007298 Center on focused frame instead of full spread when zoomed in
When a numeric zoom is active and both panes have content, #render()
now centers on the frame indicated by #side rather than centering the
entire spread. This means on page turns the view starts on the correct
frame (left for LTR, right for RTL) instead of the middle of the spread.

For fit-page/fit-width and single-pane views, centering behavior is
unchanged.
2026-04-17 22:33:18 -04:00