From a05b0167ade2f28fd68cfca044c1e9cb2556328e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 16 Aug 2026 12:48:09 -0400 Subject: [PATCH] feat(reader): PDF text highlights via fraction-rect annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- package.json | 2 +- web/src/reader/reader.ts | 220 +++++++++++++++++++++++++++++++++------ 2 files changed, 191 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index 7758937..778fde6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index e41ede6..ce1e89d 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -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 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();