fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design

This commit is contained in:
John O'Keefe committed 2026-10-10 23:19:35 -04:00
1 parent f69e659d94
commit db3b5b3d62
1 file changed
+158 -252
+158 -252
View File
@@ -512,25 +512,14 @@ document.addEventListener("alpine:init", () => {
color: string;
rects: number[][];
original: number[][];
/** The working extent as a live DOM range: handles re-anchor its
* boundaries via caret probes, and rects rebuild from its line
* boxes on every move. */
range: Range;
/** True once finalize handed off to saveHighlightChanges — cancel
* must not restore the original paint over the saved new one. */
committed: boolean;
},
pdfAdjustGrab: null as null | {
rect: number;
edge: "left" | "right" | "top" | "bottom";
/** Snap tokens (page fractions) of the grabbed rect's own line:
* maximal letter runs are atomic (snap to their start/end), each
* punctuation glyph is its own token (both edges grabbable —
* "word." can shed or keep the period). The reflowable
* snapBoundary vocabulary, ported to glyph boxes. */
tokens: { x0: number; x1: number; punct: boolean }[];
/** Line top/bottom bands around the grab (own line + neighbours) —
* the y-snap table for horizontal-edge drags. */
lines: { top: number; bottom: number }[];
/** The rect as it was at grab — restored when a drag is cancelled. */
snapshot: number[];
},
pdfAdjustRaf: 0,
pdfAdjustOnRelocate: null as null | (() => void),
pdfAdjustOnZoom: null as null | (() => void),
@@ -1564,9 +1553,10 @@ document.addEventListener("alpine:init", () => {
}
this.adjustContext = null;
},
// ----- PDF highlight extent adjustment (rect-edge) -----
// ----- PDF highlight extent adjustment (rect-edge, v2: endpoints) -----
/** Format router for the popover's Adjust button: PDF highlights
* adjust by rect edges, reflowable ones by range handles. */
* adjust with two endpoint handles, reflowable ones with range
* handles. */
startAnyAdjust() {
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
else this.startAdjust();
@@ -1612,6 +1602,10 @@ document.addEventListener("alpine:init", () => {
fy: (clientY - top) / height,
};
},
/** Doc-local px for caretRangeFromPoint probes. */
toDoc(fx: number, fy: number) {
return { x: dr.left + fx * dr.width, y: dr.top + fy * dr.height };
},
};
},
/** Character glyph boxes (page fractions) whose center sits inside a
@@ -1677,276 +1671,190 @@ document.addEventListener("alpine:init", () => {
return out;
},
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
* take a working copy of the anchor rects, put edge grips up over
* the page, and arm the cancel paths. */
* reconstruct the extent's range from the stored rects via caret
* probes, put the two endpoint handles up, and arm the cancel
* paths. */
startPdfAdjust() {
const p = this.selectionPopover;
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
const map = this.pdfPageMapping(p.pdfPage);
if (!map) return;
const rects = p.pdfRects;
const first = rects[0];
const last = rects[rects.length - 1];
// Reconstruct the extent's range: carets just inside the first
// rect's left-middle and the last rect's right-middle.
const startPt = map.toDoc(first[0] + 0.002, first[1] + first[3] / 2);
const endPt = map.toDoc(
last[0] + last[2] - 0.002,
last[1] + last[3] / 2,
);
const doc = map.doc;
const sc = doc.caretRangeFromPoint?.(startPt.x, startPt.y) ?? null;
const ec = doc.caretRangeFromPoint?.(endPt.x, endPt.y) ?? null;
if (!sc || !ec) return;
const range = doc.createRange();
range.setStart(sc.startContainer, sc.startOffset);
range.setEnd(ec.startContainer, ec.startOffset);
if (range.collapsed) return;
this.pdfAdjust = {
id: p.id,
index: p.pdfPage,
page: p.pdfPage,
color: p.color,
rects: p.pdfRects.map((r) => [...r]),
rects,
original: p.pdfRects.map((r) => [...r]),
range,
committed: false,
};
this.hideSelectionPopover();
const layer = this.ensureAdjustLayer();
layer.innerHTML = "";
this.renderPdfAdjustGrips();
this.renderPdfAdjustHandles();
// A page turn ends the session safely — the preview is discarded,
// nothing saved (same contract as the reflowable adjust).
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
once: true,
});
// Zoom re-renders the page — re-derive the grips from live rects.
this.pdfAdjustOnZoom = () => this.renderPdfAdjustGrips();
// Zoom re-renders the page — re-derive the handles from live rects.
this.pdfAdjustOnZoom = () => this.renderPdfAdjustHandles();
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
},
/** (Re)draw the edge grips: one per edge of every working rect,
* positioned through the live page mapping. Reuses existing grip
* elements when the set matches so drag-capture survives a repaint. */
renderPdfAdjustGrips() {
/** (Re)draw the two endpoint handles, positioned through the live
* page mapping. Reuses the elements so drag-capture survives a
* repaint. */
renderPdfAdjustHandles() {
const ctx = this.pdfAdjust;
if (!ctx) return;
const layer = document.getElementById("adjust-handles");
if (!layer) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const wanted: { rect: number; edge: string; dir: string }[] = [];
ctx.rects.forEach((_r, ri) => {
wanted.push(
{ rect: ri, edge: "left", dir: "v" },
{ rect: ri, edge: "right", dir: "v" },
{ rect: ri, edge: "top", dir: "h" },
{ rect: ri, edge: "bottom", dir: "h" },
);
});
const existing = Array.from(
layer.querySelectorAll<HTMLDivElement>(".adjust-grip"),
);
while (existing.length > wanted.length) {
existing.pop()?.remove();
layer.innerHTML = "";
const first = ctx.rects[0];
const last = ctx.rects[ctx.rects.length - 1];
if (!first || !last) return;
// Start dot at the first rect's LEFT-MIDDLE, end dot at the last
// rect's RIGHT-MIDDLE — the range boundary points.
const pts: Array<{ which: string; fx: number; fy: number }> = [
{ which: "start", fx: first[0], fy: first[1] + first[3] / 2 },
{ which: "end", fx: last[0] + last[2], fy: last[1] + last[3] / 2 },
];
for (const pt of pts) {
const host = map.toHost(pt.fx, pt.fy);
const dot = document.createElement("div");
dot.className = "adjust-handle";
dot.dataset.which = pt.which;
dot.style.left = `${host.x - 13}px`;
dot.style.top = `${host.y - 13}px`;
// Pointer events (mouse + touch + pen); the page's own touch
// handlers are not engaged because the dots live in the HOST
// document, above the iframe.
dot.addEventListener("pointerdown", (e) => {
e.preventDefault();
try {
dot.setPointerCapture(e.pointerId);
} catch {}
dot.dataset.dragging = "1";
});
dot.addEventListener("pointermove", (e) => {
if (!dot.dataset.dragging) return;
this.pdfAdjustHandleMove(
pt.which,
e.clientX,
e.clientY,
);
});
dot.addEventListener("pointerup", (e) => {
if (!dot.dataset.dragging) return;
delete dot.dataset.dragging;
try {
dot.releasePointerCapture?.(e.pointerId);
} catch {}
this.pdfAdjustRelease();
});
dot.addEventListener("pointercancel", () => {
delete dot.dataset.dragging;
});
layer.appendChild(dot);
}
wanted.forEach((w, i) => {
const r = ctx.rects[w.rect];
if (!r) return;
// Grip centered on the edge midpoint.
const fx =
w.edge === "left" ? r[0] : w.edge === "right" ? r[0] + r[2] : r[0] + r[2] / 2;
const fy =
w.edge === "top" ? r[1] : w.edge === "bottom" ? r[1] + r[3] : r[1] + r[3] / 2;
const pt = map.toHost(fx, fy);
let g = existing[i];
if (!g) {
g = document.createElement("div");
g.addEventListener("pointerdown", (e) => {
e.preventDefault();
// Capture keeps real drags alive when the finger slides off
// the small grip; synthetic test pointers have no active
// pointer to capture — ignore that failure.
try {
g!.setPointerCapture(e.pointerId);
} catch {}
g!.dataset.dragging = "1";
this.pdfAdjustGrabStart(
Number(g!.dataset.rect),
g!.dataset.edge as "left" | "right" | "top" | "bottom",
);
});
g.addEventListener("pointermove", (e) => {
if (!g!.dataset.dragging) return;
this.pdfAdjustGrabMove(e.clientX, e.clientY);
});
g.addEventListener("pointerup", (e) => {
if (!g!.dataset.dragging) return;
delete g!.dataset.dragging;
try {
g!.releasePointerCapture?.(e.pointerId);
} catch {}
this.pdfAdjustRelease();
});
g.addEventListener("pointercancel", () => {
if (!g!.dataset.dragging) return;
delete g!.dataset.dragging;
this.pdfAdjustAbortGrab();
});
layer.appendChild(g);
}
g.className = "adjust-grip";
g.dataset.dir = w.dir;
g.dataset.rect = String(w.rect);
g.dataset.edge = w.edge;
// 10px wide bar, 30px long (square when horizontal) — centered.
g.style.left = `${pt.x - (w.dir === "v" ? 5 : 15)}px`;
g.style.top = `${pt.y - (w.dir === "v" ? 15 : 5)}px`;
});
},
/** Grab an edge: cache the snap tables for that rect (own-line tokens
* for x-edges, band line tops/bottoms for y-edges) and snapshot the
* rect for drag-cancel. */
pdfAdjustGrabStart(
ri: number,
edge: "left" | "right" | "top" | "bottom",
/** Endpoint drag: the finger (host px) re-anchors the grabbed end at
* the caret under it — character-precise (a trailing period or
* line-break hyphen can be taken or shed), crossing lines freely —
* and the rects rebuild from the range's own line boxes. */
pdfAdjustHandleMove(
which: "start" | "end",
clientX: number,
clientY: number,
) {
const ctx = this.pdfAdjust;
if (!ctx) return;
const map = this.pdfPageMapping(ctx.index);
const r = ctx.rects[ri];
if (!map || !r) return;
const vertical = edge === "left" || edge === "right";
// Band: the rect's y-range widened to catch neighbouring line
// 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);
// x-snap table: the grabbed rect's own line only, clustered into
// punctuation-split word tokens (adjacent letter glyphs merge; each
// punctuation glyph stands alone).
const tokens: { x0: number; x1: number; punct: boolean }[] = [];
if (vertical) {
const isWordChar = (ch: string) => /[\p{L}\p{N}]/u.test(ch);
const lineChars = band
.filter((c) => {
const cy = (c.y0 + c.y1) / 2;
return cy >= r[1] - 0.002 && cy <= r[1] + r[3] + 0.002;
})
.sort((a, b) => a.x0 - b.x0);
const medW = lineChars.length
? lineChars.map((c) => c.x1 - c.x0).sort((a, b) => a - b)[
lineChars.length >> 1
] || 0.001
: 0.001;
for (const c of lineChars) {
const punct = !isWordChar(c.ch);
const prev = tokens[tokens.length - 1];
if (
prev &&
!prev.punct &&
!punct &&
c.x0 - prev.x1 <= medW * 0.45
) {
// Adjacent letter glyphs: grow the current word token.
prev.x1 = Math.max(prev.x1, c.x1);
} else {
tokens.push({ x0: c.x0, x1: c.x1, punct });
}
}
}
// y-snap table: cluster band chars into line top/bottom pairs.
const lines: { top: number; bottom: number }[] = [];
if (!vertical) {
for (const c of band) {
const cy = (c.y0 + c.y1) / 2;
const ln = lines.find((l) => l.top <= cy && cy <= l.bottom);
if (ln) {
ln.top = Math.min(ln.top, c.y0);
ln.bottom = Math.max(ln.bottom, c.y1);
} else lines.push({ top: c.y0, bottom: c.y1 });
}
}
this.pdfAdjustGrab = {
rect: ri,
edge,
tokens,
lines,
snapshot: [...r],
};
},
/** Drag move: map the finger into page fractions and snap the moving
* edge with the reflowable adjust's semantics — a start (left/top)
* edge inside a word snaps to that word's START, an end edge to its
* END; punctuation takes the exact glyph edge; whitespace between
* tokens trims toward the adjacent word. Repaints the preview. */
pdfAdjustGrabMove(clientX: number, clientY: number) {
const grab = this.pdfAdjustGrab;
const ctx = this.pdfAdjust;
if (!grab || !ctx) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const { fx, fy } = map.toFraction(clientX, clientY);
const r = ctx.rects[grab.rect];
if (!r) return;
const MIN = 0.002; // never let a drag collapse a rect away
if (grab.edge === "left" || grab.edge === "right") {
const isStart = grab.edge === "left";
let best: number | null = null;
let bestD = Infinity;
const consider = (cand: number) => {
const d = Math.abs(cand - fx);
if (d < bestD) {
bestD = d;
best = cand;
}
};
for (const t of grab.tokens) {
if (fx < t.x0) {
// Finger left of this token: a start edge in the gap trims
// toward the NEXT token's start; an end edge ignores it.
if (isStart) consider(t.x0);
} else if (fx > t.x1) {
// Finger right of this token: an end edge in the gap trims
// toward the PREVIOUS token's end.
if (!isStart) consider(t.x1);
} else if (t.punct) {
// Punctuation precision: the exact glyph edge, either side.
consider(fx - t.x0 <= t.x1 - fx ? t.x0 : t.x1);
} else {
// Inside a word: atomic — start takes the word start, end
// the word end.
consider(isStart ? t.x0 : t.x1);
}
}
if (best === null) return;
if (isStart) {
// A start-edge drag pins the FAR edge: shrink the size by the
// same amount the left moves (keeping the size would drag the
// rect's right edge along — found by device test).
const far = r[0] + r[2];
r[0] = Math.min(Math.max(best, 0), far - MIN);
r[2] = far - r[0];
const pt = map.toDoc(fx, fy);
const caret = map.doc.caretRangeFromPoint?.(pt.x, pt.y) ?? null;
if (!caret) return;
const working = map.doc.createRange();
try {
if (which === "start") {
working.setStart(caret.startContainer, caret.startOffset);
working.setEnd(ctx.range.endContainer, ctx.range.endOffset);
} else {
r[2] = Math.min(Math.max(best - r[0], MIN), 1 - r[0]);
working.setStart(ctx.range.startContainer, ctx.range.startOffset);
working.setEnd(caret.startContainer, caret.startOffset);
}
} else {
let best = fy;
let bestD = Infinity;
for (const ln of grab.lines) {
for (const by of [ln.top, ln.bottom]) {
const d = Math.abs(by - fy);
if (d < bestD) {
bestD = d;
best = by;
}
}
}
if (grab.edge === "top") {
// Mirror of the left edge: a top-edge drag pins the bottom.
const bottom = r[1] + r[3];
r[1] = Math.min(Math.max(best, 0), bottom - MIN);
r[3] = bottom - r[1];
} else {
r[3] = Math.min(Math.max(best - r[1], MIN), 1 - r[1]);
} catch {
return;
}
// Handles cannot cross: clamp the moving end at the opposite
// boundary.
if (working.collapsed) {
try {
if (which === "start")
working.setStart(ctx.range.endContainer, ctx.range.endOffset);
else working.setEnd(ctx.range.startContainer, ctx.range.startOffset);
} catch {
return;
}
}
ctx.range = working;
ctx.rects = this.rectsForRange(map.doc, working);
this.schedulePdfAdjustPreview();
},
/** Drop a cancelled drag (pointercancel): restore the snapshot. */
pdfAdjustAbortGrab() {
const grab = this.pdfAdjustGrab;
const ctx = this.pdfAdjust;
if (!grab || !ctx) return;
const r = ctx.rects[grab.rect];
if (r) Object.assign(r, grab.snapshot);
this.pdfAdjustGrab = null;
this.schedulePdfAdjustPreview();
/** Line boxes of a range (page fractions), duplicate copy boxes
* collapsed: pdf.js re-render span sets duplicate glyphs at
* sub-pixel shifts, so a naive getClientRects sweep returns the
* same line twice — harmless to paint, noise in the payload. */
rectsForRange(doc: Document, range: Range): number[][] {
const canvas =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvas?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) return [];
const out: number[][] = [];
for (const r of Array.from(range.getClientRects())) {
const box = [
(r.left - dr.left) / dr.width,
(r.top - dr.top) / dr.height,
r.width / dr.width,
r.height / dr.height,
];
if (box[2] <= 0 || box[3] <= 0) continue;
const cx = box[0] + box[2] / 2;
const cy = box[1] + box[3] / 2;
const dup = out.some(
(k) =>
cx >= k[0] - 0.003 &&
cx <= k[0] + k[2] + 0.003 &&
cy >= k[1] - 0.003 &&
cy <= k[1] + k[3] + 0.003,
);
if (!dup) out.push(box);
}
return out;
},
/** Preview scheduling: coalesce pointermove bursts into one overlay
* repaint per frame. The preview replaces the highlight's OWN
@@ -1964,15 +1872,13 @@ document.addEventListener("alpine:init", () => {
rects: ctx.rects,
color: ctx.color,
});
this.renderPdfAdjustGrips();
this.renderPdfAdjustHandles();
});
},
/** Release: recompute the anchor + selection text from the working
* rects, save through the popover's own PUT path, clean up. */
/** Release: the range IS the extent — serialize its rects, recompute
* the selection text from the deduped glyph boxes, and save through
* the popover's own PUT path. */
async pdfAdjustRelease() {
const grab = this.pdfAdjustGrab;
if (!grab) return;
this.pdfAdjustGrab = null;
const ctx = this.pdfAdjust;
if (!ctx) return;
const map = this.pdfPageMapping(ctx.index);
@@ -2040,7 +1946,6 @@ document.addEventListener("alpine:init", () => {
});
}
this.pdfAdjust = null;
this.pdfAdjustGrab = null;
if (this.pdfAdjustRaf) {
cancelAnimationFrame(this.pdfAdjustRaf);
this.pdfAdjustRaf = 0;
@@ -2058,6 +1963,7 @@ document.addEventListener("alpine:init", () => {
}
document.getElementById("adjust-handles")?.remove();
},
scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {