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
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+85
-94
@@ -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 & pan <span class="help-note">(comics, PDFs)</span></h4>
|
<h4 class="reader-help-section" x-show="isFixedLayout">Zoom & 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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user