feat(reader): in-book search for reflowable formats
Wires foliate's search engine into the new drawer system:
- 🔍 topbar button (reflowable-only; PDF/comic sections have no
searchable text documents) and the '/' keyboard shortcut open a
Search drawer: query input (Enter to run), live progress while
scanning (per-section percent), match count, and results grouped
by section with TOC labels.
- Each result shows pre/match/post excerpt rendered as three text
nodes (no x-html — book content never enters the DOM as markup);
the match is styled with a translucent <mark>. Clicking jumps to
the hit's CFI and closes the drawer.
- Hits are drawn on the page through foliate's overlayer (outline
style) and persist across page turns — the engine re-applies
search results when a section's overlay is created. Clearing the
query removes the outlines.
- A generation counter discards results and progress from superseded
searches (rapid re-query), and starting a new search clears the
previous one server-side via view.clearSearch().
- Search integrates with the drawer system: scrim, Esc-to-close,
one-drawer-at-a-time, / focuses the input via .
This commit is contained in:
+66
-1
@@ -77,7 +77,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
||||
|
||||
<!-- Drawer scrim -->
|
||||
<div
|
||||
x-show="tocOpen || settingsOpen || bookmarksOpen"
|
||||
x-show="tocOpen || settingsOpen || bookmarksOpen || searchOpen"
|
||||
x-transition.opacity.duration.200ms
|
||||
@click="closeDrawers()"
|
||||
class="drawer-scrim"
|
||||
@@ -214,6 +214,23 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
||||
>
|
||||
@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>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -229,6 +246,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
||||
<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" @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>
|
||||
</div>
|
||||
@@ -491,6 +509,53 @@ templ ReaderAnnotationsDrawer() {
|
||||
</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">
|
||||
<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">No matches</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 in group.items" :key="item.cfi">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="goToSearchResult(item.cfi)"
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user