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:
+72
-18
@@ -115,20 +115,32 @@ type UpdateMediaNoteRequest struct {
|
||||
|
||||
// CreateMediaHighlightRequest represents the request for creating a media highlight
|
||||
type CreateMediaHighlightRequest struct {
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteID string `json:"note_id"`
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
ChapterReference int32 `json:"chapter_reference"`
|
||||
}
|
||||
|
||||
// UpdateMediaHighlightRequest represents the request for updating a media highlight
|
||||
type UpdateMediaHighlightRequest struct {
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteID string `json:"note_id"`
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
ChapterReference int32 `json:"chapter_reference"`
|
||||
}
|
||||
|
||||
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
|
||||
@@ -1576,14 +1588,20 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
Color: color,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
EpubcfiStart: req.EpubcfiStart,
|
||||
EpubcfiEnd: req.EpubcfiEnd,
|
||||
Color: color,
|
||||
NoteText: req.NoteText,
|
||||
PercentageStart: req.PercentageStart,
|
||||
PercentageEnd: req.PercentageEnd,
|
||||
ChapterReference: req.ChapterReference,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1656,6 +1674,42 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
||||
color = req.Color
|
||||
}
|
||||
|
||||
// Prefer the sync-aware path: the same selection text + CFI resolves to
|
||||
// the same dedup key, so this performs an LWW update of the existing row
|
||||
// (including note_text and CFI columns the plain query cannot touch).
|
||||
if mh.annotationSvc != nil {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
EpubcfiStart: req.EpubcfiStart,
|
||||
EpubcfiEnd: req.EpubcfiEnd,
|
||||
Color: color,
|
||||
NoteText: req.NoteText,
|
||||
PercentageStart: req.PercentageStart,
|
||||
PercentageEnd: req.PercentageEnd,
|
||||
ChapterReference: req.ChapterReference,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, result.Highlight)
|
||||
}
|
||||
|
||||
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
SelectionText: req.SelectionText,
|
||||
|
||||
+162
-11
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
+14
-14
@@ -149,11 +149,11 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Bookmarks drawer (right) --><div id=\"bookmarks-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=\"Bookmarks\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</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\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = ReaderBookmarksDrawer().Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = ReaderAnnotationsDrawer().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 146, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 226, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -206,13 +206,13 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 149, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 229, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</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=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</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 items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><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> <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 class=\"w-px h-6 mx-1 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\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</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=\"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></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 items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><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> <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 class=\"w-px h-6 mx-1 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 templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 187, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 267, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -231,7 +231,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 189, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 269, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -242,7 +242,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 192, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 272, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -257,7 +257,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 269, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 349, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -267,7 +267,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 271, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 351, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -310,7 +310,7 @@ func drawerHeader(title string) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 284, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 364, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -357,7 +357,7 @@ func ReaderTOCDrawer() templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
func ReaderBookmarksDrawer() templ.Component {
|
||||
func ReaderAnnotationsDrawer() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -378,11 +378,11 @@ func ReaderBookmarksDrawer() templ.Component {
|
||||
templ_7745c5c3_Var17 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = drawerHeader("Bookmarks").Render(ctx, templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = drawerHeader("Annotations").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"reader-drawer-body\"><div id=\"bookmarks-list\" 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></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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+349
-2
@@ -1,9 +1,18 @@
|
||||
import "foliate-js/view.js";
|
||||
import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js";
|
||||
import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
|
||||
import { Alpine } from "../alpine";
|
||||
import { loadSettings, saveSettings } from "./settings-manager";
|
||||
import { getToken } from "../storage";
|
||||
|
||||
const HIGHLIGHT_COLORS = [
|
||||
"#ffd54f",
|
||||
"#a5d6a7",
|
||||
"#90caf9",
|
||||
"#f48fb1",
|
||||
"#ce93d8",
|
||||
];
|
||||
|
||||
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
|
||||
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
@@ -378,6 +387,30 @@ document.addEventListener("alpine:init", () => {
|
||||
tapZonesEnabled: true as boolean,
|
||||
tapZoneSize: 30 as number,
|
||||
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
highlightItems: [] as {
|
||||
id: string;
|
||||
text: string;
|
||||
note: string;
|
||||
color: string;
|
||||
cfi: string;
|
||||
percentage: number;
|
||||
}[],
|
||||
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
||||
annotationsTab: "highlights" as string,
|
||||
newNoteText: "",
|
||||
highlightColors: HIGHLIGHT_COLORS,
|
||||
selectionPopover: {
|
||||
open: false,
|
||||
mode: "create" as "create" | "edit",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: "",
|
||||
cfi: "",
|
||||
id: "",
|
||||
color: "#ffd54f",
|
||||
note: "",
|
||||
noteOpen: false,
|
||||
},
|
||||
progressText: "",
|
||||
progressLabel: "",
|
||||
progressMain: "",
|
||||
@@ -548,7 +581,7 @@ document.addEventListener("alpine:init", () => {
|
||||
this.renderer.setStyles?.(this.buildCSS());
|
||||
}
|
||||
this.view.addEventListener("load", (e: any) => {
|
||||
const { doc } = e.detail;
|
||||
const { doc, index } = e.detail;
|
||||
const link = doc.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/reader-fonts.css";
|
||||
@@ -566,14 +599,87 @@ document.addEventListener("alpine:init", () => {
|
||||
if (window.matchMedia("(pointer: coarse)").matches) {
|
||||
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
||||
}
|
||||
// Text selection → highlight popover (reflowable EPUB only;
|
||||
// fixed-layout highlight overlays are a later milestone).
|
||||
if (!this.isFixedLayout) {
|
||||
const checkSelection = () => {
|
||||
const sel = doc.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
||||
if (this.selectionPopover.mode === "create")
|
||||
this.hideSelectionPopover();
|
||||
return;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||
if (!text) return;
|
||||
let cfi: string;
|
||||
try {
|
||||
cfi = this.view.getCFI(index, range);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||||
const iframeRect = frame?.getBoundingClientRect();
|
||||
const rect = range.getBoundingClientRect();
|
||||
this.openSelectionPopover({
|
||||
mode: "create",
|
||||
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text,
|
||||
cfi,
|
||||
});
|
||||
};
|
||||
doc.addEventListener(
|
||||
"pointerup",
|
||||
() => setTimeout(checkSelection, 0),
|
||||
{ passive: true },
|
||||
);
|
||||
doc.addEventListener(
|
||||
"keyup",
|
||||
(ev: KeyboardEvent) => {
|
||||
if (ev.shiftKey) setTimeout(checkSelection, 0);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
if (!this.isFixedLayout) {
|
||||
this.computeChapterPageBoundaries(doc);
|
||||
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
||||
}
|
||||
});
|
||||
// ----- highlight rendering (foliate overlayer pipeline) -----
|
||||
this.view.addEventListener("draw-annotation", (e: any) => {
|
||||
const { draw, annotation } = e.detail;
|
||||
draw(Overlayer.highlight, { color: annotation.color || "#ffd54f" });
|
||||
});
|
||||
this.view.addEventListener("show-annotation", (e: any) => {
|
||||
const { value, index, range } = e.detail;
|
||||
const h = this.highlightItems.find((x) => x.cfi === value);
|
||||
if (!h) return;
|
||||
const doc = this.renderer
|
||||
?.getContents?.()
|
||||
?.find((c: any) => c.index === index)?.doc;
|
||||
const frame = doc?.defaultView?.frameElement as HTMLElement | null;
|
||||
const iframeRect = frame?.getBoundingClientRect();
|
||||
const rect = range.getBoundingClientRect();
|
||||
this.openSelectionPopover({
|
||||
mode: "edit",
|
||||
x: (iframeRect?.left ?? 0) + rect.left + rect.width / 2,
|
||||
y: (iframeRect?.top ?? 0) + rect.top,
|
||||
text: h.text,
|
||||
cfi: h.cfi,
|
||||
id: h.id,
|
||||
color: h.color,
|
||||
note: h.note,
|
||||
});
|
||||
});
|
||||
this.view.addEventListener("create-overlay", () => {
|
||||
this.renderAllHighlights();
|
||||
});
|
||||
this.view.addEventListener("relocate", (e: any) => {
|
||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||
e.detail;
|
||||
this.hideSelectionPopover();
|
||||
this.lastRelocateDetail = {
|
||||
fraction,
|
||||
location,
|
||||
@@ -634,6 +740,7 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
this.initTime = Date.now();
|
||||
this.fetchReadingSpeed();
|
||||
this.refreshAnnotations();
|
||||
this.setupChrome();
|
||||
this.setupTapZones();
|
||||
},
|
||||
@@ -644,6 +751,13 @@ document.addEventListener("alpine:init", () => {
|
||||
document.addEventListener("pointermove", () => this.pokeChrome(), {
|
||||
passive: true,
|
||||
});
|
||||
// Dismiss the selection popover on clicks outside it (iframe clicks
|
||||
// are covered by the selection tracker's collapsed check).
|
||||
document.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
if (!this.selectionPopover.open) return;
|
||||
if ((e.target as HTMLElement)?.closest?.("#selection-popover")) return;
|
||||
this.hideSelectionPopover();
|
||||
}, { passive: true });
|
||||
if (this.chromeBehavior === "always-visible") {
|
||||
this.chromeVisible = true;
|
||||
return;
|
||||
@@ -774,6 +888,237 @@ document.addEventListener("alpine:init", () => {
|
||||
tap_zone_size: this.tapZoneSize,
|
||||
});
|
||||
},
|
||||
// ----- annotations (highlights + notes) -----
|
||||
openSelectionPopover(opts: {
|
||||
mode: "create" | "edit";
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
cfi: string;
|
||||
id?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
const p = this.selectionPopover;
|
||||
p.mode = opts.mode;
|
||||
p.text = opts.text;
|
||||
p.cfi = opts.cfi;
|
||||
p.id = opts.id ?? "";
|
||||
p.color = opts.color || "#ffd54f";
|
||||
p.note = opts.note ?? "";
|
||||
p.noteOpen = !!p.note && opts.mode === "edit";
|
||||
// Clamp so the popover stays on screen (it anchors bottom-center).
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
p.x = Math.min(Math.max(opts.x, 90), w - 90);
|
||||
p.y = Math.min(Math.max(opts.y, 60), h - 60);
|
||||
p.open = true;
|
||||
},
|
||||
hideSelectionPopover() {
|
||||
this.selectionPopover.open = false;
|
||||
this.selectionPopover.noteOpen = false;
|
||||
},
|
||||
renderAllHighlights() {
|
||||
for (const hl of this.highlightItems) {
|
||||
this.view
|
||||
?.addAnnotation({
|
||||
value: hl.cfi,
|
||||
color: hl.color,
|
||||
note: hl.note,
|
||||
id: hl.id,
|
||||
})
|
||||
?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
mapHighlightRow(r: any) {
|
||||
return {
|
||||
id: r.id,
|
||||
text: r.selection_text ?? "",
|
||||
note: r.note_text ?? "",
|
||||
color: r.color ?? "#ffff00",
|
||||
cfi: r.epubcfi_start ?? "",
|
||||
percentage: r.percentage_start ?? 0,
|
||||
};
|
||||
},
|
||||
async refreshAnnotations() {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
try {
|
||||
const [hlResp, noteResp] = await Promise.all([
|
||||
fetch(`/api/media-items/${this.mediaItemId}/highlights`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`/api/media-items/${this.mediaItemId}/notes`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (hlResp.ok) {
|
||||
const rows = await hlResp.json();
|
||||
this.highlightItems = (rows as any[])
|
||||
.map((r) => this.mapHighlightRow(r))
|
||||
.filter((hl: any) => hl.cfi);
|
||||
this.renderAllHighlights();
|
||||
}
|
||||
if (noteResp.ok) {
|
||||
const rows = await noteResp.json();
|
||||
this.noteItems = (rows as any[]).map((r) => ({
|
||||
id: r.id,
|
||||
content: r.content ?? "",
|
||||
positionLabel: r.position ?? "",
|
||||
}));
|
||||
}
|
||||
} catch (_e) {
|
||||
/* annotations are non-critical; leave lists as-is */
|
||||
}
|
||||
},
|
||||
async createHighlight(color: string) {
|
||||
const p = this.selectionPopover;
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !p.cfi) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/highlights`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
selection_text: p.text,
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: p.cfi,
|
||||
color,
|
||||
note_text: "",
|
||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const row = await resp.json();
|
||||
this.highlightItems.push(this.mapHighlightRow(row));
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
color,
|
||||
note: "",
|
||||
id: row.id,
|
||||
});
|
||||
this.hideSelectionPopover();
|
||||
} catch (_e) {
|
||||
/* ignore highlight errors */
|
||||
}
|
||||
},
|
||||
async saveHighlightChanges() {
|
||||
const p = this.selectionPopover;
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !p.id) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/highlights/${p.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
selection_text: p.text,
|
||||
start_position: "",
|
||||
end_position: "",
|
||||
epubcfi_start: p.cfi,
|
||||
color: p.color,
|
||||
note_text: p.note,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const row = await resp.json();
|
||||
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
||||
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
||||
// Re-add so the overlay redraws with the new color.
|
||||
this.view?.addAnnotation({
|
||||
value: p.cfi,
|
||||
color: p.color,
|
||||
note: p.note,
|
||||
id: p.id,
|
||||
});
|
||||
p.noteOpen = false;
|
||||
} catch (_e) {
|
||||
/* ignore highlight errors */
|
||||
}
|
||||
},
|
||||
async deleteHighlightById(id: string) {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
const hl = this.highlightItems.find((h) => h.id === id);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/highlights/${id}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) return;
|
||||
this.highlightItems = this.highlightItems.filter((h) => h.id !== id);
|
||||
if (hl?.cfi) this.view?.deleteAnnotation({ value: hl.cfi });
|
||||
this.hideSelectionPopover();
|
||||
} catch (_e) {
|
||||
/* ignore highlight errors */
|
||||
}
|
||||
},
|
||||
async copySelectionText() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.selectionPopover.text);
|
||||
this.hideSelectionPopover();
|
||||
} catch (_e) {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
},
|
||||
goToHighlight(hl: { cfi: string }) {
|
||||
if (!hl.cfi) return;
|
||||
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
||||
this.closeDrawers();
|
||||
},
|
||||
async addNote(content: string) {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId || !content.trim()) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/notes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
position: !this.isFixedLayout
|
||||
? `cfi:${this.view?.lastLocation?.cfi ?? ""}`
|
||||
: `page:${(this.renderer?.index ?? 0) + 1}`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
await this.refreshAnnotations();
|
||||
} catch (_e) {
|
||||
/* ignore note errors */
|
||||
}
|
||||
},
|
||||
async deleteNoteById(id: string) {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/notes/${id}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (resp.ok || resp.status === 204) {
|
||||
this.noteItems = this.noteItems.filter((n) => n.id !== id);
|
||||
}
|
||||
} catch (_e) {
|
||||
/* ignore note errors */
|
||||
}
|
||||
},
|
||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||
if (Date.now() - this.initTime < 5000) return;
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
@@ -1395,7 +1740,9 @@ document.addEventListener("alpine:init", () => {
|
||||
else if (k === "-" || k === "_") this.zoomOut();
|
||||
else if (k === "0") this.resetZoom();
|
||||
else if (k === "Escape") {
|
||||
if (this.anyDrawerOpen()) {
|
||||
if (this.selectionPopover.open) {
|
||||
this.hideSelectionPopover();
|
||||
} else if (this.anyDrawerOpen()) {
|
||||
this.closeDrawers();
|
||||
} else if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
|
||||
this.toggleMagnifier();
|
||||
|
||||
@@ -907,6 +907,90 @@
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Selection popover & annotations drawer widgets */
|
||||
.reader-popover {
|
||||
position: fixed;
|
||||
transform: translate(-50%, calc(-100% - 10px));
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 85%, transparent);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.3);
|
||||
backdrop-filter: blur(16px) saturate(1.3);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
padding: 0.5rem 0.625rem;
|
||||
z-index: 60;
|
||||
}
|
||||
.color-dot {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.color-dot:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.color-dot.selected {
|
||||
border-color: #ffffff;
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
.reader-popover-btn {
|
||||
padding: 0.3rem;
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
}
|
||||
.reader-popover-btn:hover {
|
||||
background-color: color-mix(in srgb, currentColor 13%, transparent);
|
||||
}
|
||||
.reader-popover-btn.danger:hover {
|
||||
background-color: rgba(153, 27, 27, 0.6);
|
||||
}
|
||||
.reader-note-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: inherit;
|
||||
background-color: color-mix(in srgb, var(--bg-primary) 55%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
.reader-note-input:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.reader-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reader-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.reader-tabs button.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.reader-tab-count {
|
||||
display: inline-block;
|
||||
min-width: 1.25rem;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.1rem;
|
||||
background-color: color-mix(in srgb, currentColor 13%, transparent);
|
||||
}
|
||||
.reader-hl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- Series stacked covers ---------- */
|
||||
.stacked-covers {
|
||||
position: relative;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user