feat(reader): EPUB highlights & notes — selection popover, overlayer rendering, annotations drawer

Phase 3 (EPUB half) of the reader redesign:

- Select text in a reflowable book → floating glass popover at the
  selection (5 colors, note, copy). Clicking a color creates the
  highlight via POST /api/media-items/:id/highlights, anchored by the
  foliate range CFI (epubcfi_start) with percentage position.
- Highlights render through foliate's overlayer pipeline: draw-
  annotation draws Overlayer.highlight with the stored color,
  create-overlay re-adds persisted highlights as sections load,
  show-annotation opens the edit popover when a highlight is clicked
  (recolor, edit note, copy, delete).
- Backend: highlight create/update accept epubcfi_start/end,
  note_text, and percentage fields; position validation relaxed
  (CFIs exceed the old 100-char cap); PUT routes through
  AnnotationService.SaveHighlight so edits get dedup/LWW treatment
  and actually persist note_text (the plain query can't).
- Bookmarks drawer becomes the Annotations drawer with tabs:
  Highlights (color-bar list, note previews, jump/edit/delete),
  Notes (add note at current position, list, delete — backed by the
  existing notes API), and Bookmarks (unchanged behavior).
- Popover dismissed on outside click, collapsed selection, page
  navigation, or Esc (new top-priority Esc branch).
This commit is contained in:
2026-08-16 12:33:55 -04:00
parent bd7d71a284
commit 40d70513da
6 changed files with 682 additions and 46 deletions
+162 -11
View File
@@ -118,9 +118,89 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
@ReaderSettingsDrawer()
</div>
<!-- Bookmarks drawer (right) -->
<!-- Selection popover (highlights) -->
<div
id="bookmarks-drawer"
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"
@@ -130,9 +210,9 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
x-transition:leave-end="translate-x-full"
class="reader-drawer right"
role="dialog"
aria-label="Bookmarks"
aria-label="Annotations"
>
@ReaderBookmarksDrawer()
@ReaderAnnotationsDrawer()
</div>
</body>
</html>
@@ -149,7 +229,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 @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmarks">📝</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>
</div>
@@ -306,10 +386,81 @@ templ ReaderTOCDrawer() {
</div>
}
templ ReaderBookmarksDrawer() {
@drawerHeader("Bookmarks")
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">
<div id="bookmarks-list" class="space-y-2">
<!-- 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
@@ -333,10 +484,10 @@ templ ReaderBookmarksDrawer() {
<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>
<button @click="addBookmark()" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
+ Add Bookmark
</button>
</div>
}