The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.
- startAnyAdjust routes the popover button by format; startPdfAdjust
runs a rect-edge session: 4 grips per rect (all edges, one rect per
drag), live preview through the renderer's own addRectAnnotation
(replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
tokens (start edge takes the word start, end edge the word end), each
punctuation glyph is its own token with both edges grabbable (a period
or line-break hyphen can be taken or shed precisely). Horizontal edges
snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
rects (reading order, lines joined with spaces) so the stored text
stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
iframe docs are mapped through a WeakMap filled by the view's load
events. Pointer mapping goes through the canvas's post-transform rect
(the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
span set into the same .textLayer without clearing, and undeduped
glyphs doubled the recomputed text.
Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
978 lines
45 KiB
Templ
978 lines
45 KiB
Templ
package templates
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// The init config carries only immutable book metadata. Reading state
|
||
// (position, bookmarks, annotations) is never embedded: the reader fetches
|
||
// it from the APIs at open time, so the page can never carry — nor write
|
||
// back — a stale snapshot of it.
|
||
func readerInitExpr(metadata ReaderMetadata) string {
|
||
config := map[string]interface{}{
|
||
"mediaItemId": metadata.MediaItemID,
|
||
"fileUrl": metadata.FileURL,
|
||
"formatGroup": metadata.FormatGroup,
|
||
"readingDirection": metadata.ReadingDirection,
|
||
"mangaType": metadata.MangaType,
|
||
"treatAsComic": metadata.TreatAsComic,
|
||
}
|
||
jsonBytes, _ := json.Marshal(config)
|
||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||
}
|
||
|
||
templ Reader(user User, metadata ReaderMetadata) {
|
||
<!DOCTYPE html>
|
||
<html lang="en" class="overscroll-none">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||
<title>{ metadata.Title } - Bookhoard Reader</title>
|
||
<link rel="manifest" href="/static/manifest.json"/>
|
||
<link href="/static/reader-fonts.css" rel="stylesheet"/>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<script type="module" src="/static/reader.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||
</head>
|
||
<body
|
||
x-data="readerShell"
|
||
x-init={ readerInitExpr(metadata) }
|
||
class={ "theme-" + user.Theme + " h-screen overflow-hidden overscroll-none" }
|
||
>
|
||
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
||
drawers and scrim float above everything. -->
|
||
<div id="reader-viewport" class="fixed inset-0">
|
||
<foliate-view id="reader-view" exportparts="filter" class="block w-full h-full"></foliate-view>
|
||
<!-- Desktop edge page-turn zones: hover to reveal the arrow.
|
||
Hidden on touch (tap zones cover that) and while a
|
||
fixed-layout page is zoomed (edges belong to content then). -->
|
||
<div
|
||
class="reader-edge-zone left"
|
||
:class="edgeZonesActive ? '' : 'pointer-events-none'"
|
||
@click="goLeft()"
|
||
aria-hidden="true"
|
||
>
|
||
<svg class="reader-icon" width="28" height="28" aria-hidden="true">
|
||
<path d="M 18 6 L 10 14 L 18 22"></path>
|
||
</svg>
|
||
</div>
|
||
<div
|
||
class="reader-edge-zone right"
|
||
:class="edgeZonesActive ? '' : 'pointer-events-none'"
|
||
@click="goRight()"
|
||
aria-hidden="true"
|
||
>
|
||
<svg class="reader-icon" width="28" height="28" aria-hidden="true">
|
||
<path d="M 10 6 L 18 14 L 10 22"></path>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
@ReaderChrome(metadata)
|
||
<!-- Drawer scrim -->
|
||
<div
|
||
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
|
||
x-transition.opacity.duration.200ms
|
||
@click="closeDrawers()"
|
||
class="drawer-scrim"
|
||
aria-hidden="true"
|
||
></div>
|
||
<!-- TOC drawer (left) -->
|
||
<div
|
||
id="toc-drawer"
|
||
x-show="tocOpen"
|
||
x-transition:enter="transition-transform duration-200 ease-out"
|
||
x-transition:enter-start="-translate-x-full"
|
||
x-transition:enter-end="translate-x-0"
|
||
x-transition:leave="transition-transform duration-150 ease-in"
|
||
x-transition:leave-start="translate-x-0"
|
||
x-transition:leave-end="-translate-x-full"
|
||
class="reader-drawer left"
|
||
role="dialog"
|
||
aria-label="Table of contents"
|
||
>
|
||
@ReaderTOCDrawer()
|
||
</div>
|
||
<!-- Settings drawer (right) -->
|
||
<div
|
||
id="settings-drawer"
|
||
x-show="settingsOpen"
|
||
x-transition:enter="transition-transform duration-200 ease-out"
|
||
x-transition:enter-start="translate-x-full"
|
||
x-transition:enter-end="translate-x-0"
|
||
x-transition:leave="transition-transform duration-150 ease-in"
|
||
x-transition:leave-start="translate-x-0"
|
||
x-transition:leave-end="translate-x-full"
|
||
class="reader-drawer right"
|
||
role="dialog"
|
||
aria-label="Reader settings"
|
||
>
|
||
@ReaderSettingsDrawer()
|
||
</div>
|
||
<!-- Selection popover (highlights) -->
|
||
<div
|
||
id="selection-popover"
|
||
x-show="selectionPopover.open"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-100"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
:style="'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'"
|
||
class="reader-popover"
|
||
role="dialog"
|
||
aria-label="Highlight selection"
|
||
@pointerdown.stop
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<template x-for="c in highlightColors" :key="c">
|
||
<button
|
||
type="button"
|
||
class="color-dot"
|
||
:class="selectionPopover.color === c ? 'selected' : ''"
|
||
:style="'background-color:' + c"
|
||
:title="selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'"
|
||
:aria-label="'Highlight color ' + c"
|
||
@click="selectionPopover.noteOpen
|
||
? (selectionPopover.mode === 'create'
|
||
? (selectionPopover.color = c)
|
||
: (selectionPopover.color = c, saveHighlightChanges(true)))
|
||
: (selectionPopover.mode === 'create'
|
||
? createHighlight(c)
|
||
: (selectionPopover.color = c, saveHighlightChanges()))"
|
||
></button>
|
||
</template>
|
||
<div class="w-px h-5 reader-sep"></div>
|
||
<button
|
||
type="button"
|
||
class="reader-popover-btn"
|
||
title="Note"
|
||
@click="selectionPopover.noteOpen = true"
|
||
>
|
||
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5"></path></svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="reader-popover-btn"
|
||
title="Copy text"
|
||
@click="copySelectionText()"
|
||
>
|
||
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z"></path></svg>
|
||
</button>
|
||
<template x-if="selectionPopover.mode === 'edit'">
|
||
<button
|
||
type="button"
|
||
class="reader-popover-btn"
|
||
title="Adjust highlight extent"
|
||
@click="startAnyAdjust()"
|
||
>
|
||
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14"></path></svg>
|
||
</button>
|
||
</template>
|
||
<template x-if="selectionPopover.mode === 'edit'">
|
||
<button
|
||
type="button"
|
||
class="reader-popover-btn danger"
|
||
title="Delete highlight"
|
||
@click="deleteHighlightById(selectionPopover.id)"
|
||
>
|
||
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16"></path></svg>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<div x-show="selectionPopover.noteOpen" class="mt-2">
|
||
<textarea
|
||
x-model="selectionPopover.note"
|
||
rows="3"
|
||
class="reader-note-input"
|
||
placeholder="Note…"
|
||
@paste="richNotePaste"
|
||
></textarea>
|
||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||
<div
|
||
class="text-xs mt-0.5 text-right"
|
||
x-show="selectionPopover.note.length > 0"
|
||
:style="noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'"
|
||
x-text="noteCountLabel(selectionPopover.note)"
|
||
></div>
|
||
<div class="flex gap-2 mt-1">
|
||
<button
|
||
type="button"
|
||
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="noteOverLimit(selectionPopover.note)"
|
||
@click="selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()"
|
||
x-text="selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'"
|
||
>Save</button>
|
||
<button
|
||
type="button"
|
||
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
||
style="border-color: var(--border);"
|
||
@click="hideSelectionPopover()"
|
||
>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Annotations drawer (right): highlights / notes / bookmarks -->
|
||
<div
|
||
id="annotations-drawer"
|
||
x-show="bookmarksOpen"
|
||
x-transition:enter="transition-transform duration-200 ease-out"
|
||
x-transition:enter-start="translate-x-full"
|
||
x-transition:enter-end="translate-x-0"
|
||
x-transition:leave="transition-transform duration-150 ease-in"
|
||
x-transition:leave-start="translate-x-0"
|
||
x-transition:leave-end="translate-x-full"
|
||
class="reader-drawer right"
|
||
role="dialog"
|
||
aria-label="Annotations"
|
||
>
|
||
@ReaderAnnotationsDrawer()
|
||
</div>
|
||
<!-- Search drawer (right, reflowable only) -->
|
||
<div
|
||
id="search-drawer"
|
||
x-show="searchOpen"
|
||
x-transition:enter="transition-transform duration-200 ease-out"
|
||
x-transition:enter-start="translate-x-full"
|
||
x-transition:enter-end="translate-x-0"
|
||
x-transition:leave="transition-transform duration-150 ease-in"
|
||
x-transition:leave-start="translate-x-0"
|
||
x-transition:leave-end="translate-x-full"
|
||
class="reader-drawer right"
|
||
role="dialog"
|
||
aria-label="Search"
|
||
>
|
||
@ReaderSearchDrawer()
|
||
</div>
|
||
<!-- Shortcuts & gestures help modal -->
|
||
<div
|
||
x-show="helpOpen"
|
||
x-transition.opacity.duration.200ms
|
||
class="reader-help-backdrop"
|
||
@click="helpOpen = false"
|
||
aria-hidden="true"
|
||
></div>
|
||
<div
|
||
x-show="helpOpen"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-100"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
class="reader-help"
|
||
role="dialog"
|
||
aria-label="Shortcuts and gestures"
|
||
>
|
||
<div class="reader-help-header">
|
||
<h3 class="font-semibold text-base">Shortcuts & Gestures</h3>
|
||
<button @click="helpOpen = false" class="p-1.5 rounded-lg hover:bg-gray-700" title="Close (Esc)" aria-label="Close">✕</button>
|
||
</div>
|
||
<div class="reader-help-body">
|
||
<h4 class="reader-help-section">Navigation</h4>
|
||
<div class="help-row"><span>Previous / next page</span><span class="help-keys"><kbd class="kbd">←</kbd><kbd class="kbd">→</kbd> or <kbd class="kbd">h</kbd><kbd class="kbd">l</kbd></span></div>
|
||
<div class="help-row"><span>Return to previous location</span><span class="help-keys"><kbd class="kbd">Alt</kbd>+<kbd class="kbd">←</kbd></span></div>
|
||
<div class="help-row"><span>Page edge zones <span class="help-note">(desktop)</span></span><span class="help-keys">click page edges</span></div>
|
||
<div class="help-row" x-show="!isFixedLayout || isPDF"><span>Search in book</span><span class="help-keys"><kbd class="kbd">/</kbd></span></div>
|
||
<div class="help-row"><span>Contents / thumbnails</span><span class="help-keys"><kbd class="kbd">t</kbd></span></div>
|
||
<div class="help-row"><span>Annotations drawer</span><span class="help-keys"><kbd class="kbd">a</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>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>
|
||
<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>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>
|
||
<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>Edit / delete</span><span class="help-keys">click the highlight</span></div>
|
||
<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>Toggle chrome</span><span class="help-keys">tap center</span></div>
|
||
<div class="help-row" x-show="isFixedLayout"><span>Zoom / move</span><span class="help-keys">pinch · two-finger drag · double-tap</span></div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ ReaderChrome(metadata ReaderMetadata) {
|
||
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
|
||
<!-- Top bar -->
|
||
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
|
||
<div class="flex items-center justify-between px-3 py-2 sm:px-4">
|
||
<a id="reader-back" href={ "/media/" + metadata.MediaItemID } class="text-base sm:text-lg hover:underline">
|
||
← Back
|
||
</a>
|
||
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
|
||
<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 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 (a)">📝</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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Bottom bar -->
|
||
<div id="reader-bottombar" class="fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass">
|
||
<!-- Reflowable row -->
|
||
<div x-show="!isFixedLayout" class="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 grow min-w-[190px]">
|
||
<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>
|
||
<input
|
||
id="progress-slider"
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="any"
|
||
list="tick-marks"
|
||
@input="goToFraction($event.target.value)"
|
||
class="grow"
|
||
/>
|
||
<datalist id="tick-marks"></datalist>
|
||
<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>
|
||
<div class="flex items-center gap-1">
|
||
<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">
|
||
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">—</span>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
<!-- Fixed-layout row (comics, PDFs, manga) -->
|
||
<div x-show="isFixedLayout">
|
||
<!-- 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="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">
|
||
<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">
|
||
<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 — 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">
|
||
<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>
|
||
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
||
</div>
|
||
</div>
|
||
<!-- Compact row (phone-sized windows): single line + ⋯ overflow.
|
||
Standard mobile-reader pattern: paging, slider, progress stay;
|
||
power tools move into a labeled menu. -->
|
||
<div class="flex md:hidden items-center gap-1 px-1.5 py-1.5">
|
||
<button @click="goLeft()" class="p-1.5 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 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>
|
||
<input
|
||
id="progress-slider-fx-compact"
|
||
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 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 @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3rem] text-center cursor-pointer truncate shrink-0">
|
||
<span x-text="progressMain">0/0</span>
|
||
</div>
|
||
<button
|
||
data-tools-toggle
|
||
@click="toggleTools()"
|
||
class="p-1.5 rounded-lg hover:bg-gray-700"
|
||
:title="toolsOpen ? 'Close tools' : 'More tools'"
|
||
aria-label="More tools"
|
||
:aria-expanded="toolsOpen ? 'true' : 'false'"
|
||
>
|
||
<svg class="reader-icon" width="20" height="20" aria-hidden="true">
|
||
<circle cx="5" cy="12" r="1.5" fill="currentColor"></circle>
|
||
<circle cx="12" cy="12" r="1.5" fill="currentColor"></circle>
|
||
<circle cx="19" cy="12" r="1.5" fill="currentColor"></circle>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<!-- Tools overflow menu: labeled rows (mobile-reader pattern —
|
||
labels beat mystery icons). Anchored above the bottom bar. -->
|
||
<div
|
||
x-show="toolsOpen"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0 translate-y-2"
|
||
x-transition:enter-end="opacity-100 translate-y-0"
|
||
x-transition:leave="transition ease-in duration-100"
|
||
x-transition:leave-start="opacity-100 translate-y-0"
|
||
x-transition:leave-end="opacity-0 translate-y-2"
|
||
class="reader-tools-popover"
|
||
role="menu"
|
||
aria-label="Reader tools"
|
||
@pointerdown.stop
|
||
>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<span>Zoom</span>
|
||
<div class="flex items-center gap-1">
|
||
<button @click="zoomOut()" class="reader-popover-btn" title="Zoom out">−</button>
|
||
<button @click="resetZoom()" class="text-xs px-1 min-w-[3rem] text-center" title="Reset zoom"><span x-text="zoomPercent + '%'">100%</span></button>
|
||
<button @click="zoomIn()" class="reader-popover-btn" title="Zoom in">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<span>Fit</span>
|
||
<select
|
||
@change="applyFitMode($event.target.value)"
|
||
class="reader-select text-xs px-1.5 py-1 rounded-lg"
|
||
aria-label="Fit mode"
|
||
>
|
||
<option value="fit-page">Fit Page</option>
|
||
<option value="fit-width">Fit Width</option>
|
||
</select>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<span>Page position</span>
|
||
<button @click="recenterView()" class="reader-popover-btn text-xs px-2" title="Reset pan, keep zoom">Recenter</button>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<span>Magnifier</span>
|
||
<button @click="toggleMagnifier()" class="reader-popover-btn text-xs px-2" :class="magnifierEnabled ? 'text-blue-400' : ''" x-text="magnifierEnabled ? 'On' : 'Off'">Off</button>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<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>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="isPDF && !treatAsComic">
|
||
<span>Pointer</span>
|
||
<div class="reader-seg" role="group" aria-label="Pointer mode">
|
||
<button :class="interactionMode === 'select' ? 'active' : ''" @click="setInteractionMode('select')">Smart</button>
|
||
<button :class="interactionMode === 'pan' ? 'active' : ''" @click="setInteractionMode('pan')">Pan</button>
|
||
<button :class="interactionMode === 'text' ? 'active' : ''" @click="setInteractionMode('text')">Text</button>
|
||
</div>
|
||
</div>
|
||
<div class="reader-tools-row" x-show="comicFlow !== 'webtoon'">
|
||
<span>Double page</span>
|
||
<button @click="toggleSpread()" class="reader-popover-btn text-xs px-2" :class="doublePageSpread ? 'text-blue-400' : ''" x-text="doublePageSpread ? 'On' : 'Off'">Off</button>
|
||
</div>
|
||
<div class="reader-tools-row">
|
||
<span>Contents</span>
|
||
<button @click="toggleTOC()" class="reader-popover-btn text-xs px-2">Open</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ drawerHeader(title string) {
|
||
<div class="reader-drawer-header">
|
||
<h3 class="font-semibold">{ title }</h3>
|
||
<button @click="closeDrawers()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Close (Esc)" aria-label="Close">✕</button>
|
||
</div>
|
||
}
|
||
|
||
templ ReaderTOCDrawer() {
|
||
@drawerHeader("Contents")
|
||
<!-- Fixed-layout books get a Pages (thumbnails) tab; outline TOC may be
|
||
absent in manga-scan PDFs and CBZs. -->
|
||
<div class="reader-tabs" x-show="isFixedLayout">
|
||
<button :class="tocTab === 'contents' ? 'active' : ''" @click="tocTab = 'contents'">
|
||
Contents <span class="reader-tab-count" x-text="tocItems.length">0</span>
|
||
</button>
|
||
<button :class="tocTab === 'pages' ? 'active' : ''" @click="tocTab = 'pages'; initPageThumbs()">
|
||
Pages <span class="reader-tab-count" x-text="pageThumbList.length || ''">0</span>
|
||
</button>
|
||
</div>
|
||
<div class="reader-drawer-body">
|
||
<nav id="toc-list" class="space-y-1" x-show="tocTab === 'contents' || !isFixedLayout">
|
||
<!-- Key must include the index: PDF outline entries can share the
|
||
same destination href, and Alpine's x-for renders nothing on
|
||
duplicate keys. -->
|
||
<template x-for="(item, idx) in tocItems" :key="item.href + '::' + idx">
|
||
<a
|
||
href="#"
|
||
@click.prevent="goToTOCItem(item)"
|
||
class="block py-1 px-2 rounded hover:bg-gray-700 text-sm"
|
||
:style="'padding-left: ' + ((item.depth || 0) * 1 + 0.5) + 'rem'"
|
||
x-text="item.label"
|
||
></a>
|
||
</template>
|
||
<template x-if="tocItems.length === 0">
|
||
<p class="text-sm py-2" style="color: var(--text-secondary)">No table of contents</p>
|
||
</template>
|
||
</nav>
|
||
<div id="page-thumb-grid" class="reader-thumb-grid" x-show="isFixedLayout && tocTab === 'pages'">
|
||
<template x-for="p in pageThumbList" :key="p.index">
|
||
<button
|
||
class="reader-thumb"
|
||
:class="p.index === currentPageIndex ? 'active' : ''"
|
||
@click="goToPage(p.index)"
|
||
:title="'Page ' + (p.index + 1)"
|
||
>
|
||
<div class="reader-thumb-img" :data-page="p.index"></div>
|
||
<span class="reader-thumb-num" x-text="p.index + 1">1</span>
|
||
</button>
|
||
</template>
|
||
<template x-if="pageThumbList.length === 0">
|
||
<p class="text-sm py-2" style="color: var(--text-secondary)">No pages</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ ReaderAnnotationsDrawer() {
|
||
@drawerHeader("Annotations")
|
||
<div class="reader-tabs">
|
||
<button :class="annotationsTab === 'highlights' ? 'active' : ''" @click="annotationsTab = 'highlights'">
|
||
Highlights <span class="reader-tab-count" x-text="highlightItems.length">0</span>
|
||
</button>
|
||
<button :class="annotationsTab === 'notes' ? 'active' : ''" @click="annotationsTab = 'notes'">
|
||
Notes <span class="reader-tab-count" x-text="noteItems.length">0</span>
|
||
</button>
|
||
<button :class="annotationsTab === 'bookmarks' ? 'active' : ''" @click="annotationsTab = 'bookmarks'">
|
||
Bookmarks <span class="reader-tab-count" x-text="bookmarkItems.length">0</span>
|
||
</button>
|
||
</div>
|
||
<div class="reader-drawer-body">
|
||
<!-- Highlights -->
|
||
<div x-show="annotationsTab === 'highlights'" class="space-y-2">
|
||
<template x-for="hl in highlightItems" :key="hl.id">
|
||
<div class="reader-hl-row group">
|
||
<a
|
||
href="#"
|
||
@click.prevent="goToHighlight(hl)"
|
||
class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700"
|
||
>
|
||
<span class="block text-sm truncate" :style="'border-left: 3px solid ' + hl.color + '; padding-left: 0.5rem;'" x-text="hl.text"></span>
|
||
<span class="note-rich block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-html="'📝 ' + hl.noteHtml"></span>
|
||
</a>
|
||
<button
|
||
@click="deleteHighlightById(hl.id)"
|
||
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Delete highlight"
|
||
aria-label="Delete highlight"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<template x-if="highlightItems.length === 0">
|
||
<p class="text-sm" style="color: var(--text-secondary)">Select text in the book to highlight it</p>
|
||
</template>
|
||
</div>
|
||
<!-- Notes -->
|
||
<div x-show="annotationsTab === 'notes'">
|
||
<textarea
|
||
x-model="newNoteText"
|
||
rows="2"
|
||
class="reader-note-input"
|
||
placeholder="Add a note at the current position…"
|
||
@paste="richNotePaste"
|
||
></textarea>
|
||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">Markdown supported — rich-text paste converts to markdown</p>
|
||
<div
|
||
class="text-xs mt-0.5 text-right"
|
||
x-show="newNoteText.length > 0"
|
||
:style="noteOverLimit(newNoteText) ? 'color: #f87171;' : 'color: var(--text-secondary);'"
|
||
x-text="noteCountLabel(newNoteText)"
|
||
></div>
|
||
<button
|
||
@click="addNote(newNoteText); newNoteText = ''"
|
||
:disabled="noteOverLimit(newNoteText)"
|
||
class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
+ Add Note
|
||
</button>
|
||
<div class="space-y-2">
|
||
<template x-for="note in noteItems" :key="note.id">
|
||
<div class="reader-hl-row group">
|
||
<template x-if="!note.editOpen">
|
||
<div class="flex">
|
||
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
||
<span class="note-rich block text-sm truncate" x-html="note.contentHtml"></span>
|
||
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
||
</a>
|
||
<button
|
||
@click="startNoteEdit(note)"
|
||
class="p-2 rounded hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Edit note"
|
||
aria-label="Edit note"
|
||
>
|
||
✎
|
||
</button>
|
||
<button
|
||
@click="deleteNoteById(note.id)"
|
||
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Delete note"
|
||
aria-label="Delete note"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<!-- Inline edit: mirrors the bookmark-rename pattern -->
|
||
<div class="w-full py-2" x-show="note.editOpen" @click.stop>
|
||
<textarea
|
||
x-model="note.editText"
|
||
rows="3"
|
||
class="reader-note-input"
|
||
></textarea>
|
||
<div class="flex gap-2 mt-1">
|
||
<button
|
||
@click="saveNoteEdit(note)"
|
||
class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
:disabled="!String(note.editText || '').trim()"
|
||
>Save</button>
|
||
<button
|
||
@click="cancelNoteEdit(note)"
|
||
class="flex-1 py-1 text-xs border rounded hover:opacity-80"
|
||
style="border-color: var(--border);"
|
||
>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="noteItems.length === 0">
|
||
<p class="text-sm" style="color: var(--text-secondary)">No notes yet</p>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- Bookmarks -->
|
||
<div x-show="annotationsTab === 'bookmarks'" class="space-y-2">
|
||
<template x-for="bookmark in bookmarkItems" :key="bookmark.id">
|
||
<div class="flex items-center gap-1 group">
|
||
<a
|
||
href="#"
|
||
@click.prevent="goToBookmark(bookmark)"
|
||
class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2"
|
||
x-show="!bookmark.renameOpen"
|
||
>
|
||
<span class="font-medium block truncate" x-text="bookmark.title"></span>
|
||
<span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
|
||
</a>
|
||
<div class="flex-1 min-w-0 py-2" x-show="bookmark.renameOpen" @click.stop>
|
||
<input
|
||
type="text"
|
||
class="reader-note-input"
|
||
x-model="bookmark.renameText"
|
||
@keydown.enter="renameBookmark(bookmark)"
|
||
@keydown.escape="bookmark.renameOpen = false"
|
||
placeholder="Bookmark name…"
|
||
/>
|
||
<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="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
@click="startBookmarkRename(bookmark)"
|
||
class="p-2 rounded hover:bg-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Rename bookmark"
|
||
aria-label="Rename bookmark"
|
||
>
|
||
✎
|
||
</button>
|
||
<button
|
||
@click="deleteBookmark(bookmark.id)"
|
||
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Delete bookmark"
|
||
aria-label="Delete bookmark"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<template x-if="bookmarkItems.length === 0">
|
||
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
|
||
</template>
|
||
<button @click="addBookmark()" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||
+ Add Bookmark
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ ReaderSearchDrawer() {
|
||
@drawerHeader("Search")
|
||
<div class="reader-search-bar">
|
||
<input
|
||
type="search"
|
||
x-ref="searchInput"
|
||
x-model="searchQuery"
|
||
@keydown.enter.prevent="runSearch()"
|
||
placeholder="Search in book…"
|
||
class="reader-note-input grow"
|
||
aria-label="Search query"
|
||
/>
|
||
<button
|
||
x-show="searchQuery || searchGroups.length"
|
||
@click="searchQuery = ''; clearSearchResults()"
|
||
class="p-2 rounded-lg hover:bg-gray-700 shrink-0"
|
||
title="Clear results"
|
||
aria-label="Clear results"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div class="reader-search-status" x-show="searchQuery || searching || searchGroups.length || searchError">
|
||
<span x-show="searching" x-text="'Searching… ' + Math.round(searchProgress * 100) + '%'">Searching…</span>
|
||
<span x-show="!searching && searchGroups.length" x-text="searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')">0 matches</span>
|
||
<span x-show="!searching && searchQuery && !searchGroups.length && !searchError">No matches</span>
|
||
<span x-show="searchError" x-text="searchError" style="color: #f87171;">Search failed</span>
|
||
</div>
|
||
<div class="reader-drawer-body">
|
||
<template x-for="(group, gi) in searchGroups" :key="gi">
|
||
<div class="mb-3">
|
||
<div class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)" x-text="group.label"></div>
|
||
<div class="space-y-1">
|
||
<template x-for="(item, si) in group.items" :key="gi + '-' + si">
|
||
<a
|
||
href="#"
|
||
@click.prevent="goToSearchResult(item)"
|
||
class="search-result"
|
||
>
|
||
<span x-text="item.pre"></span><mark x-text="item.match"></mark><span x-text="item.post"></span>
|
||
</a>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
}
|
||
|
||
templ ReaderSettingsDrawer() {
|
||
@drawerHeader("Settings")
|
||
<div class="reader-drawer-body">
|
||
<!-- Behavior -->
|
||
<div class="mb-6">
|
||
<h3 class="font-semibold mb-2">Behavior</h3>
|
||
<label class="block mb-2">
|
||
Chrome
|
||
<select name="chrome_behavior" x-model="chromeBehavior" @change="applyChromeBehavior()" class="w-full mt-1 px-3 py-2 rounded border" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||
<option value="auto-hide">Auto Hide</option>
|
||
<option value="always-visible">Always Visible</option>
|
||
</select>
|
||
</label>
|
||
<label class="block mb-2">
|
||
Progress Mode
|
||
<select name="progress_mode" x-model="progressMode" @change="applyProgressMode()" class="w-full mt-1 px-3 py-2 rounded border" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||
<option value="pages">Pages</option>
|
||
<option value="chapter">Chapter</option>
|
||
<option value="percentage">Percentage</option>
|
||
<option value="time-left">Time Left</option>
|
||
</select>
|
||
</label>
|
||
<label class="flex items-center mb-2">
|
||
<input type="checkbox" name="tap_zones_enabled" x-model="tapZonesEnabled" @change="applyTapZoneSettings()" class="mr-2"/>
|
||
Tap Zones <span class="text-xs ml-1" style="color: var(--text-secondary)">(touch)</span>
|
||
</label>
|
||
<label class="block mb-2" x-show="tapZonesEnabled">
|
||
Tap Zone Size
|
||
<input type="range" name="tap_zone_size" x-model.number="tapZoneSize" @change="applyTapZoneSettings()" min="10" max="50" class="w-full"/>
|
||
<span class="text-xs" x-text="tapZoneSize + '%'" style="color: var(--text-secondary)">30%</span>
|
||
</label>
|
||
</div>
|
||
<!-- Reading Theme (reflowable only) -->
|
||
<div class="mb-6" x-show="!isFixedLayout">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h3 class="font-semibold">Reading Theme</h3>
|
||
<button
|
||
@click="toggleReadingMode()"
|
||
class="relative w-14 h-7 rounded-full transition-colors duration-200 flex items-center px-1"
|
||
:class="readingMode === 'dark' ? 'bg-blue-500' : 'bg-gray-400'"
|
||
type="button"
|
||
:aria-label="readingMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||
>
|
||
<span
|
||
class="absolute top-0.5 left-0.5 w-6 h-6 bg-white rounded-full shadow transition-transform duration-200 flex items-center justify-center text-xs"
|
||
:class="readingMode === 'dark' ? 'translate-x-7' : 'translate-x-0'"
|
||
x-text="readingMode === 'dark' ? '🌙' : '☀️'"
|
||
>☀️</span>
|
||
</button>
|
||
</div>
|
||
<div class="grid grid-cols-6 gap-2">
|
||
<template x-for="sw in themeSwatches" :key="sw.value">
|
||
<button
|
||
type="button"
|
||
@click="readingTheme = sw.value; applyTheme()"
|
||
class="theme-swatch"
|
||
:class="readingTheme === sw.value ? 'ring-2 ring-blue-500' : ''"
|
||
:style="'background-color:' + sw.bg + '; color:' + sw.fg"
|
||
:title="sw.label"
|
||
:aria-label="'Theme ' + sw.label"
|
||
:aria-pressed="readingTheme === sw.value"
|
||
>
|
||
<span class="text-xs font-semibold">Aa</span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<!-- Typography (reflowable only) -->
|
||
<div class="mb-6" x-show="!isFixedLayout">
|
||
<h3 class="font-semibold mb-2">Typography</h3>
|
||
<label class="block mb-2">
|
||
Reading Font
|
||
<select name="reading_font" x-model="readingFont" @change="applyFont()" class="w-full mt-1 px-3 py-2 rounded border" style="border-color: var(--border); background-color: var(--bg-secondary);">
|
||
<option value="literata">Literata (Default)</option>
|
||
<option value="crimson">Crimson Text</option>
|
||
<option value="source-serif">Source Serif 4</option>
|
||
<option value="eb-garamond">EB Garamond</option>
|
||
<option value="libertinus">Libertinus Serif</option>
|
||
<option value="noto-serif">Noto Serif</option>
|
||
<option value="charis-sil">Charis SIL</option>
|
||
<option value="ibm-plex">IBM Plex Serif</option>
|
||
</select>
|
||
</label>
|
||
<label class="block mb-2">
|
||
Font Size
|
||
<input type="range" name="font_size" x-model.number="fontSize" @change="applyFont()" min="10" max="40" class="w-full"/>
|
||
<span class="text-xs" x-text="fontSize + 'px'" style="color: var(--text-secondary)">18px</span>
|
||
</label>
|
||
<label class="block mb-2">
|
||
Line Height
|
||
<input type="range" name="line_height" x-model.number="lineHeight" @change="applyFont()" min="0.8" max="3.0" step="0.1" class="w-full"/>
|
||
<span class="text-xs" x-text="lineHeight" style="color: var(--text-secondary)">1.6</span>
|
||
</label>
|
||
</div>
|
||
<!-- Layout & display (fixed-layout only) -->
|
||
<div class="mb-6" x-show="isFixedLayout">
|
||
<h3 class="font-semibold mb-2">Layout & Display</h3>
|
||
<label class="block mb-3" x-show="isComic">
|
||
Reading Flow
|
||
<div class="reader-seg w-full mt-1" role="group" aria-label="Reading flow">
|
||
<button type="button" class="flex-1" :class="comicFlow !== 'webtoon' ? 'active' : ''" @click="setComicFlow('paged')" title="One page or spread at a time">Paged</button>
|
||
<button type="button" class="flex-1" :class="comicFlow === 'webtoon' ? 'active' : ''" @click="setComicFlow('webtoon')" title="Continuous vertical scroll (long-strip)">Webtoon</button>
|
||
</div>
|
||
</label>
|
||
<label class="block mb-2">
|
||
Brightness
|
||
<input type="range" name="fx_brightness" x-model.number="fxBrightness" @input="applyFxFilter()" @change="applyFxFilter(true)" min="0.3" max="1.3" step="0.05" class="w-full"/>
|
||
<span class="text-xs" x-text="Math.round(fxBrightness * 100) + '%'" style="color: var(--text-secondary)">100%</span>
|
||
</label>
|
||
<label class="block mb-2">
|
||
Contrast
|
||
<input type="range" name="fx_contrast" x-model.number="fxContrast" @input="applyFxFilter()" @change="applyFxFilter(true)" min="0.7" max="1.3" step="0.05" class="w-full"/>
|
||
<span class="text-xs" x-text="Math.round(fxContrast * 100) + '%'" style="color: var(--text-secondary)">100%</span>
|
||
</label>
|
||
<label class="flex items-center mb-2">
|
||
<input type="checkbox" name="fx_invert" x-model="fxInvert" @change="applyFxFilter(true)" class="mr-2"/>
|
||
Night Mode <span class="text-xs ml-1" style="color: var(--text-secondary)">(invert)</span>
|
||
</label>
|
||
<label class="flex items-center mb-2" x-show="comicFlow !== 'webtoon'">
|
||
<input type="checkbox" name="double_page_spread" x-model="doublePageSpread" @change="applyDoublePageSpread()" class="mr-2"/>
|
||
Double Page Spread
|
||
</label>
|
||
</div>
|
||
<div class="flex gap-2 mt-6">
|
||
<button @click="restoreDefaults()" class="flex-1 py-2 border rounded hover:opacity-80" style="border-color: var(--border);">
|
||
Restore Defaults
|
||
</button>
|
||
<button @click="closeDrawers()" class="flex-1 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|