Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5329bf35a9 | ||
|
|
80e5d5b0d1 | ||
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 | ||
|
|
27a93ba2ea | ||
|
|
7afad5dcde | ||
|
|
929a862e74 | ||
|
|
0e55dafb1e | ||
|
|
cc8084a713 |
+4
-1
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
|||||||
|
|
||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||||
|
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||||
|
# could not evict; a new mount id guarantees a clean fetch.
|
||||||
|
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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://github.com/john-okeefe/foliate-js.git#e448d36",
|
"@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",
|
||||||
|
|||||||
+95
-98
@@ -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))
|
||||||
@@ -23,7 +24,7 @@ func readerInitExpr(metadata ReaderMetadata) string {
|
|||||||
|
|
||||||
templ Reader(user User, metadata ReaderMetadata) {
|
templ Reader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="overscroll-none">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
@@ -38,7 +39,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
<body
|
<body
|
||||||
x-data="readerShell"
|
x-data="readerShell"
|
||||||
x-init={ readerInitExpr(metadata) }
|
x-init={ readerInitExpr(metadata) }
|
||||||
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
class={ "theme-" + user.Theme + " h-screen overflow-hidden overscroll-none" }
|
||||||
>
|
>
|
||||||
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
||||||
drawers and scrim float above everything. -->
|
drawers and scrim float above everything. -->
|
||||||
@@ -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"
|
||||||
@@ -139,7 +135,13 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
:style="'background-color:' + c"
|
:style="'background-color:' + c"
|
||||||
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
||||||
:aria-label="'Highlight color ' + c"
|
:aria-label="'Highlight color ' + c"
|
||||||
@click="selectionPopover.mode === 'create' ? createHighlight(c) : (selectionPopover.color = c, saveHighlightChanges())"
|
@click="selectionPopover.noteOpen
|
||||||
|
? (selectionPopover.mode === 'create'
|
||||||
|
? (selectionPopover.color = c)
|
||||||
|
: (selectionPopover.color = c, saveHighlightChanges(true)))
|
||||||
|
: (selectionPopover.mode === 'create'
|
||||||
|
? createHighlight(c)
|
||||||
|
: (selectionPopover.color = c, saveHighlightChanges()))"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-px h-5 reader-sep"></div>
|
<div class="w-px h-5 reader-sep"></div>
|
||||||
@@ -188,12 +190,11 @@ templ Reader(user User, metadata ReaderMetadata) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
||||||
style="border-color: var(--border);"
|
style="border-color: var(--border);"
|
||||||
@click="selectionPopover.noteOpen = false"
|
@click="hideSelectionPopover()"
|
||||||
>Cancel</button>
|
>Cancel</button>
|
||||||
</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"
|
||||||
@@ -210,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"
|
||||||
@@ -227,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"
|
||||||
@@ -263,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>
|
||||||
@@ -295,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>
|
||||||
@@ -338,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>
|
||||||
@@ -348,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;
|
||||||
@@ -528,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>
|
||||||
@@ -700,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 {
|
||||||
|
|||||||
+601
-130
@@ -358,6 +358,15 @@ const getCSS = ({
|
|||||||
text-align: ${justify ? "justify" : "start"} !important;
|
text-align: ${justify ? "justify" : "start"} !important;
|
||||||
hyphens: ${hyphenate ? "auto" : "none"};
|
hyphens: ${hyphenate ? "auto" : "none"};
|
||||||
}
|
}
|
||||||
|
/* Chrome's touch word-selection walks hyphen fragments: dragging from a
|
||||||
|
line-start word re-anchors at the line-end fragment (silently dropping
|
||||||
|
the selected word) and fast drags overshoot into the next column.
|
||||||
|
Coarse pointers get whole words only. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
p, li, blockquote, dd {
|
||||||
|
hyphens: ${hyphenate ? "manual" : "none"};
|
||||||
|
}
|
||||||
|
}
|
||||||
[align="left"] { text-align: left; }
|
[align="left"] { text-align: left; }
|
||||||
[align="right"] { text-align: right; }
|
[align="right"] { text-align: right; }
|
||||||
[align="center"] { text-align: center; }
|
[align="center"] { text-align: center; }
|
||||||
@@ -389,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,
|
||||||
@@ -404,12 +417,20 @@ document.addEventListener("alpine:init", () => {
|
|||||||
pointerFine: true as boolean,
|
pointerFine: true as boolean,
|
||||||
fxZoomed: false as boolean,
|
fxZoomed: false as boolean,
|
||||||
tapZonesEnabled: true as boolean,
|
tapZonesEnabled: true as boolean,
|
||||||
tapZoneSize: 30 as number,
|
tapZoneSize: 12 as number,
|
||||||
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
// Any live text selection, host document or content iframe. Fed by
|
// Any live text selection, host document or content iframe. Fed by
|
||||||
// selectionchange listeners (touch devices); tap zones stand down
|
// selectionchange listeners (touch devices); tap zones stand down
|
||||||
// while one exists.
|
// while one exists.
|
||||||
anySelection: false as boolean,
|
anySelection: false as boolean,
|
||||||
|
// The section document most recently loaded (for clearing its
|
||||||
|
// selection after actions that consume it).
|
||||||
|
currentDoc: null as any,
|
||||||
|
// Timestamp of the last touch press (pointer:coarse surfaces); a
|
||||||
|
// selection appearing during a short press is Chrome's tap-to-select,
|
||||||
|
// not a selection gesture.
|
||||||
|
lastTouchDownT: 0 as number,
|
||||||
|
selPopTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
highlightItems: [] as {
|
highlightItems: [] as {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -571,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 —
|
||||||
@@ -598,7 +621,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.chromeBehavior =
|
this.chromeBehavior =
|
||||||
behavior === "always-visible" ? "always-visible" : "auto-hide";
|
behavior === "always-visible" ? "always-visible" : "auto-hide";
|
||||||
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
|
this.tapZonesEnabled = this.settings.tap_zones_enabled ?? true;
|
||||||
this.tapZoneSize = this.settings.tap_zone_size || 30;
|
this.tapZoneSize = this.settings.tap_zone_size || 12;
|
||||||
const mode = this.settings.pdf_interaction_mode;
|
const mode = this.settings.pdf_interaction_mode;
|
||||||
if (mode === "pan" || mode === "text" || mode === "select") {
|
if (mode === "pan" || mode === "text" || mode === "select") {
|
||||||
this.interactionMode = mode;
|
this.interactionMode = mode;
|
||||||
@@ -679,6 +702,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.view.addEventListener("load", (e: any) => {
|
this.view.addEventListener("load", (e: any) => {
|
||||||
const { doc, index } = e.detail;
|
const { doc, index } = e.detail;
|
||||||
|
this.currentDoc = doc;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = "/static/reader-fonts.css";
|
link.href = "/static/reader-fonts.css";
|
||||||
@@ -702,64 +726,61 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.noteSelectionActivity(
|
this.noteSelectionActivity(
|
||||||
!!sel && !sel.isCollapsed && !!sel.toString(),
|
!!sel && !sel.isCollapsed && !!sel.toString(),
|
||||||
);
|
);
|
||||||
|
// The gesture takeover swallows pointerup for touch selection
|
||||||
|
// drags, so the popover's pointerup trigger never fires for
|
||||||
|
// them; a selection that has been stable for a moment is the
|
||||||
|
// settled gesture — open the popover for it.
|
||||||
|
if (
|
||||||
|
this.isFixedLayout &&
|
||||||
|
!this.treatAsComic &&
|
||||||
|
doc.querySelector(".textLayer")
|
||||||
|
)
|
||||||
|
this.schedulePDFSelectionPopover(doc, index);
|
||||||
|
else if (!this.isFixedLayout)
|
||||||
|
this.scheduleSelectionPopover(doc, index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Clicks in the book dismiss the popover in ANY mode and for ANY
|
||||||
|
// content doc (EPUB sections and fixed-layout pages alike): iframe
|
||||||
|
// events never bubble to the host document (so the host
|
||||||
|
// outside-click dismiss never sees them). Clicking a painted
|
||||||
|
// highlight still works: this hides, then foliate's
|
||||||
|
// show-annotation re-opens it in edit mode.
|
||||||
|
doc.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
() => {
|
||||||
|
if (this.selectionPopover.open)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
// Text selection → highlight popover (reflowable EPUB only;
|
// Text selection → highlight popover (reflowable EPUB only;
|
||||||
// fixed-layout highlight overlays are a later milestone).
|
// fixed-layout highlight overlays are a later milestone).
|
||||||
if (!this.isFixedLayout) {
|
if (!this.isFixedLayout) {
|
||||||
const checkSelection = () => {
|
const checkSelection = (e?: PointerEvent) => {
|
||||||
const sel = doc.getSelection();
|
const sel = doc.getSelection();
|
||||||
|
// Chrome on touch selects the word under a quick tap — that is
|
||||||
|
// a page-turn tap, not a selection gesture; clear it and show
|
||||||
|
// nothing. Real hold selections never reach this pointerup
|
||||||
|
// (the gesture takeover swallows it) — they open the popover
|
||||||
|
// through the selectionchange debounce below.
|
||||||
|
if (
|
||||||
|
e && e.pointerType === "touch" && this.lastTouchDownT &&
|
||||||
|
Date.now() - this.lastTouchDownT < 300
|
||||||
|
) {
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||||
if (this.selectionPopover.mode === "create")
|
if (this.selectionPopover.mode === "create")
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const range = sel.getRangeAt(0);
|
this.openPopoverForSelection(sel, doc, index);
|
||||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
|
||||||
if (!text) return;
|
|
||||||
let cfi: string;
|
|
||||||
let cfiEnd: string;
|
|
||||||
try {
|
|
||||||
cfi = this.view.getCFI(index, range);
|
|
||||||
// Collapse to the end point for a distinct end anchor —
|
|
||||||
// KOReader sync renders the highlight box from pos0/pos1, and
|
|
||||||
// pos1 == pos0 would be a degenerate (zero-length) range.
|
|
||||||
const endRange = range.cloneRange();
|
|
||||||
endRange.collapse(false);
|
|
||||||
cfiEnd = this.view.getCFI(index, endRange);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
|
||||||
const iframeRect = frame?.getBoundingClientRect();
|
|
||||||
const rect = range.getBoundingClientRect();
|
|
||||||
this.openSelectionPopover({
|
|
||||||
mode: "create",
|
|
||||||
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
|
|
||||||
y: (iframeRect?.top ?? 0) + rect.top,
|
|
||||||
text,
|
|
||||||
cfi,
|
|
||||||
cfiEnd,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"pointerup",
|
"pointerup",
|
||||||
() => setTimeout(checkSelection, 0),
|
(e: PointerEvent) => setTimeout(() => checkSelection(e), 0),
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
// Clicks in the book dismiss the popover in ANY mode: iframe
|
|
||||||
// events never bubble to the host document (so the host
|
|
||||||
// outside-click dismiss never sees them), and the collapsed
|
|
||||||
// check above only covers create mode — edit mode had no
|
|
||||||
// outside-click path at all, leaving Esc as the only way out.
|
|
||||||
// Clicking a painted highlight still works: this hides, then
|
|
||||||
// foliate's show-annotation re-opens it in edit mode.
|
|
||||||
doc.addEventListener(
|
|
||||||
"pointerdown",
|
|
||||||
() => {
|
|
||||||
if (this.selectionPopover.open)
|
|
||||||
this.hideSelectionPopover();
|
|
||||||
},
|
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
@@ -774,66 +795,29 @@ 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.
|
||||||
const checkPDFSelection = () => {
|
if (
|
||||||
const sel = doc.getSelection();
|
this.isFixedLayout &&
|
||||||
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
!this.treatAsComic &&
|
||||||
const range = sel.getRangeAt(0);
|
doc.querySelector(".textLayer")
|
||||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
) {
|
||||||
if (!text) return;
|
|
||||||
// Denominator: the element whose post-transform screen rect IS
|
|
||||||
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
|
||||||
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
|
||||||
// is dpr× too small and would inflate every fraction (highlight
|
|
||||||
// shifted right/oversized on any dpr != 1 display). The canvas's
|
|
||||||
// rect is in the same transform-inclusive space as the textLayer
|
|
||||||
// span rects, so the dpr scaling cancels exactly.
|
|
||||||
const denom =
|
|
||||||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
|
||||||
(doc.querySelector("img") as HTMLElement);
|
|
||||||
let dr = denom?.getBoundingClientRect();
|
|
||||||
if (!dr || !dr.width || !dr.height) {
|
|
||||||
const vw = doc.defaultView;
|
|
||||||
dr = {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: vw?.innerWidth || 1,
|
|
||||||
height: vw?.innerHeight || 1,
|
|
||||||
} as DOMRect;
|
|
||||||
}
|
|
||||||
const rects: number[][] = [];
|
|
||||||
for (const r of range.getClientRects()) {
|
|
||||||
const x = (r.left - dr.left) / dr.width;
|
|
||||||
const y = (r.top - dr.top) / dr.height;
|
|
||||||
const w = r.width / dr.width;
|
|
||||||
const h = r.height / dr.height;
|
|
||||||
if (w > 0 && h > 0) rects.push([x, y, w, h]);
|
|
||||||
}
|
|
||||||
if (!rects.length) return;
|
|
||||||
// Map the first rect to host-space for popover placement.
|
|
||||||
// The canvas's screen rect maps 1:1 onto the host iframe box
|
|
||||||
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
|
|
||||||
// kept general for the comic img fallback.
|
|
||||||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
|
||||||
if (!frame) return;
|
|
||||||
const fr = frame.getBoundingClientRect();
|
|
||||||
const first = range.getBoundingClientRect();
|
|
||||||
const sx = fr.width / dr.width;
|
|
||||||
const sy = fr.height / dr.height;
|
|
||||||
this.pdfSelDoc = doc;
|
|
||||||
this.openSelectionPopover({
|
|
||||||
mode: "create",
|
|
||||||
x: fr.left + first.left * sx + (first.width * sx) / 2,
|
|
||||||
y: fr.top + first.top * sy,
|
|
||||||
text,
|
|
||||||
cfi: "",
|
|
||||||
pdfPage: index,
|
|
||||||
pdfRects: rects,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"pointerup",
|
"pointerup",
|
||||||
() => setTimeout(checkPDFSelection, 0),
|
(e: PointerEvent) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
// Chrome on touch selects the word under a quick tap —
|
||||||
|
// that is a page-turn tap, not a selection gesture; clear
|
||||||
|
// it and show nothing (same guard as the reflowable path).
|
||||||
|
if (
|
||||||
|
e.pointerType === "touch" &&
|
||||||
|
this.lastTouchDownT &&
|
||||||
|
Date.now() - this.lastTouchDownT < 300
|
||||||
|
) {
|
||||||
|
doc.getSelection()?.removeAllRanges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.checkPDFSelectionFor(doc, index);
|
||||||
|
}, 0),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -885,7 +869,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.view.addEventListener("relocate", (e: any) => {
|
this.view.addEventListener("relocate", (e: any) => {
|
||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
e.detail;
|
||||||
this.hideSelectionPopover();
|
// Only dismiss the popover on an actual position change — the
|
||||||
|
// paginator also emits no-op relocates (e.g. a tap's page-aligned
|
||||||
|
// anchor), which would otherwise close a just-opened highlight
|
||||||
|
// edit popover. NOTE: detail.section is an object (recreated on
|
||||||
|
// every relocate), so it can't be compared by reference; the
|
||||||
|
// fraction threshold alone covers section changes (any section
|
||||||
|
// boundary moves the whole-book fraction far more than 0.1%).
|
||||||
|
const prev = this.lastRelocateDetail;
|
||||||
|
const moved =
|
||||||
|
!prev ||
|
||||||
|
Math.abs((prev.fraction ?? 0) - (fraction ?? 0)) > 0.001;
|
||||||
|
if (moved) this.hideSelectionPopover();
|
||||||
this.lastCfi = cfi || "";
|
this.lastCfi = cfi || "";
|
||||||
this.lastRelocateDetail = {
|
this.lastRelocateDetail = {
|
||||||
fraction,
|
fraction,
|
||||||
@@ -959,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;
|
||||||
@@ -1006,6 +1004,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.pokeChrome();
|
this.pokeChrome();
|
||||||
},
|
},
|
||||||
pokeChrome() {
|
pokeChrome() {
|
||||||
|
// Never raise the chrome while a touch selection is live — the
|
||||||
|
// selection popover owns the screen until the selection is
|
||||||
|
// consumed or dismissed.
|
||||||
|
if (this.anySelection) return;
|
||||||
this.chromeVisible = true;
|
this.chromeVisible = true;
|
||||||
if (this.chromeBehavior !== "auto-hide") return;
|
if (this.chromeBehavior !== "auto-hide") return;
|
||||||
if (this.hideTimer) clearTimeout(this.hideTimer);
|
if (this.hideTimer) clearTimeout(this.hideTimer);
|
||||||
@@ -1057,6 +1059,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
clearTimeout(this.tapZoneTimer);
|
clearTimeout(this.tapZoneTimer);
|
||||||
this.tapZoneTimer = null;
|
this.tapZoneTimer = null;
|
||||||
}
|
}
|
||||||
|
// Hide the chrome the moment a selection appears — it competes
|
||||||
|
// with the selection popover and handles for screen space.
|
||||||
|
if (hasSelection && this.chromeVisible) {
|
||||||
|
this.chromeVisible = false;
|
||||||
|
if (this.hideTimer) {
|
||||||
|
clearTimeout(this.hideTimer);
|
||||||
|
this.hideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
|
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
|
||||||
let downX = 0;
|
let downX = 0;
|
||||||
@@ -1080,6 +1091,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
downId = e.pointerId;
|
downId = e.pointerId;
|
||||||
moved = false;
|
moved = false;
|
||||||
longPressed = false;
|
longPressed = false;
|
||||||
|
this.lastTouchDownT = downT;
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
@@ -1129,16 +1141,26 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// No tap actions while a fixed-layout page is zoomed — taps then
|
// No tap actions while a fixed-layout page is zoomed — taps then
|
||||||
// belong to the content (and double-tap zoom).
|
// belong to the content (and double-tap zoom).
|
||||||
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
||||||
const width =
|
// Content iframes are laid out at the full section width (the
|
||||||
(isDoc
|
// paginator shows one column slice of a much wider document), so
|
||||||
? (surface as any).documentElement.clientWidth
|
// clientX must be mapped into the visible page before it can be
|
||||||
: (surface as HTMLElement).getBoundingClientRect().width) || 1;
|
// compared against the tap zones — dividing by the iframe's
|
||||||
const relX =
|
// clientWidth puts every tap in the left zone.
|
||||||
(e.clientX -
|
let relX: number;
|
||||||
(isDoc
|
if (isDoc) {
|
||||||
? 0
|
const r: any = this.renderer;
|
||||||
: (surface as HTMLElement).getBoundingClientRect().left)) /
|
const docWidth = (surface as any).documentElement.clientWidth || 1;
|
||||||
width;
|
if (r && !r.scrolled && r.size) {
|
||||||
|
const base = r.start - r.size;
|
||||||
|
relX = (e.clientX - base) / r.size;
|
||||||
|
} else {
|
||||||
|
relX = e.clientX / docWidth;
|
||||||
|
}
|
||||||
|
if (this.view?.book?.dir === "rtl") relX = 1 - relX;
|
||||||
|
} else {
|
||||||
|
const rect = (surface as HTMLElement).getBoundingClientRect();
|
||||||
|
relX = (e.clientX - rect.left) / (rect.width || 1);
|
||||||
|
}
|
||||||
this.routeTapDebounced(relX);
|
this.routeTapDebounced(relX);
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
@@ -1154,6 +1176,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.tapZoneTimer = setTimeout(() => {
|
this.tapZoneTimer = setTimeout(() => {
|
||||||
this.tapZoneTimer = null;
|
this.tapZoneTimer = null;
|
||||||
|
// Touch pages by swipe only (the paginator's pan + snap); a tap
|
||||||
|
// anywhere just toggles the chrome. The edge zones remain for
|
||||||
|
// mouse-driven taps.
|
||||||
|
if (window.matchMedia("(pointer: coarse)").matches) {
|
||||||
|
this.toggleChromeManually();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const zone = this.tapZoneSize / 100;
|
const zone = this.tapZoneSize / 100;
|
||||||
if (relX < zone) this.goLeft();
|
if (relX < zone) this.goLeft();
|
||||||
else if (relX > 1 - zone) this.goRight();
|
else if (relX > 1 - zone) this.goRight();
|
||||||
@@ -1199,10 +1228,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
p.noteOpen = !!p.note && opts.mode === "edit";
|
p.noteOpen = !!p.note && opts.mode === "edit";
|
||||||
p.pdfPage = opts.pdfPage ?? -1;
|
p.pdfPage = opts.pdfPage ?? -1;
|
||||||
p.pdfRects = opts.pdfRects ?? [];
|
p.pdfRects = opts.pdfRects ?? [];
|
||||||
// Clamp so the popover stays on screen (it anchors bottom-center).
|
// Clamp so the popover stays on screen (it anchors bottom-center;
|
||||||
|
// the popover is ~240px wide, so the fixed margins must cover half
|
||||||
|
// of it). NOTE: do not mutate the position after opening — touching
|
||||||
|
// the style mid-enter-transition leaves the popover invisible to
|
||||||
|
// compositor hit-testing (taps fall through to the content iframe).
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
const h = window.innerHeight;
|
const h = window.innerHeight;
|
||||||
p.x = Math.min(Math.max(opts.x, 90), w - 90);
|
p.x = Math.min(Math.max(opts.x, 130), w - 130);
|
||||||
p.y = Math.min(Math.max(opts.y, 60), h - 60);
|
p.y = Math.min(Math.max(opts.y, 60), h - 60);
|
||||||
p.open = true;
|
p.open = true;
|
||||||
},
|
},
|
||||||
@@ -1210,6 +1243,266 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.selectionPopover.open = false;
|
this.selectionPopover.open = false;
|
||||||
this.selectionPopover.noteOpen = false;
|
this.selectionPopover.noteOpen = false;
|
||||||
},
|
},
|
||||||
|
scheduleSelectionPopover(doc: any, index: number) {
|
||||||
|
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||||
|
this.selPopTimer = setTimeout(() => {
|
||||||
|
this.selPopTimer = null;
|
||||||
|
// A tap on a painted highlight re-opens the popover in edit mode
|
||||||
|
// via its own path — never clobber it.
|
||||||
|
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
|
||||||
|
return;
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||||
|
if (
|
||||||
|
this.selectionPopover.open &&
|
||||||
|
this.selectionPopover.mode === "create"
|
||||||
|
)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
this.hideSelectionHandles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The browser's selection auto-scroll (drags near a viewport
|
||||||
|
// edge) scrolls the paginated container off the page grid, and
|
||||||
|
// a selection that crosses the page edge keeps its reveal-scroll
|
||||||
|
// chasing the off-page text — the view bounces between pages
|
||||||
|
// forever. Correct the view to the anchor's page and clamp the
|
||||||
|
// selection to the visible page; scrollBy clamps to the current
|
||||||
|
// section, so the correction can never cross sections.
|
||||||
|
const r: any = this.renderer;
|
||||||
|
if (r && !r.scrolled && r.size && this.view?.book?.dir !== "rtl") {
|
||||||
|
try {
|
||||||
|
const anchorProbe = doc.createRange();
|
||||||
|
anchorProbe.setStart(sel.anchorNode, sel.anchorOffset);
|
||||||
|
// collapsed ranges report degenerate (0,0) rects — extend by
|
||||||
|
// one character to get a measurable one
|
||||||
|
if (
|
||||||
|
sel.anchorNode?.nodeType === 3 &&
|
||||||
|
sel.anchorOffset < sel.anchorNode.length
|
||||||
|
)
|
||||||
|
anchorProbe.setEnd(sel.anchorNode, sel.anchorOffset + 1);
|
||||||
|
const rects = anchorProbe.getClientRects();
|
||||||
|
const ar =
|
||||||
|
rects[rects.length - 1] || anchorProbe.getBoundingClientRect();
|
||||||
|
// rect x is already in the iframe's own (content) space,
|
||||||
|
// i.e. scroll-invariant: page = floor(x / column stride)
|
||||||
|
const desired = (Math.floor(ar.x / r.size) + 1) * r.size;
|
||||||
|
const needsScroll = Math.abs(r.start - desired) > r.size * 0.02;
|
||||||
|
if (needsScroll) r.scrollBy(desired - r.start, 0);
|
||||||
|
const clamped = this.clampSelectionToPage(sel, doc, r);
|
||||||
|
if (clamped)
|
||||||
|
// the clamp's selectionchange re-arms this settle; the
|
||||||
|
// next pass finds view and selection in place and opens
|
||||||
|
// the popover
|
||||||
|
return;
|
||||||
|
if (needsScroll) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = doc.getSelection();
|
||||||
|
if (s && s.rangeCount && !s.isCollapsed && s.type === "Range")
|
||||||
|
this.openPopoverForSelection(s, doc, index);
|
||||||
|
}, 350);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* anchor geometry unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.openPopoverForSelection(sel, doc, index);
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
// PDF mirror of scheduleSelectionPopover: the gesture takeover
|
||||||
|
// swallows pointerup for touch selection drags in the fx iframe too,
|
||||||
|
// so a selection that has been stable for a moment is the settled
|
||||||
|
// gesture — open the popover for it.
|
||||||
|
schedulePDFSelectionPopover(doc: any, index: number) {
|
||||||
|
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||||
|
this.selPopTimer = setTimeout(() => {
|
||||||
|
this.selPopTimer = null;
|
||||||
|
// A tap on a painted highlight re-opens the popover in edit mode
|
||||||
|
// via its own path — never clobber it.
|
||||||
|
if (this.selectionPopover.open && this.selectionPopover.mode === "edit")
|
||||||
|
return;
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||||
|
if (
|
||||||
|
this.selectionPopover.open &&
|
||||||
|
this.selectionPopover.mode === "create"
|
||||||
|
)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.checkPDFSelectionFor(doc, index);
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
checkPDFSelectionFor(doc: any, index: number) {
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||||
|
if (!text) return;
|
||||||
|
// Denominator: the element whose post-transform screen rect IS
|
||||||
|
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
||||||
|
// scales <html> by 1/devicePixelRatio, so documentElement's rect
|
||||||
|
// is dpr× too small and would inflate every fraction (highlight
|
||||||
|
// shifted right/oversized on any dpr != 1 display). The canvas's
|
||||||
|
// rect is in the same transform-inclusive space as the textLayer
|
||||||
|
// span rects, so the dpr scaling cancels exactly.
|
||||||
|
const denom =
|
||||||
|
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||||||
|
(doc.querySelector("img") as HTMLElement);
|
||||||
|
let dr = denom?.getBoundingClientRect();
|
||||||
|
if (!dr || !dr.width || !dr.height) {
|
||||||
|
const vw = doc.defaultView;
|
||||||
|
dr = {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: vw?.innerWidth || 1,
|
||||||
|
height: vw?.innerHeight || 1,
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
const rects: number[][] = [];
|
||||||
|
for (const r of range.getClientRects()) {
|
||||||
|
const x = (r.left - dr.left) / dr.width;
|
||||||
|
const y = (r.top - dr.top) / dr.height;
|
||||||
|
const w = r.width / dr.width;
|
||||||
|
const h = r.height / dr.height;
|
||||||
|
if (w > 0 && h > 0) rects.push([x, y, w, h]);
|
||||||
|
}
|
||||||
|
if (!rects.length) return;
|
||||||
|
// Map the first rect to host-space for popover placement.
|
||||||
|
// The canvas's screen rect maps 1:1 onto the host iframe box
|
||||||
|
// (the visible page fills the iframe), so sx/sy are 1 for PDFs;
|
||||||
|
// kept general for the comic img fallback.
|
||||||
|
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||||||
|
if (!frame) return;
|
||||||
|
const fr = frame.getBoundingClientRect();
|
||||||
|
const first = range.getBoundingClientRect();
|
||||||
|
const sx = fr.width / dr.width;
|
||||||
|
const sy = fr.height / dr.height;
|
||||||
|
// On touch the browser's own selection menu attaches ABOVE the
|
||||||
|
// selection and its drag handles sit just below it — place ours
|
||||||
|
// below both (same +90 clearance the reflowable path uses). Fine
|
||||||
|
// pointers (desktop) keep the above-the-selection placement.
|
||||||
|
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
const y = coarse
|
||||||
|
? fr.top + first.bottom * sy + 90
|
||||||
|
: fr.top + first.top * sy;
|
||||||
|
this.pdfSelDoc = doc;
|
||||||
|
this.openSelectionPopover({
|
||||||
|
mode: "create",
|
||||||
|
x: fr.left + first.left * sx + (first.width * sx) / 2,
|
||||||
|
y,
|
||||||
|
text,
|
||||||
|
cfi: "",
|
||||||
|
pdfPage: index,
|
||||||
|
pdfRects: rects,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Trim a touch selection to the visible page. The visible column
|
||||||
|
// occupies content x [start - size, start]; endpoints beyond it are
|
||||||
|
// re-mapped to the page edge on their own line via caretRangeFromPoint.
|
||||||
|
clampSelectionToPage(sel: any, doc: any, r: any): boolean {
|
||||||
|
const left = r.start - r.size;
|
||||||
|
const right = r.start;
|
||||||
|
const TOL = 8;
|
||||||
|
const backwardProbe = doc.createRange();
|
||||||
|
backwardProbe.setStart(sel.anchorNode, sel.anchorOffset);
|
||||||
|
backwardProbe.setEnd(sel.focusNode, sel.focusOffset);
|
||||||
|
const backward = backwardProbe.collapsed;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const probeRect = (node: any, offset: number, forward: boolean) => {
|
||||||
|
const p = doc.createRange();
|
||||||
|
p.setStart(node, offset);
|
||||||
|
if (node?.nodeType === 3) {
|
||||||
|
if (forward && offset < node.length)
|
||||||
|
p.setEnd(node, offset + 1);
|
||||||
|
else if (!forward && offset > 0)
|
||||||
|
p.setStart(node, offset - 1);
|
||||||
|
}
|
||||||
|
if (p.collapsed) return null;
|
||||||
|
const rects = p.getClientRects();
|
||||||
|
return rects.length ? rects[forward ? 0 : rects.length - 1] : null;
|
||||||
|
};
|
||||||
|
const caretAt = (x: number, y: number) => {
|
||||||
|
const caret = doc.caretRangeFromPoint?.(x, y)
|
||||||
|
?? doc.caretPositionFromPoint?.(x, y);
|
||||||
|
if (!caret) return null;
|
||||||
|
return {
|
||||||
|
node: caret.startContainer ?? caret.offsetNode,
|
||||||
|
offset: caret.startOffset ?? caret.offset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// selection end beyond the visible page (into the next column):
|
||||||
|
// trim to the last position on the visible page (bottom-right
|
||||||
|
// corner of the column — nearest-text mapping is unambiguous
|
||||||
|
// from the margin)
|
||||||
|
const endRect = probeRect(range.endContainer, range.endOffset, false);
|
||||||
|
if (endRect && endRect.x + endRect.width > right + TOL) {
|
||||||
|
const caret = caretAt(
|
||||||
|
right - 15, doc.documentElement.clientHeight - 40);
|
||||||
|
if (caret && range.isPointInRange(caret.node, caret.offset)) {
|
||||||
|
if (backward)
|
||||||
|
sel.setBaseAndExtent(caret.node, caret.offset,
|
||||||
|
sel.focusNode, sel.focusOffset);
|
||||||
|
else sel.extend(caret.node, caret.offset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// selection start before the visible page (previous column):
|
||||||
|
// trim to the first position on the visible page (top-left corner)
|
||||||
|
const startRect = probeRect(range.startContainer, range.startOffset, true);
|
||||||
|
if (startRect && startRect.x < left - TOL) {
|
||||||
|
const caret = caretAt(left + 15, 40);
|
||||||
|
if (caret && range.isPointInRange(caret.node, caret.offset)) {
|
||||||
|
if (backward)
|
||||||
|
sel.extend(caret.node, caret.offset);
|
||||||
|
else
|
||||||
|
sel.setBaseAndExtent(caret.node, caret.offset,
|
||||||
|
sel.focusNode, sel.focusOffset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
openPopoverForSelection(sel: any, doc: any, index: number) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||||
|
if (!text) return;
|
||||||
|
let cfi: string;
|
||||||
|
let cfiEnd: string;
|
||||||
|
try {
|
||||||
|
cfi = this.view.getCFI(index, range);
|
||||||
|
// Collapse to the end point for a distinct end anchor —
|
||||||
|
// KOReader sync renders the highlight box from pos0/pos1, and
|
||||||
|
// pos1 == pos0 would be a degenerate (zero-length) range.
|
||||||
|
const endRange = range.cloneRange();
|
||||||
|
endRange.collapse(false);
|
||||||
|
cfiEnd = this.view.getCFI(index, endRange);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||||||
|
const iframeRect = frame?.getBoundingClientRect();
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
// On touch the browser's own selection menu attaches ABOVE the
|
||||||
|
// selection (and paints over page content) — place ours BELOW it
|
||||||
|
// so both are visible. The popover anchors bottom-center at y-10
|
||||||
|
// and is ~44px tall; the drag handles occupy the first 24px below
|
||||||
|
// the selection, so the offset must clear them too.
|
||||||
|
const coarse = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
const y = coarse
|
||||||
|
? (iframeRect?.top ?? 0) + rect.bottom + 90
|
||||||
|
: (iframeRect?.top ?? 0) + rect.top;
|
||||||
|
this.openSelectionPopover({
|
||||||
|
mode: "create",
|
||||||
|
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
|
||||||
|
y,
|
||||||
|
text,
|
||||||
|
cfi,
|
||||||
|
cfiEnd,
|
||||||
|
});
|
||||||
|
if (coarse && this.view?.book?.dir !== "rtl")
|
||||||
|
this.showSelectionHandles(doc);
|
||||||
|
},
|
||||||
renderAllHighlights() {
|
renderAllHighlights() {
|
||||||
for (const hl of this.highlightItems) {
|
for (const hl of this.highlightItems) {
|
||||||
if (hl.pdfPage >= 0) {
|
if (hl.pdfPage >= 0) {
|
||||||
@@ -1332,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) {
|
||||||
@@ -1351,6 +1649,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
|
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
|
||||||
|
// The note editor writes into p.note live; "Highlight with note"
|
||||||
|
// creates the highlight AND its note in one POST.
|
||||||
|
const note = p.note.trim();
|
||||||
const pdfAnchor =
|
const pdfAnchor =
|
||||||
p.pdfPage >= 0
|
p.pdfPage >= 0
|
||||||
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
|
||||||
@@ -1371,7 +1672,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||||
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
epubcfi_end: p.pdfPage >= 0 ? pdfAnchor : p.cfiEnd,
|
||||||
color,
|
color,
|
||||||
note_text: "",
|
note_text: note,
|
||||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -1400,16 +1701,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
? ""
|
? ""
|
||||||
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
: this.toRangeCfi(p.cfi, p.cfiEnd, p.text) || p.cfi,
|
||||||
color,
|
color,
|
||||||
note: "",
|
note,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
});
|
});
|
||||||
|
// Clear the DOM selection: the gesture is consumed, and it is
|
||||||
|
// what keeps Chrome's native copy/share menu on screen.
|
||||||
|
try {
|
||||||
|
this.currentDoc?.getSelection()?.removeAllRanges();
|
||||||
|
} catch {
|
||||||
|
/* doc may be gone */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async saveHighlightChanges() {
|
async saveHighlightChanges(keepNoteOpen = false) {
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || !p.id) return;
|
if (!token || !this.mediaItemId || !p.id) return;
|
||||||
@@ -1473,7 +1781,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
p.noteOpen = false;
|
// Explicit saves (the Save button) close the whole popover; color
|
||||||
|
// tweaks made while the note editor is open keep everything open.
|
||||||
|
if (!keepNoteOpen) this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
}
|
}
|
||||||
@@ -1504,12 +1814,170 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async copySelectionText() {
|
async copySelectionText() {
|
||||||
|
const text = this.selectionPopover.text;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(this.selectionPopover.text);
|
if (navigator.clipboard?.writeText) {
|
||||||
this.hideSelectionPopover();
|
await navigator.clipboard.writeText(text);
|
||||||
} catch (_e) {
|
this.finishCopy();
|
||||||
/* clipboard unavailable */
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to the legacy path */
|
||||||
}
|
}
|
||||||
|
// Plain HTTP (typical LAN access) has no navigator.clipboard;
|
||||||
|
// a transient textarea + execCommand works from this user
|
||||||
|
// gesture on insecure contexts.
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.append(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
ta.remove();
|
||||||
|
this.finishCopy();
|
||||||
|
} catch {
|
||||||
|
/* ignore clipboard errors */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finishCopy() {
|
||||||
|
try {
|
||||||
|
this.currentDoc?.getSelection()?.removeAllRanges();
|
||||||
|
} catch {
|
||||||
|
/* doc may be gone */
|
||||||
|
}
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
},
|
||||||
|
// ----- touch selection handles -----
|
||||||
|
// The native selection handles cannot be used (the custom gesture's
|
||||||
|
// touchstart preventDefault disables them along with the rest of the
|
||||||
|
// native controller), so adjustment after lift happens through these.
|
||||||
|
// Purely imperative DOM — position updates run on every drag frame.
|
||||||
|
selectionHandles: {
|
||||||
|
root: null as HTMLElement | null,
|
||||||
|
start: null as HTMLElement | null,
|
||||||
|
end: null as HTMLElement | null,
|
||||||
|
drag: null as "start" | "end" | null,
|
||||||
|
doc: null as any,
|
||||||
|
},
|
||||||
|
ensureSelectionHandles() {
|
||||||
|
const h = this.selectionHandles as any;
|
||||||
|
if (h.root) return;
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.id = "selection-handles";
|
||||||
|
const make = (which: "start" | "end") => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "sel-handle";
|
||||||
|
el.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
h.drag = which;
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
el.addEventListener("pointermove", (e: PointerEvent) => {
|
||||||
|
if (h.drag !== which) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.dragSelectionHandle(e);
|
||||||
|
});
|
||||||
|
const release = () => {
|
||||||
|
h.drag = null;
|
||||||
|
};
|
||||||
|
el.addEventListener("pointerup", release);
|
||||||
|
el.addEventListener("pointercancel", release);
|
||||||
|
root.append(el);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
h.start = make("start");
|
||||||
|
h.end = make("end");
|
||||||
|
h.root = root;
|
||||||
|
document.body.append(root);
|
||||||
|
},
|
||||||
|
showSelectionHandles(doc: any) {
|
||||||
|
this.ensureSelectionHandles();
|
||||||
|
const h = this.selectionHandles as any;
|
||||||
|
h.doc = doc;
|
||||||
|
h.root!.style.display = "block";
|
||||||
|
this.positionSelectionHandles(doc);
|
||||||
|
},
|
||||||
|
hideSelectionHandles() {
|
||||||
|
const h = this.selectionHandles as any;
|
||||||
|
if (!h.root) return;
|
||||||
|
h.root.style.display = "none";
|
||||||
|
h.drag = null;
|
||||||
|
},
|
||||||
|
// Host-space position of a selection boundary: the left edge of the
|
||||||
|
// start caret or the right edge of the end caret, at the line bottom.
|
||||||
|
caretHostPos(doc: any, node: any, offset: number, forward: boolean) {
|
||||||
|
try {
|
||||||
|
const p = doc.createRange();
|
||||||
|
p.setStart(node, offset);
|
||||||
|
if (node?.nodeType === 3) {
|
||||||
|
if (forward && offset < node.length) p.setEnd(node, offset + 1);
|
||||||
|
else if (!forward && offset > 0) p.setStart(node, offset - 1);
|
||||||
|
}
|
||||||
|
if (p.collapsed) return null;
|
||||||
|
const rects = p.getClientRects();
|
||||||
|
const r = rects[forward ? 0 : rects.length - 1];
|
||||||
|
if (!r) return null;
|
||||||
|
const off = doc.defaultView?.frameElement?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (off?.left ?? 0) + (forward ? r.left : r.right),
|
||||||
|
y: (off?.top ?? 0) + r.bottom,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
positionSelectionHandles(doc: any) {
|
||||||
|
const h = this.selectionHandles as any;
|
||||||
|
if (!h.root || h.root.style.display === "none") return;
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel?.rangeCount || sel.isCollapsed) {
|
||||||
|
this.hideSelectionHandles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const startPos = this.caretHostPos(doc, range.startContainer,
|
||||||
|
range.startOffset, true);
|
||||||
|
const endPos = this.caretHostPos(doc, range.endContainer,
|
||||||
|
range.endOffset, false);
|
||||||
|
if (!startPos || !endPos) {
|
||||||
|
this.hideSelectionHandles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.start!.style.transform = `translate(${startPos.x - 6}px, ${startPos.y}px)`;
|
||||||
|
h.end!.style.transform = `translate(${endPos.x - 6}px, ${endPos.y}px)`;
|
||||||
|
},
|
||||||
|
dragSelectionHandle(ev: PointerEvent) {
|
||||||
|
const h = this.selectionHandles as any;
|
||||||
|
const doc = h.doc;
|
||||||
|
const r: any = this.renderer;
|
||||||
|
if (!doc || !r || r.scrolled) return;
|
||||||
|
if (this.view?.book?.dir === "rtl") return;
|
||||||
|
const frame = doc.defaultView?.frameElement;
|
||||||
|
if (!frame) return;
|
||||||
|
const off = frame.getBoundingClientRect();
|
||||||
|
// pointer → iframe content space, clamped to the visible page
|
||||||
|
const size = r.size;
|
||||||
|
const left = r.start - size;
|
||||||
|
const right = r.start;
|
||||||
|
const x = Math.min(Math.max(ev.clientX - off.left, left + 2), right - 2);
|
||||||
|
const y = ev.clientY - off.top;
|
||||||
|
const caret = doc.caretRangeFromPoint?.(x, y)
|
||||||
|
?? doc.caretPositionFromPoint?.(x, y);
|
||||||
|
if (!caret) return;
|
||||||
|
const node = caret.startContainer ?? caret.offsetNode;
|
||||||
|
const offset = caret.startOffset ?? caret.offset;
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel?.rangeCount || !node) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (h.drag === "start")
|
||||||
|
sel.setBaseAndExtent(node, offset, range.endContainer, range.endOffset);
|
||||||
|
else
|
||||||
|
sel.setBaseAndExtent(range.startContainer, range.startOffset,
|
||||||
|
node, offset);
|
||||||
|
this.positionSelectionHandles(doc);
|
||||||
},
|
},
|
||||||
goToHighlight(hl: {
|
goToHighlight(hl: {
|
||||||
cfi: string;
|
cfi: string;
|
||||||
@@ -1698,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;
|
||||||
@@ -1792,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;
|
||||||
@@ -2201,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;
|
||||||
|
|||||||
@@ -1434,3 +1434,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Touch selection handles (custom gesture — the native ones are disabled) */
|
||||||
|
#selection-handles {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 55;
|
||||||
|
}
|
||||||
|
#selection-handles .sel-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 24px;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: none;
|
||||||
|
background: #8ab4f8;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
#selection-handles .sel-handle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 5px;
|
||||||
|
background: inherit;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user