feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap

The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
This commit is contained in:
John O'Keefe committed 2026-10-10 11:22:09 -04:00
1 parent 679e0f9116
commit 4d1fb05e49
5 files changed
+546 -4

No files matched your search

+520 -1
View File
@@ -24,6 +24,12 @@ const HIGHLIGHT_COLORS = [
// Alpine reactivity never wraps it and revisiting pages is instant.
const thumbCache = new Map<number, HTMLCanvasElement>();
// Fixed-layout page iframe → its page index. The fixed-layout renderer's
// getContents() returns {doc} WITHOUT an index (unlike the reflowable
// renderer), so the rect-edge adjust maps a page index back to its iframe
// doc through this registry, filled by the view's load events.
const pdfFrameDocs = new WeakMap<object, number>();
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
const FONT_MAP: Record<string, string> = {
@@ -496,6 +502,38 @@ document.addEventListener("alpine:init", () => {
color: string;
},
adjustCancelOnRelocate: null as null | (() => void),
/** PDF rect-edge adjust session (fixed-layout highlights): the working
* copy of the highlight's rects (page fractions) plus the original
* for cancel. Null outside adjust mode. */
pdfAdjust: null as null | {
id: string;
index: number;
page: number;
color: string;
rects: number[][];
original: number[][];
/** 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),
pdfSelDoc: null as any,
progressText: "",
progressLabel: "",
@@ -691,7 +729,10 @@ document.addEventListener("alpine:init", () => {
"show-rect-annotation",
(e: any) => {
const { key, clientX, clientY } = e.detail;
const h = this.highlightItems.find((x) => x.id === key);
// A tap anywhere on the page during rect adjust must not pop
// the editor mid-session (page turn is the cancel gesture).
if (this.pdfAdjust) return;
const h = this.highlightItems.find((x: any) => x.id === key);
if (!h) return;
this.openSelectionPopover({
mode: "edit",
@@ -718,6 +759,8 @@ document.addEventListener("alpine:init", () => {
}
this.view.addEventListener("load", (e: any) => {
const { doc, index } = e.detail;
if (doc && typeof index === "number")
pdfFrameDocs.set(doc, index);
this.currentDoc = doc;
const link = doc.createElement("link");
link.rel = "stylesheet";
@@ -1521,6 +1564,482 @@ document.addEventListener("alpine:init", () => {
}
this.adjustContext = null;
},
// ----- PDF highlight extent adjustment (rect-edge) -----
/** Format router for the popover's Adjust button: PDF highlights
* adjust by rect edges, reflowable ones by range handles. */
startAnyAdjust() {
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
else this.startAdjust();
},
/** Page→host mapping for a fixed-layout index. The canvas's
* post-transform rect IS the visible page (documentElement is dpr×
* too small — pdf.js scales <html> by 1/devicePixelRatio); iframe
* client px map to host px by the frame's own rect ratios (the
* fork's convertPoint math). Computed fresh per call so zoom/pan
* mid-session never works from stale geometry. */
pdfPageMapping(index: number) {
// The fixed-layout renderer's getContents() carries no index — map
// through the load-event registry, keeping only live frames.
const contents = this.renderer?.getContents?.() ?? [];
const doc = contents
.map((c: any) => c.doc)
.find(
(d: any) =>
d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement,
);
if (!doc) return null;
const canvas =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvas?.getBoundingClientRect();
const frame = doc.defaultView?.frameElement as HTMLElement | null;
const fr = frame?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height || !fr) return null;
const scaleX = fr.width / (doc.documentElement.clientWidth || 1);
const scaleY = fr.height / (doc.documentElement.clientHeight || 1);
const left = fr.left + dr.left * scaleX;
const top = fr.top + dr.top * scaleY;
const width = dr.width * scaleX;
const height = dr.height * scaleY;
return {
doc: doc as Document,
toHost(fx: number, fy: number) {
return { x: left + fx * width, y: top + fy * height };
},
toFraction(clientX: number, clientY: number) {
return {
fx: (clientX - left) / width,
fy: (clientY - top) / height,
};
},
};
},
/** Character glyph boxes (page fractions) whose center sits inside a
* y-band of the text layer, reading order. One Range per character
* over each span's text node — caret-granularity snap targets, the
* same vocabulary the reflowable adjust snaps with. Spans outside
* the band are prefiltered by their own box to keep the per-char
* range work line-sized. */
pdfCharBoxes(
doc: Document,
bandTop: number,
bandBottom: number,
): { x0: number; x1: number; y0: number; y1: number; ch: string }[] {
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: { x0: number; x1: number; y0: number; y1: number; ch: string }[] =
[];
// pdf.js re-renders (zoom settle, scale updates) append a second
// span set into the same .textLayer without clearing — char boxes
// come back duplicated at identical positions. Dedupe by box key so
// snap tables and recomputed text see each glyph once.
const seen = new Set<string>();
const range = doc.createRange();
const spans = doc.querySelectorAll(".textLayer span");
spans.forEach((span) => {
const sr = span.getBoundingClientRect();
const sTop = (sr.top - dr.top) / dr.height;
const sBottom = (sr.bottom - dr.top) / dr.height;
if (sBottom < bandTop || sTop > bandBottom) return;
const node = span.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.nodeValue ?? "";
for (let i = 0; i < text.length; i++) {
if (/\s/.test(text[i])) continue;
range.setStart(node, i);
range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
if (!r.width && !r.height) continue;
const x0 = (r.left - dr.left) / dr.width;
const x1 = (r.right - dr.left) / dr.width;
const y0 = (r.top - dr.top) / dr.height;
const y1 = (r.bottom - dr.top) / dr.height;
const cy = (y0 + y1) / 2;
if (cy < bandTop || cy > bandBottom) continue;
// 3 decimals ≈ sub-pixel tolerance: distinct glyphs are never
// this close, but re-render copies jitter less than this.
const key = `${x0.toFixed(3)}:${y0.toFixed(3)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ x0, x1, y0, y1, ch: text[i] });
}
});
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
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. */
startPdfAdjust() {
const p = this.selectionPopover;
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
this.pdfAdjust = {
id: p.id,
index: p.pdfPage,
page: p.pdfPage,
color: p.color,
rects: p.pdfRects.map((r) => [...r]),
original: p.pdfRects.map((r) => [...r]),
committed: false,
};
this.hideSelectionPopover();
const layer = this.ensureAdjustLayer();
layer.innerHTML = "";
this.renderPdfAdjustGrips();
// 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();
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() {
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();
}
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",
) {
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 (horizontal-edge drags snap to line tops/bottoms).
const bandPad = vertical ? 0.002 : Math.max(r[3] * 0.75, 0.008);
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) {
r[0] = Math.min(Math.max(best, 0), r[0] + r[2] - MIN);
} else {
r[2] = Math.min(Math.max(best - r[0], MIN), 1 - r[0]);
}
} 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") {
r[1] = Math.min(Math.max(best, 0), r[1] + r[3] - MIN);
} else {
r[3] = Math.min(Math.max(best - r[1], MIN), 1 - r[1]);
}
}
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();
},
/** Preview scheduling: coalesce pointermove bursts into one overlay
* repaint per frame. The preview replaces the highlight's OWN
* overlay entry (addRectAnnotation is replace-by-key), so there is
* never a double paint and the save re-adds the same key. */
schedulePdfAdjustPreview() {
if (this.pdfAdjustRaf) return;
this.pdfAdjustRaf = requestAnimationFrame(() => {
this.pdfAdjustRaf = 0;
const ctx = this.pdfAdjust;
if (!ctx) return;
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.rects,
color: ctx.color,
});
this.renderPdfAdjustGrips();
});
},
/** Release: recompute the anchor + selection text from the working
* rects, save through the popover's own PUT path, clean up. */
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);
const text = map ? this.pdfTextForRects(map.doc, ctx.rects) : "";
const { id, page, rects } = ctx;
ctx.committed = true;
this.cancelPdfAdjust();
if (!text) {
showToast("Highlights can't be empty", "error");
return;
}
// Route through the popover state: saveHighlightChanges serializes
// the anchor from p.pdfRects, PUTs, and re-renders the overlay.
const p = this.selectionPopover;
p.id = id;
p.pdfPage = page;
p.pdfRects = rects;
p.text = text;
await this.saveHighlightChanges();
},
/** Reading-order text of the characters whose glyph boxes sit inside
* any working rect — the adjusted extent's selection text. Lines join
* with a space and in-line word gaps re-insert the space, matching
* the creation path's normalized selection text. */
pdfTextForRects(doc: Document, rects: number[][]) {
const top = Math.min(...rects.map((r) => r[1]));
const bottom = Math.max(...rects.map((r) => r[1] + r[3]));
const chars = this.pdfCharBoxes(doc, top, bottom).filter((c) => {
const cy = (c.y0 + c.y1) / 2;
const cx = (c.x0 + c.x1) / 2;
return rects.some(
(r) =>
cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3],
);
});
if (!chars.length) return "";
const widths = chars.map((c) => c.x1 - c.x0).sort((a, b) => a - b);
const medW = widths[widths.length >> 1] || 0.001;
let text = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars) {
if (prev) {
const pc = (prev.y0 + prev.y1) / 2;
const cc = (c.y0 + c.y1) / 2;
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
if (Math.abs(cc - pc) > half) text += " ";
else if (c.x0 - prev.x1 > medW * 0.3) text += " ";
}
text += c.ch;
prev = c;
}
return text.replace(/\s+/g, " ").trim();
},
/** Leave rect adjust: restore the stored paint unless the adjustment
* was committed (cancel runs after finalize too), and drop the mode. */
cancelPdfAdjust() {
const ctx = this.pdfAdjust;
if (!ctx) return;
if (!ctx.committed) {
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.original,
color: ctx.color,
});
}
this.pdfAdjust = null;
this.pdfAdjustGrab = null;
if (this.pdfAdjustRaf) {
cancelAnimationFrame(this.pdfAdjustRaf);
this.pdfAdjustRaf = 0;
}
if (this.pdfAdjustOnRelocate) {
this.renderer?.removeEventListener?.(
"relocate",
this.pdfAdjustOnRelocate,
);
this.pdfAdjustOnRelocate = null;
}
if (this.pdfAdjustOnZoom) {
this.renderer?.removeEventListener?.("zoom", this.pdfAdjustOnZoom);
this.pdfAdjustOnZoom = null;
}
document.getElementById("adjust-handles")?.remove();
},
scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => {