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:
+1
-1
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user