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:
2026-08-17 07:58:03 -04:00
parent eb09a5d939
commit 5e73b0a4f6
5 changed files with 252 additions and 26 deletions
+66 -1
View File
@@ -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">