Compare commits

...
3 Commits
Author SHA1 Message Date
John O'Keefe 7caa46c2da fix(build): exclude local package-lock.json from Docker build context
The lockfile is gitignored (each machine keeps its own), but
.dockerignore did not exclude it, so any stale local lock rode into
every docker build via `COPY package*.json`. Because the forked
foliate-js declares "version": "0.0.0" on every commit, npm treats
the git pin as already satisfied by name@version and never
re-resolves the new commit hash — silently installing and bundling
the old code. This bit both the host npm cache mount (documented at
Dockerfile:18-20) and, today, `make rebuild-app-force`: a fresh
no-cache image was built with the pre-feature 1305a52 foliate-js
(chunk fixed-layout-B8-qRQLl.js) despite package.json pinning
e16530a, while the Gitea runner (fresh checkout, no lockfile, cold
cache) built correctly.

With no lockfile in the context, npm install resolves git pins
fresh from package.json each build (tarballs are cached by
commit-specific URLs), so the persistent npm cache mount cannot
serve old commits across pin bumps. Local lockfiles can no longer
poison builds even if regenerated on the host.

Verified: after evicting the poisoned cache mounts
(docker builder prune --filter type=exec.cachemount) and rebuilding,
the container serves fixed-layout-BE0KdOql.js with both dblclick
handlers present, matching the reference build.
2026-09-17 15:37:58 -04:00
john-okeefe 5329bf35a9 fix: pin foliate-js e16530a — desktop double-click zoom toggle
Release / build-and-push (push) Successful in 3m20s
Bump the linuxhg mirror pin from 1305a52 to e16530a (mirror main is
already synced). The fork adds a desktop double-click zoom to the
fixed-layout renderer for all fixed-layout content (comics, manga,
PDFs, fixed EPUBs):

- at fit scale, double-click zooms to 2.5x at the clicked location
- zoomed or panned, double-click resets to 100% centered
- PDF text-layer spans and annotation links keep native double-click
  (word selection) in Smart/Text modes; Pan mode zooms anywhere
- mirrors the existing touch double-tap; disabled in panel-zoom mode
  like the other gestures

Also closes the gap where node_modules was serving a stale tarball
older than the previous 1305a52 pin (missing the toolbar-resize
debounce and selection-drag fixes); a fresh install now resolves the
full set.
2026-09-17 14:37:05 -04:00
john-okeefe 80e5d5b0d1 feat(reader): comic treatment for fixed-layout items in comics/manga libraries
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
2026-09-17 14:36:58 -04:00
8 changed files with 162 additions and 109 deletions
+3
View File
@@ -20,6 +20,9 @@ logs/
# Dependencies (install fresh in container) # Dependencies (install fresh in container)
node_modules/ node_modules/
# Local-only lockfile: a stale one pins old foliate-js commits into builds
# (npm reuses name@version without re-resolving the git pin)
package-lock.json
# Environment # Environment
.env .env
+21
View File
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
// Determine if panel detection should be enabled // Determine if panel detection should be enabled
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup) enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
// Get reading progress // Get reading progress
var progress database.ReadingProgress var progress database.ReadingProgress
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
"progress": progress, "progress": progress,
"bookmarks": bookmarks, "bookmarks": bookmarks,
"enable_panel_detection": enablePanelDetection, "enable_panel_detection": enablePanelDetection,
"treat_as_comic": treatAsComic,
"format_group": mediaItem.FormatGroup, "format_group": mediaItem.FormatGroup,
"manga_type": mediaItem.MangaType, "manga_type": mediaItem.MangaType,
"reading_direction": mediaItem.ReadingDirection, "reading_direction": mediaItem.ReadingDirection,
@@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
}) })
} }
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
// comics or manga library should get comic reader treatment: bookmarks
// only — no text-selection highlights, no annotations, no in-book search.
// Comic archives already behave this way; this flag extends it to fixed-
// layout PDFs shelved in comic libraries (scanned or official manga PDFs).
func ShouldTreatAsComic(libraryType string, formatGroup string) bool {
comicLibraries := map[string]bool{
"manga": true,
"comics": true,
}
fixedFormats := map[string]bool{
"fixed_layout": true,
"comic_archive": true,
}
return comicLibraries[libraryType] && fixedFormats[formatGroup]
}
// shouldEnablePanelDetection determines if panel detection should be enabled // shouldEnablePanelDetection determines if panel detection should be enabled
// based on library type and format group // based on library type and format group
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool { func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
+8
View File
@@ -66,6 +66,13 @@ func registerReaderRoutes(cfg *Config) {
if !visible { if !visible {
return renderErrorPage(c, "Access denied", "access_denied") return renderErrorPage(c, "Access denied", "access_denied")
} }
// Library type decides comic reader treatment: fixed-layout items
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
// through like comics — bookmarks only, no highlights or search.
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return renderErrorPage(c, "Failed to get library info", "library_error")
}
// Convert to template types. Reading state is deliberately NOT // Convert to template types. Reading state is deliberately NOT
// fetched or embedded: the reader pulls position, bookmarks, and // fetched or embedded: the reader pulls position, bookmarks, and
// annotations from the APIs at open time so the page can never // annotations from the APIs at open time so the page can never
@@ -89,6 +96,7 @@ func registerReaderRoutes(cfg *Config) {
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}), FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
TotalCharacters: mediaItem.TotalCharacters.Int64, TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64), EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
} }
// Render template // Render template
var buf bytes.Buffer var buf bytes.Buffer
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css" "dev": "npm run build:ts:dev && npm run build:css"
}, },
"dependencies": { "dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#1305a52", "@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#e16530a",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
+85 -94
View File
@@ -16,6 +16,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
"formatGroup": metadata.FormatGroup, "formatGroup": metadata.FormatGroup,
"readingDirection": metadata.ReadingDirection, "readingDirection": metadata.ReadingDirection,
"mangaType": metadata.MangaType, "mangaType": metadata.MangaType,
"treatAsComic": metadata.TreatAsComic,
} }
jsonBytes, _ := json.Marshal(config) jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes)) return fmt.Sprintf("initReader(%s)", string(jsonBytes))
@@ -68,9 +69,7 @@ templ Reader(user User, metadata ReaderMetadata) {
</svg> </svg>
</div> </div>
</div> </div>
@ReaderChrome(metadata) @ReaderChrome(metadata)
<!-- Drawer scrim --> <!-- Drawer scrim -->
<div <div
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen" x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
@@ -79,7 +78,6 @@ templ Reader(user User, metadata ReaderMetadata) {
class="drawer-scrim" class="drawer-scrim"
aria-hidden="true" aria-hidden="true"
></div> ></div>
<!-- TOC drawer (left) --> <!-- TOC drawer (left) -->
<div <div
id="toc-drawer" id="toc-drawer"
@@ -96,7 +94,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderTOCDrawer() @ReaderTOCDrawer()
</div> </div>
<!-- Settings drawer (right) --> <!-- Settings drawer (right) -->
<div <div
id="settings-drawer" id="settings-drawer"
@@ -113,7 +110,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderSettingsDrawer() @ReaderSettingsDrawer()
</div> </div>
<!-- Selection popover (highlights) --> <!-- Selection popover (highlights) -->
<div <div
id="selection-popover" id="selection-popover"
@@ -199,7 +195,6 @@ templ Reader(user User, metadata ReaderMetadata) {
</div> </div>
</div> </div>
</div> </div>
<!-- Annotations drawer (right): highlights / notes / bookmarks --> <!-- Annotations drawer (right): highlights / notes / bookmarks -->
<div <div
id="annotations-drawer" id="annotations-drawer"
@@ -216,7 +211,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderAnnotationsDrawer() @ReaderAnnotationsDrawer()
</div> </div>
<!-- Search drawer (right, reflowable only) --> <!-- Search drawer (right, reflowable only) -->
<div <div
id="search-drawer" id="search-drawer"
@@ -233,7 +227,6 @@ templ Reader(user User, metadata ReaderMetadata) {
> >
@ReaderSearchDrawer() @ReaderSearchDrawer()
</div> </div>
<!-- Shortcuts & gestures help modal --> <!-- Shortcuts & gestures help modal -->
<div <div
x-show="helpOpen" x-show="helpOpen"
@@ -269,17 +262,14 @@ templ Reader(user User, metadata ReaderMetadata) {
<div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div> <div class="help-row"><span>Settings</span><span class="help-keys"><kbd class="kbd">s</kbd></span></div>
<div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div> <div class="help-row"><span>Add bookmark</span><span class="help-keys"><kbd class="kbd">b</kbd></span></div>
<div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div> <div class="help-row"><span>Close menus / toggle chrome</span><span class="help-keys"><kbd class="kbd">Esc</kbd></span></div>
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom &amp; pan <span class="help-note">(comics, PDFs)</span></h4> <h4 class="reader-help-section" x-show="isFixedLayout">Zoom &amp; pan <span class="help-note">(comics, PDFs)</span></h4>
<div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd"></kbd><kbd class="kbd">0</kbd> or wheel</span></div> <div class="help-row" x-show="isFixedLayout"><span>Zoom in / out / reset</span><span class="help-keys"><kbd class="kbd">+</kbd><kbd class="kbd"></kbd><kbd class="kbd">0</kbd> or wheel</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div> <div class="help-row" x-show="isFixedLayout"><span>Move the page</span><span class="help-keys">drag with the mouse</span></div>
<div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys"> button</span></div> <div class="help-row" x-show="isFixedLayout"><span>Recenter <span class="help-note">(keeps zoom)</span></span><span class="help-keys"> button</span></div>
<div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div> <div class="help-row" x-show="isPDF"><span>Select text in a PDF</span><span class="help-keys">Smart mode: drag over text · <kbd class="kbd">Shift</kbd>+drag anywhere · Text mode</span></div>
<h4 class="reader-help-section">Highlights</h4> <h4 class="reader-help-section">Highlights</h4>
<div class="help-row"><span>Create</span><span class="help-keys">select text pick a color</span></div> <div class="help-row"><span>Create</span><span class="help-keys">select text pick a color</span></div>
<div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div> <div class="help-row"><span>Edit / delete</span><span class="help-keys">click the highlight</span></div>
<h4 class="reader-help-section">Touch</h4> <h4 class="reader-help-section">Touch</h4>
<div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div> <div class="help-row"><span>Page turn</span><span class="help-keys">swipe, or tap outer edges</span></div>
<div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div> <div class="help-row"><span>Toggle chrome</span><span class="help-keys">tap center</span></div>
@@ -301,7 +291,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1> <h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button> <button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button> <button x-show="(!isFixedLayout || isPDF) && !treatAsComic" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button> <button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button> <button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
<button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button> <button @click="toggleHelp()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold" title="Shortcuts & gestures (?)" aria-label="Shortcuts and gestures">?</button>
@@ -344,7 +334,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden"> <div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"></span> <span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"></span>
</div> </div>
<div class="w-px h-6 reader-sep"></div> <div class="w-px h-6 reader-sep"></div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button> <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
@@ -354,92 +344,93 @@ templ ReaderChrome(metadata ReaderMetadata) {
<div x-show="isFixedLayout"> <div x-show="isFixedLayout">
<!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --> <!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. -->
<div class="hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1"> <div class="hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left"> <button @click="goLeft()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Left (←)" aria-label="Go left">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path>
</svg>
</button>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg>
</button>
</div>
<input
id="progress-slider-fx"
type="range"
min="0"
max="1"
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true"> <svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"></path> <path d="M 9 6 L 15 12 L 9 18"></path>
</svg> </svg>
</button> </button>
<button x-show="backStack.length > 0" @click="goBackToLocation()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Return to previous location (Alt+←)" aria-label="Return to previous location"> <div class="w-px h-6 mx-1 reader-sep"></div>
<!-- Fit mode + zoom cluster (not applicable in webtoon flow) -->
<div class="flex items-center gap-1" x-show="comicFlow !== 'webtoon'">
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg shrink-0"
title="Fit mode"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
<button @click="zoomOut()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom Out (-)" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
</button>
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom (0)" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<button @click="zoomIn()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom In (+)" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Recenter: reset pan, keep zoom -->
<button @click="recenterView()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Recenter page (reset pan, keep zoom)" aria-label="Recenter page">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M 12 2 V 5 M 12 19 V 22 M 2 12 H 5 M 19 12 H 22"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="magnifierEnabled ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
</div>
<!-- Pointer mode (PDF only): Smart / Pan / Text — hidden for
comic-treated PDFs: no text selection, pan only -->
<div class="reader-seg shrink-0" x-show="isPDF && !treatAsComic" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
</div>
<!-- Double page spread (not applicable in webtoon flow) -->
<button x-show="comicFlow !== 'webtoon'" @click="toggleSpread()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="doublePageSpread ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Double Page Spread" aria-label="Toggle double page spread">
<svg class="reader-icon" width="20" height="20" aria-hidden="true"> <svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z"></path> <path d="M 3 5 h 6 v 14 h -6 z M 15 5 h 6 v 14 h -6 z"></path>
<circle cx="12" cy="9.5" r="2"></circle>
</svg> </svg>
</button> </button>
</div> <!-- Progress + TOC -->
<input <div class="flex items-center gap-1">
id="progress-slider-fx" <div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
type="range" <span x-text="progressMain"></span>
min="0" </div>
max="1" <button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
step="any"
@input="goToFraction($event.target.value)"
class="grow min-w-[3rem]"
/>
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
<svg class="reader-icon" width="24" height="24" aria-hidden="true">
<path d="M 9 6 L 15 12 L 9 18"></path>
</svg>
</button>
<div class="w-px h-6 mx-1 reader-sep"></div>
<!-- Fit mode + zoom cluster (not applicable in webtoon flow) -->
<div class="flex items-center gap-1" x-show="comicFlow !== 'webtoon'">
<select
@change="applyFitMode($event.target.value)"
class="reader-select text-xs px-1.5 py-1 rounded-lg shrink-0"
title="Fit mode"
aria-label="Fit mode"
>
<option value="fit-page">Fit Page</option>
<option value="fit-width">Fit Width</option>
</select>
<button @click="zoomOut()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom Out (-)" aria-label="Zoom out">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 5 10 L 15 10"></path>
</svg>
</button>
<button @click="resetZoom()" class="text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]" title="Reset Zoom (0)" aria-label="Reset zoom">
<span x-text="zoomPercent + '%'">100%</span>
</button>
<button @click="zoomIn()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Zoom In (+)" aria-label="Zoom in">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 10 5 L 10 15 M 5 10 L 15 10"></path>
</svg>
</button>
<!-- Recenter: reset pan, keep zoom -->
<button @click="recenterView()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Recenter page (reset pan, keep zoom)" aria-label="Recenter page">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M 12 2 V 5 M 12 19 V 22 M 2 12 H 5 M 19 12 H 22"></path>
</svg>
</button>
<!-- Magnifier -->
<button @click="toggleMagnifier()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="magnifierEnabled ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Magnifier" aria-label="Toggle magnifier">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<circle cx="9" cy="9" r="5"></circle>
<path d="M 13 13 L 18 18"></path>
</svg>
</button>
</div>
<!-- Pointer mode (PDF only): Smart / Pan / Text -->
<div class="reader-seg shrink-0" x-show="isPDF" role="group" aria-label="Pointer mode" title="Smart: drag text to select, drag blank to pan · Pan: drag anywhere to move · Text: selection only. Shift always force-pans.">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')" title="Smart: drag text to select, drag blank to pan">Smart</button>
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')" title="Pan: drag anywhere to move">Pan</button>
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')" title="Text: selection only, never pan (Shift overrides)">Text</button>
</div>
<!-- Double page spread (not applicable in webtoon flow) -->
<button x-show="comicFlow !== 'webtoon'" @click="toggleSpread()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" :class="doublePageSpread ? 'bg-blue-600 hover:bg-blue-700' : ''" title="Double Page Spread" aria-label="Toggle double page spread">
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
<path d="M 3 5 h 6 v 14 h -6 z M 15 5 h 6 v 14 h -6 z"></path>
</svg>
</button>
<!-- Progress + TOC -->
<div class="flex items-center gap-1">
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span x-text="progressMain"></span>
</div> </div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
</div> </div>
<!-- Compact row (phone-sized windows): single line + ⋯ overflow. <!-- Compact row (phone-sized windows): single line + ⋯ overflow.
Standard mobile-reader pattern: paging, slider, progress stay; Standard mobile-reader pattern: paging, slider, progress stay;
@@ -534,7 +525,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
<span>Night mode</span> <span>Night mode</span>
<button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button> <button @click="fxInvert = !fxInvert; applyFxFilter(true)" class="reader-popover-btn text-xs px-2" :class="fxInvert ? 'text-blue-400' : ''" x-text="fxInvert ? 'On' : 'Off'">Off</button>
</div> </div>
<div class="reader-tools-row" x-show="isPDF"> <div class="reader-tools-row" x-show="isPDF && !treatAsComic">
<span>Pointer</span> <span>Pointer</span>
<div class="reader-seg" role="group" aria-label="Pointer mode"> <div class="reader-seg" role="group" aria-label="Pointer mode">
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button> <button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
@@ -706,7 +697,7 @@ templ ReaderAnnotationsDrawer() {
@keydown.enter="renameBookmark(bookmark)" @keydown.enter="renameBookmark(bookmark)"
@keydown.escape="bookmark.renameOpen = false" @keydown.escape="bookmark.renameOpen = false"
placeholder="Bookmark name…" placeholder="Bookmark name…"
></input> />
<div class="flex gap-2 mt-1"> <div class="flex gap-2 mt-1">
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button> <button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button> <button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
File diff suppressed because one or more lines are too long
+3
View File
@@ -243,6 +243,9 @@ type ReaderMetadata struct {
LibraryID string `json:"library_id"` LibraryID string `json:"library_id"`
TotalCharacters int64 `json:"total_characters"` TotalCharacters int64 `json:"total_characters"`
EstimatedPages int `json:"estimated_pages"` EstimatedPages int `json:"estimated_pages"`
// TreatAsComic: fixed-layout item in a comics/manga library gets
// comic reader treatment (bookmarks only — no highlights, no search).
TreatAsComic bool `json:"treat_as_comic"`
} }
type ReadingProgress struct { type ReadingProgress struct {
+33 -7
View File
@@ -398,6 +398,10 @@ document.addEventListener("alpine:init", () => {
isFixedLayout: false, isFixedLayout: false,
isPDF: false, isPDF: false,
isComic: false, isComic: false,
// Fixed-layout item in a comics/manga library (e.g. a manga PDF):
// comic reader treatment — bookmarks only, no text-selection
// highlights, no in-book search.
treatAsComic: false as boolean,
comicFlow: "paged" as string, comicFlow: "paged" as string,
fxBrightness: 1 as number, fxBrightness: 1 as number,
fxContrast: 1 as number, fxContrast: 1 as number,
@@ -588,8 +592,10 @@ document.addEventListener("alpine:init", () => {
formatGroup: string; formatGroup: string;
readingDirection: string; readingDirection: string;
mangaType: string; mangaType: string;
treatAsComic: boolean;
}) { }) {
this.mediaItemId = config.mediaItemId; this.mediaItemId = config.mediaItemId;
this.treatAsComic = config.treatAsComic;
// Reading state (position, bookmarks, annotations) is never baked // Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to // into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time — // the server, so it reads all of it from the APIs at open time —
@@ -724,7 +730,11 @@ document.addEventListener("alpine:init", () => {
// drags, so the popover's pointerup trigger never fires for // drags, so the popover's pointerup trigger never fires for
// them; a selection that has been stable for a moment is the // them; a selection that has been stable for a moment is the
// settled gesture — open the popover for it. // settled gesture — open the popover for it.
if (this.isFixedLayout && doc.querySelector(".textLayer")) if (
this.isFixedLayout &&
!this.treatAsComic &&
doc.querySelector(".textLayer")
)
this.schedulePDFSelectionPopover(doc, index); this.schedulePDFSelectionPopover(doc, index);
else if (!this.isFixedLayout) else if (!this.isFixedLayout)
this.scheduleSelectionPopover(doc, index); this.scheduleSelectionPopover(doc, index);
@@ -785,7 +795,12 @@ document.addEventListener("alpine:init", () => {
// Detected structurally: renderer.isPDF isn't set until the first // Detected structurally: renderer.isPDF isn't set until the first
// spread renders (during view.init), which is after this listener // spread renders (during view.init), which is after this listener
// attaches — the stale copy here would always be falsy. // attaches — the stale copy here would always be falsy.
if (this.isFixedLayout && doc.querySelector(".textLayer")) { // Comic-treated PDFs never attach it: no highlights, like comics.
if (
this.isFixedLayout &&
!this.treatAsComic &&
doc.querySelector(".textLayer")
) {
doc.addEventListener( doc.addEventListener(
"pointerup", "pointerup",
(e: PointerEvent) => (e: PointerEvent) =>
@@ -939,6 +954,9 @@ document.addEventListener("alpine:init", () => {
// Smart|Pan|Text control appear for PDFs. // Smart|Pan|Text control appear for PDFs.
this.isPDF = !!this.renderer?.isPDF; this.isPDF = !!this.renderer?.isPDF;
if (this.isPDF) { if (this.isPDF) {
// Comic-treated PDFs are paged through like comics: no text
// selection, so pointer interactions stay in pan mode.
if (this.treatAsComic) this.interactionMode = "pan";
this.renderer.setAttribute("interaction-mode", this.interactionMode); this.renderer.setAttribute("interaction-mode", this.interactionMode);
} }
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null; this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
@@ -1607,7 +1625,12 @@ document.addEventListener("alpine:init", () => {
const rows = await hlResp.json(); const rows = await hlResp.json();
this.highlightItems = (rows as any[]) this.highlightItems = (rows as any[])
.map((r) => this.mapHighlightRow(r)) .map((r) => this.mapHighlightRow(r))
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0); // Comic-treated items show no highlights (bookmarks only),
// and previously-created PDF highlights stop rendering.
.filter(
(hl: any) =>
!this.treatAsComic && (hl.cfi || hl.pdfPage >= 0),
);
this.renderAllHighlights(); this.renderAllHighlights();
} }
if (noteResp.ok) { if (noteResp.ok) {
@@ -2143,6 +2166,8 @@ document.addEventListener("alpine:init", () => {
}, },
setInteractionMode(mode: string) { setInteractionMode(mode: string) {
if (!this.isFixedLayout || !this.isPDF) return; if (!this.isFixedLayout || !this.isPDF) return;
// Comic-treated PDFs have no text selection: pan only.
if (this.treatAsComic) return;
if (mode !== "select" && mode !== "pan" && mode !== "text") return; if (mode !== "select" && mode !== "pan" && mode !== "text") return;
this.renderer.setAttribute("interaction-mode", mode); this.renderer.setAttribute("interaction-mode", mode);
this.interactionMode = mode; this.interactionMode = mode;
@@ -2237,8 +2262,9 @@ document.addEventListener("alpine:init", () => {
}, },
toggleSearch() { toggleSearch() {
// Reflowable books use foliate's DOM search; PDFs use extracted text. // Reflowable books use foliate's DOM search; PDFs use extracted text.
// Comics have no text at all. // Comics have no text at all — and comic-treated PDFs (manga scans)
if (this.isFixedLayout && !this.isPDF) return; // opt out of search to match comic treatment: bookmarks only.
if ((this.isFixedLayout && !this.isPDF) || this.treatAsComic) return;
const opening = !this.searchOpen; const opening = !this.searchOpen;
this.closeDrawers(); this.closeDrawers();
this.searchOpen = opening; this.searchOpen = opening;
@@ -2646,8 +2672,8 @@ document.addEventListener("alpine:init", () => {
this.hyphenate = true; this.hyphenate = true;
this.chromeBehavior = "auto-hide"; this.chromeBehavior = "auto-hide";
this.applyChromeBehavior(); this.applyChromeBehavior();
this.interactionMode = "select"; this.interactionMode = this.treatAsComic ? "pan" : "select";
this.setInteractionMode("select"); this.setInteractionMode(this.interactionMode);
this.fxBrightness = 1; this.fxBrightness = 1;
this.fxContrast = 1; this.fxContrast = 1;
this.fxInvert = false; this.fxInvert = false;