Phase 4 of the reader redesign (foliate-js ea268df): - Webtoon mode for comics: continuous vertical scroll of all pages (900px centered column on wide screens), lazy-loaded with a 150% IntersectionObserver margin, far pages unloaded to bound memory with stable aspect-ratio placeholders so the scrollbar never jumps. Chosen per book (Paged | Webtoon segmented control in Settings → Layout & Display; stored in localStorage per media item since a webtoon title and a paged manga volume want different flows). Toggling reloads the reader — the renderer is chosen at open time — and progress restores from the saved page. Relocate events flow through the same pipeline, so the slider, progress saving, back stack, tap zones, and edge zones all work unchanged. Zoom/fit/ magnifier/spread controls hide in webtoon (natural-width scroll). - Display filters for fixed-layout: brightness (30-130%) and contrast (70-130%) sliders with live preview, plus Night Mode (invert) — also a quick row in the ⋯ tools menu. One --fx-filter CSS var drives everything: ::part(filter) on foliate-view iframes (forwarded via the new exportparts attribute) and the webtoon page images alike. Persisted as fx_brightness/fx_contrast/fx_invert (types + defaults both sides); Restore Defaults resets them.
938 lines
42 KiB
Templ
938 lines
42 KiB
Templ
package templates
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string {
|
||
config := map[string]interface{}{
|
||
"mediaItemId": metadata.MediaItemID,
|
||
"fileUrl": metadata.FileURL,
|
||
"formatGroup": metadata.FormatGroup,
|
||
"readingDirection": metadata.ReadingDirection,
|
||
"mangaType": metadata.MangaType,
|
||
}
|
||
if progress.Percentage > 0 {
|
||
config["savedPercentage"] = progress.Percentage / 100
|
||
}
|
||
if progress.EpubCfi != "" {
|
||
config["savedCfi"] = progress.EpubCfi
|
||
}
|
||
// Fixed-layout & comic formats: the page index is the canonical, exact
|
||
// locator (pages are fixed images). Pass it so the reader restores by page.
|
||
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
|
||
config["savedPage"] = progress.CurrentPage
|
||
if progress.TotalPages > 0 {
|
||
config["savedTotalPages"] = progress.TotalPages
|
||
}
|
||
}
|
||
if len(bookmarks) > 0 {
|
||
items := make([]map[string]interface{}, 0, len(bookmarks))
|
||
for _, b := range bookmarks {
|
||
var page any
|
||
if b.PageNumber != nil {
|
||
page = *b.PageNumber
|
||
}
|
||
items = append(items, map[string]interface{}{
|
||
"id": b.ID,
|
||
"title": b.Title,
|
||
"positionLabel": b.Position,
|
||
"cfi": b.CfiPosition,
|
||
"page": page,
|
||
})
|
||
}
|
||
config["bookmarks"] = items
|
||
}
|
||
jsonBytes, _ := json.Marshal(config)
|
||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||
}
|
||
|
||
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<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, progress, bookmarks) }
|
||
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
||
>
|
||
<!-- 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, progress)
|
||
|
||
<!-- 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.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 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…"
|
||
></textarea>
|
||
<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"
|
||
@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="selectionPopover.noteOpen = false"
|
||
>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">📝 button</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, progress ReadingProgress) {
|
||
<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" @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="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">
|
||
if progress.FormatGroup == "reflowable" {
|
||
if metadata.EstimatedPages > 0 {
|
||
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
|
||
} else {
|
||
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
|
||
}
|
||
} else {
|
||
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
|
||
}
|
||
</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 -->
|
||
<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">
|
||
if progress.FormatGroup == "reflowable" {
|
||
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
|
||
} else {
|
||
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
|
||
}
|
||
</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">
|
||
<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="block text-xs mt-0.5 truncate pl-2" style="color: var(--text-secondary)" x-show="hl.note" x-text="'📝 ' + hl.note"></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…"
|
||
></textarea>
|
||
<button @click="addNote(newNoteText); newNoteText = ''" class="w-full py-1.5 mt-1 mb-3 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
|
||
+ Add Note
|
||
</button>
|
||
<div class="space-y-2">
|
||
<template x-for="note in noteItems" :key="note.id">
|
||
<div class="reader-hl-row group">
|
||
<a href="#" @click.prevent="closeDrawers()" class="flex-1 min-w-0 block py-2 px-2 rounded hover:bg-gray-700">
|
||
<span class="block text-sm" x-text="note.content"></span>
|
||
<span class="block text-xs mt-0.5 truncate" style="color: var(--text-secondary)" x-text="note.positionLabel"></span>
|
||
</a>
|
||
<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>
|
||
<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"
|
||
>
|
||
<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>
|
||
<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>
|
||
}
|