feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp

Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
This commit is contained in:
John O'Keefe committed 2026-10-10 18:25:29 -04:00
1 parent dbeefb395c
commit f69e659d94
1 file changed
+47 -2
+47 -2
View File
@@ -1798,8 +1798,12 @@ document.addEventListener("alpine:init", () => {
if (!map || !r) return;
const vertical = edge === "left" || edge === "right";
// Band: the rect's y-range widened to catch neighbouring line
// boundaries (horizontal-edge drags snap to line tops/bottoms).
const bandPad = vertical ? 0.002 : Math.max(r[3] * 0.75, 0.008);
// boundaries — but only ±quarter height now: a vertical drag adjusts
// THIS line box's height and can no longer swallow a neighbouring
// line (that produced sliver rects whose text recompute swept
// fragments — "some 14ng that scholars…"). Multi-line changes go
// through re-selection below.
const bandPad = vertical ? 0.002 : Math.max(r[3] * 0.25, 0.004);
const bandTop = r[1] - bandPad;
const bandBottom = r[1] + r[3] + bandPad;
const band = this.pdfCharBoxes(map.doc, bandTop, bandBottom);
@@ -2150,6 +2154,47 @@ document.addEventListener("alpine:init", () => {
const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return;
// Adjust re-selection (v2): a selection made during a rect-edge
// session REPLACES the working extent — any lines, partial lines
// (the exact gap the grips could not express). Rects rebuild from
// the selection's line boxes, text re-reads, saved immediately:
// the grips' release-to-save parity.
if (this.pdfAdjust) {
const canvasRect =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvasRect?.getBoundingClientRect();
if (!dr || !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;
const t = this.pdfTextForRects(doc, rects);
if (!t) {
showToast("Highlights can't be empty", "error");
return;
}
const session = this.pdfAdjust;
session.committed = true;
this.cancelPdfAdjust();
const p = this.selectionPopover;
p.id = session.id;
p.pdfPage = session.page;
p.pdfRects = rects;
p.text = t;
this.saveHighlightChanges();
try {
sel.removeAllRanges();
} catch {
/* doc may be gone */
}
return;
}
// Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect