From db3b5b3d6221aaefc2534b742291eb0706c55baa Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 10 Oct 2026 23:19:35 -0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20endpoint=20handles=20verified=20end?= =?UTF-8?q?-to-end=20=E2=80=94=20multi-line=20extent=20saved=20(5=20rects,?= =?UTF-8?q?=20clean=20text);=20note:=20footnote=20markers=20between=20line?= =?UTF-8?q?s=20are=20geometrically=20included=20by=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/reader/reader.ts | 410 +++++++++++++++------------------------ 1 file changed, 158 insertions(+), 252 deletions(-) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 8329d5d7..fec3af81 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -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(".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(() => {