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
+349 -2
View File
@@ -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();
+84
View File
@@ -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;
+1 -1
View File
File diff suppressed because one or more lines are too long