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"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
+169
-9
@@ -394,6 +394,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
color: string;
|
color: string;
|
||||||
cfi: string;
|
cfi: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
pdfPage: number;
|
||||||
|
pdfRects: number[][];
|
||||||
}[],
|
}[],
|
||||||
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
noteItems: [] as { id: string; content: string; positionLabel: string }[],
|
||||||
annotationsTab: "highlights" as string,
|
annotationsTab: "highlights" as string,
|
||||||
@@ -410,7 +412,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
color: "#ffd54f",
|
color: "#ffd54f",
|
||||||
note: "",
|
note: "",
|
||||||
noteOpen: false,
|
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: "",
|
progressText: "",
|
||||||
progressLabel: "",
|
progressLabel: "",
|
||||||
progressMain: "",
|
progressMain: "",
|
||||||
@@ -572,6 +579,27 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (this.isPDF) {
|
if (this.isPDF) {
|
||||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
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.renderer.addEventListener("zoom", () => {
|
||||||
this.zoomPercent = this.renderer.zoomPercent;
|
this.zoomPercent = this.renderer.zoomPercent;
|
||||||
});
|
});
|
||||||
@@ -642,6 +670,56 @@ document.addEventListener("alpine:init", () => {
|
|||||||
{ passive: true },
|
{ 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) {
|
if (!this.isFixedLayout) {
|
||||||
this.computeChapterPageBoundaries(doc);
|
this.computeChapterPageBoundaries(doc);
|
||||||
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
||||||
@@ -898,6 +976,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
id?: string;
|
id?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
pdfPage?: number;
|
||||||
|
pdfRects?: number[][];
|
||||||
}) {
|
}) {
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
p.mode = opts.mode;
|
p.mode = opts.mode;
|
||||||
@@ -907,6 +987,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
p.color = opts.color || "#ffd54f";
|
p.color = opts.color || "#ffd54f";
|
||||||
p.note = opts.note ?? "";
|
p.note = opts.note ?? "";
|
||||||
p.noteOpen = !!p.note && opts.mode === "edit";
|
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).
|
// Clamp so the popover stays on screen (it anchors bottom-center).
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
const h = window.innerHeight;
|
const h = window.innerHeight;
|
||||||
@@ -920,6 +1002,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
renderAllHighlights() {
|
renderAllHighlights() {
|
||||||
for (const hl of this.highlightItems) {
|
for (const hl of this.highlightItems) {
|
||||||
|
if (hl.pdfPage >= 0) {
|
||||||
|
this.renderer?.addRectAnnotation?.({
|
||||||
|
key: hl.id,
|
||||||
|
index: hl.pdfPage,
|
||||||
|
rects: hl.pdfRects,
|
||||||
|
color: hl.color,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
this.view
|
this.view
|
||||||
?.addAnnotation({
|
?.addAnnotation({
|
||||||
value: hl.cfi,
|
value: hl.cfi,
|
||||||
@@ -929,15 +1019,40 @@ document.addEventListener("alpine:init", () => {
|
|||||||
})
|
})
|
||||||
?.catch?.(() => {});
|
?.catch?.(() => {});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
mapHighlightRow(r: any) {
|
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 {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
text: r.selection_text ?? "",
|
text: r.selection_text ?? "",
|
||||||
note: r.note_text ?? "",
|
note: r.note_text ?? "",
|
||||||
color: r.color ?? "#ffff00",
|
color: r.color ?? "#ffff00",
|
||||||
cfi: r.epubcfi_start ?? "",
|
cfi,
|
||||||
percentage: r.percentage_start ?? 0,
|
percentage: r.percentage_start ?? 0,
|
||||||
|
pdfPage,
|
||||||
|
pdfRects,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async refreshAnnotations() {
|
async refreshAnnotations() {
|
||||||
@@ -956,7 +1071,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const rows = await hlResp.json();
|
const rows = await hlResp.json();
|
||||||
this.highlightItems = (rows as any[])
|
this.highlightItems = (rows as any[])
|
||||||
.map((r) => this.mapHighlightRow(r))
|
.map((r) => this.mapHighlightRow(r))
|
||||||
.filter((hl: any) => hl.cfi);
|
.filter((hl: any) => hl.cfi || hl.pdfPage >= 0);
|
||||||
this.renderAllHighlights();
|
this.renderAllHighlights();
|
||||||
}
|
}
|
||||||
if (noteResp.ok) {
|
if (noteResp.ok) {
|
||||||
@@ -974,7 +1089,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async createHighlight(color: string) {
|
async createHighlight(color: string) {
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
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 {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/media-items/${this.mediaItemId}/highlights`,
|
`/api/media-items/${this.mediaItemId}/highlights`,
|
||||||
@@ -988,7 +1107,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
selection_text: p.text,
|
selection_text: p.text,
|
||||||
start_position: "",
|
start_position: "",
|
||||||
end_position: "",
|
end_position: "",
|
||||||
epubcfi_start: p.cfi,
|
epubcfi_start: p.pdfPage >= 0 ? pdfAnchor : p.cfi,
|
||||||
color,
|
color,
|
||||||
note_text: "",
|
note_text: "",
|
||||||
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
percentage_start: this.lastRelocateDetail?.fraction ?? 0,
|
||||||
@@ -997,13 +1116,29 @@ document.addEventListener("alpine:init", () => {
|
|||||||
);
|
);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const row = await resp.json();
|
const row = await resp.json();
|
||||||
this.highlightItems.push(this.mapHighlightRow(row));
|
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({
|
this.view?.addAnnotation({
|
||||||
value: p.cfi,
|
value: p.cfi,
|
||||||
color,
|
color,
|
||||||
note: "",
|
note: "",
|
||||||
id: row.id,
|
id: row.id,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
@@ -1013,6 +1148,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId || !p.id) return;
|
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 {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`/api/media-items/${this.mediaItemId}/highlights/${p.id}`,
|
`/api/media-items/${this.mediaItemId}/highlights/${p.id}`,
|
||||||
@@ -1026,7 +1165,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
selection_text: p.text,
|
selection_text: p.text,
|
||||||
start_position: "",
|
start_position: "",
|
||||||
end_position: "",
|
end_position: "",
|
||||||
epubcfi_start: p.cfi,
|
epubcfi_start: anchor,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
note_text: p.note,
|
note_text: p.note,
|
||||||
}),
|
}),
|
||||||
@@ -1037,12 +1176,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
||||||
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
||||||
// Re-add so the overlay redraws with the new color.
|
// Re-add so the overlay redraws with the new color.
|
||||||
|
if (p.pdfPage >= 0) {
|
||||||
|
this.renderer?.addRectAnnotation?.({
|
||||||
|
key: p.id,
|
||||||
|
index: p.pdfPage,
|
||||||
|
rects: p.pdfRects,
|
||||||
|
color: p.color,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
this.view?.addAnnotation({
|
this.view?.addAnnotation({
|
||||||
value: p.cfi,
|
value: p.cfi,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
note: p.note,
|
note: p.note,
|
||||||
id: p.id,
|
id: p.id,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
p.noteOpen = false;
|
p.noteOpen = false;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
@@ -1059,7 +1207,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
);
|
);
|
||||||
if (!resp.ok && resp.status !== 204) return;
|
if (!resp.ok && resp.status !== 204) return;
|
||||||
this.highlightItems = this.highlightItems.filter((h) => h.id !== id);
|
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();
|
this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
/* ignore highlight errors */
|
/* ignore highlight errors */
|
||||||
@@ -1073,10 +1225,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
/* clipboard unavailable */
|
/* clipboard unavailable */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
goToHighlight(hl: { cfi: string }) {
|
goToHighlight(hl: {
|
||||||
if (!hl.cfi) return;
|
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.view?.showAnnotation({ value: hl.cfi })?.catch?.(() => {});
|
||||||
this.closeDrawers();
|
this.closeDrawers();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async addNote(content: string) {
|
async addNote(content: string) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
Reference in New Issue
Block a user