feat(reader): PDF text highlights via fraction-rect annotations

Phase 3b of the reader redesign — highlighting for fixed-layout PDFs:

- Select text on a PDF page → same glass popover as EPUBs (colors,
  note, copy). The selection's client rects are normalized to
  page-fraction quads using a transform-inclusive denominator so
  pdf.js's devicePixelRatio scaling on <html> cancels out, then
  stored as a JSON anchor {page, rects} in epubcfi_start.
- Rendering goes through the fork's new rect-annotation pipeline
  (foliate-js aba68d8): a full-bleed viewBox-0-100 SVG inside the
  page iframe, so highlights stay aligned through pan/zoom, iframe
  CSS-scaling, and PDF hi-res re-renders with zero re-anchoring.
  Frames carry their page index and re-render annotations when
  recreated on spread changes.
- Clicking an existing highlight hit-tests in fraction space and
  opens the edit popover (recolor, note, copy, delete) at the
  host-space click position; drag-selecting text never triggers it.
- Annotations drawer: PDF highlights jump by page index; notes and
  recolors round-trip through the same LWW/dedup sync path as EPUBs
  (same dedup key derivation on the JSON anchor).
- Comics keep bookmark-only highlighting (no text layer) by design.
This commit is contained in:
2026-08-16 12:48:09 -04:00
parent 40d70513da
commit a05b0167ad
2 changed files with 191 additions and 31 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e9e61d8",
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#aba68d8",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"highlight.js": "^11.11.1",
+190 -30
View File
@@ -394,6 +394,8 @@ document.addEventListener("alpine:init", () => {
color: string;
cfi: string;
percentage: number;
pdfPage: number;
pdfRects: number[][];
}[],
noteItems: [] as { id: string; content: string; positionLabel: string }[],
annotationsTab: "highlights" as string,
@@ -410,7 +412,12 @@ document.addEventListener("alpine:init", () => {
color: "#ffd54f",
note: "",
noteOpen: false,
// PDF/fixed-layout anchor: page index + page-fraction rects
// (empty for CFI-anchored reflowable highlights).
pdfPage: -1,
pdfRects: [] as number[][],
},
pdfSelDoc: null as any,
progressText: "",
progressLabel: "",
progressMain: "",
@@ -572,6 +579,27 @@ document.addEventListener("alpine:init", () => {
if (this.isPDF) {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
// Click on a PDF/fixed-layout highlight rect → edit popover.
this.renderer.addEventListener(
"show-rect-annotation",
(e: any) => {
const { key, clientX, clientY } = e.detail;
const h = this.highlightItems.find((x) => x.id === key);
if (!h) return;
this.openSelectionPopover({
mode: "edit",
x: clientX,
y: clientY,
text: h.text,
cfi: "",
id: h.id,
color: h.color,
note: h.note,
pdfPage: h.pdfPage,
pdfRects: h.pdfRects,
});
},
);
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
});
@@ -642,6 +670,56 @@ document.addEventListener("alpine:init", () => {
{ passive: true },
);
}
// PDF textLayer selection → rect-fraction highlight popover.
if (this.isPDF) {
const checkPDFSelection = () => {
const sel = doc.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Denominator in the same (transform-inclusive) coordinate
// space as getClientRects so the devicePixelRatio transform
// pdf.js applies to <html> cancels in the fraction.
const denom =
(doc.querySelector("img") as HTMLElement) ||
doc.documentElement;
const dr = denom.getBoundingClientRect();
if (!dr.width || !dr.height) return;
const rects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) rects.push([x, y, w, h]);
}
if (!rects.length) return;
// Map the first rect to host-space for popover placement,
// accounting for the iframe's own scale factor.
const frame = doc.defaultView?.frameElement as HTMLElement | null;
if (!frame) return;
const fr = frame.getBoundingClientRect();
const first = range.getBoundingClientRect();
const sx = fr.width / dr.width;
const sy = fr.height / dr.height;
this.pdfSelDoc = doc;
this.openSelectionPopover({
mode: "create",
x: fr.left + first.left * sx + (first.width * sx) / 2,
y: fr.top + first.top * sy,
text,
cfi: "",
pdfPage: index,
pdfRects: rects,
});
};
doc.addEventListener(
"pointerup",
() => setTimeout(checkPDFSelection, 0),
{ passive: true },
);
}
if (!this.isFixedLayout) {
this.computeChapterPageBoundaries(doc);
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
@@ -898,6 +976,8 @@ document.addEventListener("alpine:init", () => {
id?: string;
color?: string;
note?: string;
pdfPage?: number;
pdfRects?: number[][];
}) {
const p = this.selectionPopover;
p.mode = opts.mode;
@@ -907,6 +987,8 @@ document.addEventListener("alpine:init", () => {
p.color = opts.color || "#ffd54f";
p.note = opts.note ?? "";
p.noteOpen = !!p.note && opts.mode === "edit";
p.pdfPage = opts.pdfPage ?? -1;
p.pdfRects = opts.pdfRects ?? [];
// Clamp so the popover stays on screen (it anchors bottom-center).
const w = window.innerWidth;
const h = window.innerHeight;
@@ -920,24 +1002,57 @@ document.addEventListener("alpine:init", () => {
},
renderAllHighlights() {
for (const hl of this.highlightItems) {
this.view
?.addAnnotation({
value: hl.cfi,
if (hl.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({
key: hl.id,
index: hl.pdfPage,
rects: hl.pdfRects,
color: hl.color,
note: hl.note,
id: hl.id,
})
?.catch?.(() => {});
});
} else {
this.view
?.addAnnotation({
value: hl.cfi,
color: hl.color,
note: hl.note,
id: hl.id,
})
?.catch?.(() => {});
}
}
},
mapHighlightRow(r: any) {
let cfi = r.epubcfi_start ?? "";
let pdfPage = -1;
let pdfRects: number[][] = [];
// Fixed-layout anchors are stored as a JSON descriptor in the CFI
// column (page index + page-fraction rects).
if (cfi.startsWith("{")) {
try {
const a = JSON.parse(cfi);
if (
a &&
typeof a.page === "number" &&
Array.isArray(a.rects) &&
a.rects.length
) {
pdfPage = a.page;
pdfRects = a.rects;
cfi = "";
}
} catch {
/* not ours; leave as-is */
}
}
return {
id: r.id,
text: r.selection_text ?? "",
note: r.note_text ?? "",
color: r.color ?? "#ffff00",
cfi: r.epubcfi_start ?? "",
cfi,
percentage: r.percentage_start ?? 0,
pdfPage,
pdfRects,
};
},
async refreshAnnotations() {
@@ -956,7 +1071,7 @@ document.addEventListener("alpine:init", () => {
const rows = await hlResp.json();
this.highlightItems = (rows as any[])
.map((r) => this.mapHighlightRow(r))
.filter((hl: any) => hl.cfi);
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0);
this.renderAllHighlights();
}
if (noteResp.ok) {
@@ -974,7 +1089,11 @@ document.addEventListener("alpine:init", () => {
async createHighlight(color: string) {
const p = this.selectionPopover;
const token = getToken();
if (!token || !this.mediaItemId || !p.cfi) return;
if (!token || !this.mediaItemId || (!p.cfi && p.pdfPage < 0)) return;
const pdfAnchor =
p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
: "";
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/highlights`,
@@ -988,7 +1107,7 @@ document.addEventListener("alpine:init", () => {
selection_text: p.text,
start_position: "",
end_position: "",
epubcfi_start: p.cfi,
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
color,
note_text: "",
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
@@ -997,13 +1116,29 @@ document.addEventListener("alpine:init", () => {
);
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,
});
const hl = this.mapHighlightRow(row);
this.highlightItems.push(hl);
if (hl.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({
key: hl.id,
index: hl.pdfPage,
rects: hl.pdfRects,
color,
});
// Clear the textLayer selection so the highlight reads cleanly.
try {
this.pdfSelDoc?.getSelection()?.removeAllRanges();
} catch {
/* doc may be gone */
}
} else {
this.view?.addAnnotation({
value: p.cfi,
color,
note: "",
id: row.id,
});
}
this.hideSelectionPopover();
} catch (_e) {
/* ignore highlight errors */
@@ -1013,6 +1148,10 @@ document.addEventListener("alpine:init", () => {
const p = this.selectionPopover;
const token = getToken();
if (!token || !this.mediaItemId || !p.id) return;
const anchor =
p.pdfPage >= 0
? JSON.stringify({ v: 1, page: p.pdfPage, rects: p.pdfRects })
: p.cfi;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/highlights/${p.id}`,
@@ -1026,7 +1165,7 @@ document.addEventListener("alpine:init", () => {
selection_text: p.text,
start_position: "",
end_position: "",
epubcfi_start: p.cfi,
epubcfi_start: anchor,
color: p.color,
note_text: p.note,
}),
@@ -1037,12 +1176,21 @@ document.addEventListener("alpine:init", () => {
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,
});
if (p.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({
key: p.id,
index: p.pdfPage,
rects: p.pdfRects,
color: p.color,
});
} else {
this.view?.addAnnotation({
value: p.cfi,
color: p.color,
note: p.note,
id: p.id,
});
}
p.noteOpen = false;
} catch (_e) {
/* ignore highlight errors */
@@ -1059,7 +1207,11 @@ document.addEventListener("alpine:init", () => {
);
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 });
if (hl?.pdfPage >= 0) {
this.renderer?.removeRectAnnotation?.(id);
} else if (hl?.cfi) {
this.view?.deleteAnnotation({ value: hl.cfi });
}
this.hideSelectionPopover();
} catch (_e) {
/* ignore highlight errors */
@@ -1073,10 +1225,18 @@ document.addEventListener("alpine:init", () => {
/* clipboard unavailable */
}
},
goToHighlight(hl: { cfi: string }) {
if (!hl.cfi) return;
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
this.closeDrawers();
goToHighlight(hl: {
cfi: string;
pdfPage: number;
}) {
if (hl.pdfPage >= 0) {
// Fixed-layout: a bare number navigates to the section (page) index.
this.view?.goTo?.(hl.pdfPage);
this.closeDrawers();
} else if (hl.cfi) {
this.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
this.closeDrawers();
}
},
async addNote(content: string) {
const token = getToken();