fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle "jumped several lines down and stuck". The instrumented drag exposed the chain: 1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the dot's pointermove listener passed raw e.clientX/clientY PIXELS. Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space picks the bottom-right-most glyph — the extent leaped to the page's end and never moved again (stuck). Engine-independent: failed identically on Chrome/Brave/Firefox desktops; my synthetic tests masked it by passing pre-converted fractions. handleMove now maps host px → fractions internally (one contract). 2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas rect with the host iframe rect. Whether that combination is valid depends on the engine's cross-document getBoundingClientRect semantics. Replaced with the page WRAPPER element (the iframe's parent — the same box the fork's rect-annotation overlay fills), measured in a single host context. Handles now align with the painted highlights by construction. 3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line gap tall). Dedupe at a full box height ate the line-below char in the same x-column (silent character loss); clustering at full box height never split lines (merged 0.45-tall "line" boxes). Dedupe now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of the char's own height (inter-line gap ≈ 1.0× breaks, baseline variance ≈ 0.1× stays). Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117) → span extends line 1 → line 2, 2 per-line rects saved, clean multi-line text. Direct-fraction probes and the pointer path now agree on the same load.
This commit is contained in:
1 parent
93f020f2b5
commit
1c209408cc
1 file changed
+222
-199
+222
-199
@@ -1553,7 +1553,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.adjustContext = null;
|
this.adjustContext = null;
|
||||||
},
|
},
|
||||||
// ----- PDF highlight extent adjustment (rect-edge, v2: endpoints) -----
|
// ----- PDF highlight extent adjustment (v2: endpoint handles) -----
|
||||||
/** Format router for the popover's Adjust button: PDF highlights
|
/** Format router for the popover's Adjust button: PDF highlights
|
||||||
* adjust with two endpoint handles, reflowable ones with range
|
* adjust with two endpoint handles, reflowable ones with range
|
||||||
* handles. */
|
* handles. */
|
||||||
@@ -1561,12 +1561,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
|
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
|
||||||
else this.startAdjust();
|
else this.startAdjust();
|
||||||
},
|
},
|
||||||
/** Page→host mapping for a fixed-layout index. The canvas's
|
/** Page→host mapping for a fixed-layout index. Basis: the page's
|
||||||
* post-transform rect IS the visible page (documentElement is dpr×
|
* WRAPPER element (the iframe's parent — the same box the fork's
|
||||||
* too small — pdf.js scales <html> by 1/devicePixelRatio); iframe
|
* own rect-annotation overlay fills with percentage-positioned
|
||||||
* client px map to host px by the frame's own rect ratios (the
|
* children). Measuring it is a single same-context host measurement:
|
||||||
* fork's convertPoint math). Computed fresh per call so zoom/pan
|
* the previous version combined the iframe-internal canvas rect
|
||||||
* mid-session never works from stale geometry. */
|
* with the host iframe rect, and whether that mix is valid depends
|
||||||
|
* on which coordinate space a given engine returns for
|
||||||
|
* cross-document getBoundingClientRect — on desktop Brave it
|
||||||
|
* mismatched, so drags re-anchored "several lines down" and stuck.
|
||||||
|
* The wrapper is also what the highlights are painted against, so
|
||||||
|
* handle positions align with the paint by construction. */
|
||||||
pdfPageMapping(index: number) {
|
pdfPageMapping(index: number) {
|
||||||
// The fixed-layout renderer's getContents() carries no index — map
|
// The fixed-layout renderer's getContents() carries no index — map
|
||||||
// through the load-event registry, keeping only live frames.
|
// through the load-event registry, keeping only live frames.
|
||||||
@@ -1578,19 +1583,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement,
|
d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement,
|
||||||
);
|
);
|
||||||
if (!doc) return null;
|
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 frame = doc.defaultView?.frameElement as HTMLElement | null;
|
||||||
const fr = frame?.getBoundingClientRect();
|
const wrapper = frame?.parentElement;
|
||||||
if (!dr || !dr.width || !dr.height || !fr) return null;
|
const wr = wrapper?.getBoundingClientRect();
|
||||||
const scaleX = fr.width / (doc.documentElement.clientWidth || 1);
|
if (!wr || !wr.width || !wr.height) return null;
|
||||||
const scaleY = fr.height / (doc.documentElement.clientHeight || 1);
|
const left = wr.left;
|
||||||
const left = fr.left + dr.left * scaleX;
|
const top = wr.top;
|
||||||
const top = fr.top + dr.top * scaleY;
|
const width = wr.width;
|
||||||
const width = dr.width * scaleX;
|
const height = wr.height;
|
||||||
const height = dr.height * scaleY;
|
|
||||||
return {
|
return {
|
||||||
doc: doc as Document,
|
doc: doc as Document,
|
||||||
toHost(fx: number, fy: number) {
|
toHost(fx: number, fy: number) {
|
||||||
@@ -1602,23 +1602,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
fy: (clientY - top) / height,
|
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
|
/** Character glyph boxes (page fractions) in reading order, with
|
||||||
* y-band of the text layer, reading order. One Range per character
|
* dedupe: pdf.js re-renders (zoom settle, scale updates) append a
|
||||||
* over each span's text node — caret-granularity snap targets, the
|
* second span set into the same .textLayer without clearing — char
|
||||||
* same vocabulary the reflowable adjust snaps with. Spans outside
|
* boxes come back duplicated, and the copies can sit SUB-PIXEL
|
||||||
* the band are prefiltered by their own box to keep the per-char
|
* SHIFTED (a doubled-letter text recompute once slipped through an
|
||||||
* range work line-sized. */
|
* exact-position key). Dedupe by overlap instead: a candidate is a
|
||||||
pdfCharBoxes(
|
* duplicate when a kept glyph on the same line overlaps more than
|
||||||
doc: Document,
|
* half the narrower glyph. Distinct glyphs never reach that. */
|
||||||
bandTop: number,
|
pdfCharBoxes(doc: Document): {
|
||||||
bandBottom: number,
|
x0: number;
|
||||||
): { x0: number; x1: number; y0: number; y1: number; ch: string }[] {
|
x1: number;
|
||||||
|
y0: number;
|
||||||
|
y1: number;
|
||||||
|
ch: string;
|
||||||
|
}[] {
|
||||||
const canvas =
|
const canvas =
|
||||||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||||||
(doc.querySelector("img") as HTMLElement);
|
(doc.querySelector("img") as HTMLElement);
|
||||||
@@ -1626,21 +1626,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!dr || !dr.width || !dr.height) return [];
|
if (!dr || !dr.width || !dr.height) return [];
|
||||||
const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] =
|
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, and the copies can sit SUB-PIXEL SHIFTED
|
|
||||||
// from the originals (a doubled-letter text recompute once slipped
|
|
||||||
// through an exact-position key). Dedupe by overlap instead: a
|
|
||||||
// candidate is a duplicate when a kept glyph on the same line
|
|
||||||
// overlaps more than half the narrower glyph. Distinct glyphs
|
|
||||||
// (including tight pairs like "il") never reach that.
|
|
||||||
const range = doc.createRange();
|
const range = doc.createRange();
|
||||||
const spans = doc.querySelectorAll(".textLayer span");
|
const spans = doc.querySelectorAll(".textLayer span");
|
||||||
spans.forEach((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;
|
const node = span.firstChild;
|
||||||
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
||||||
const text = node.nodeValue ?? "";
|
const text = node.nodeValue ?? "";
|
||||||
@@ -1650,61 +1638,84 @@ document.addEventListener("alpine:init", () => {
|
|||||||
range.setEnd(node, i + 1);
|
range.setEnd(node, i + 1);
|
||||||
const r = range.getBoundingClientRect();
|
const r = range.getBoundingClientRect();
|
||||||
if (!r.width && !r.height) continue;
|
if (!r.width && !r.height) continue;
|
||||||
const x0 = (r.left - dr.left) / dr.width;
|
const box = {
|
||||||
const x1 = (r.right - dr.left) / dr.width;
|
x0: (r.left - dr.left) / dr.width,
|
||||||
const y0 = (r.top - dr.top) / dr.height;
|
x1: (r.right - dr.left) / dr.width,
|
||||||
const y1 = (r.bottom - dr.top) / dr.height;
|
y0: (r.top - dr.top) / dr.height,
|
||||||
const cy = (y0 + y1) / 2;
|
y1: (r.bottom - dr.top) / dr.height,
|
||||||
if (cy < bandTop || cy > bandBottom) continue;
|
ch: text[i],
|
||||||
const w = x1 - x0;
|
};
|
||||||
|
if (box.x1 <= 0 || box.x0 >= 1 || box.y1 <= 0 || box.y0 >= 1) continue;
|
||||||
|
const w = box.x1 - box.x0;
|
||||||
|
const h = box.y1 - box.y0;
|
||||||
const dup = out.some((k) => {
|
const dup = out.some((k) => {
|
||||||
if (Math.abs(k.y0 - y0) > (k.y1 - k.y0) * 0.5) return false;
|
// A duplicate is the SAME glyph re-rendered at a drifted
|
||||||
|
// position: y within 0.75 of the SHORTER box (per-char boxes
|
||||||
|
// are LINE-HEIGHT boxes — a full-height tolerance let a
|
||||||
|
// line-1 char eat the line-2 char beneath it in the same
|
||||||
|
// x-column), x overlapping more than half the narrower box.
|
||||||
|
// The staircase of offset re-render copies steps by ~0.004 —
|
||||||
|
// well inside this tolerance; real inter-line gaps (~0.0196)
|
||||||
|
// are outside it.
|
||||||
|
const kh = k.y1 - k.y0;
|
||||||
|
const tolY = Math.min(kh, h) * 0.75;
|
||||||
|
if (Math.abs(k.y0 - box.y0) > tolY) return false;
|
||||||
const kw = k.x1 - k.x0;
|
const kw = k.x1 - k.x0;
|
||||||
const overlap = Math.min(k.x1, x1) - Math.max(k.x0, x0);
|
const overlap = Math.min(k.x1, box.x1) - Math.max(k.x0, box.x0);
|
||||||
return overlap > Math.min(kw, w) * 0.5;
|
return overlap > Math.min(kw, w) * 0.5;
|
||||||
});
|
});
|
||||||
if (dup) continue;
|
if (dup) continue;
|
||||||
out.push({ x0, x1, y0, y1, ch: text[i] });
|
out.push(box);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
|
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
|
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
|
||||||
* reconstruct the extent's range from the stored rects via caret
|
* build the page's character table (once — every drag reads the
|
||||||
* probes, put the two endpoint handles up, and arm the cancel
|
* cached table in pure fraction space; no DOM reads, no
|
||||||
* paths. */
|
* coordinate-space hazards — portable to any browser engine), map
|
||||||
|
* the stored rects to a word... char range, put the two endpoint
|
||||||
|
* handles up, and arm the cancel paths. */
|
||||||
startPdfAdjust() {
|
startPdfAdjust() {
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
|
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
|
||||||
const map = this.pdfPageMapping(p.pdfPage);
|
const map = this.pdfPageMapping(p.pdfPage);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const rects = p.pdfRects;
|
const chars = this.pdfCharBoxes(map.doc);
|
||||||
const first = rects[0];
|
if (!chars.length) return;
|
||||||
const last = rects[rects.length - 1];
|
// The extent's char range: chars whose centers sit inside the
|
||||||
// Reconstruct the extent's range: carets just inside the first
|
// stored rects (creation- and adjust-made extents both cover their
|
||||||
// rect's left-middle and the last rect's right-middle.
|
// own glyphs).
|
||||||
const startPt = map.toDoc(first[0] + 0.002, first[1] + first[3] / 2);
|
let startI = -1;
|
||||||
const endPt = map.toDoc(
|
let endI = -1;
|
||||||
last[0] + last[2] - 0.002,
|
chars.forEach((c, i) => {
|
||||||
last[1] + last[3] / 2,
|
const cx = (c.x0 + c.x1) / 2;
|
||||||
);
|
const cy = (c.y0 + c.y1) / 2;
|
||||||
const doc = map.doc;
|
if (
|
||||||
const sc = doc.caretRangeFromPoint?.(startPt.x, startPt.y) ?? null;
|
p.pdfRects.some(
|
||||||
const ec = doc.caretRangeFromPoint?.(endPt.x, endPt.y) ?? null;
|
(r) =>
|
||||||
if (!sc || !ec) return;
|
cx >= r[0] &&
|
||||||
const range = doc.createRange();
|
cx <= r[0] + r[2] &&
|
||||||
range.setStart(sc.startContainer, sc.startOffset);
|
cy >= r[1] &&
|
||||||
range.setEnd(ec.startContainer, ec.startOffset);
|
cy <= r[1] + r[3],
|
||||||
if (range.collapsed) return;
|
)
|
||||||
|
) {
|
||||||
|
if (startI < 0) startI = i;
|
||||||
|
endI = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (startI < 0) return;
|
||||||
this.pdfAdjust = {
|
this.pdfAdjust = {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
index: p.pdfPage,
|
index: p.pdfPage,
|
||||||
page: p.pdfPage,
|
page: p.pdfPage,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
rects,
|
rects: p.pdfRects.map((r) => [...r]),
|
||||||
original: p.pdfRects.map((r) => [...r]),
|
original: p.pdfRects.map((r) => [...r]),
|
||||||
range,
|
chars,
|
||||||
|
startI,
|
||||||
|
endI,
|
||||||
committed: false,
|
committed: false,
|
||||||
};
|
};
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
@@ -1722,8 +1733,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
|
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
|
||||||
},
|
},
|
||||||
/** (Re)draw the two endpoint handles, positioned through the live
|
/** (Re)draw the two endpoint handles, positioned through the live
|
||||||
* page mapping. Reuses the elements so drag-capture survives a
|
* page mapping. The dots are created ONCE per session — every later
|
||||||
* repaint. */
|
* call only repositions the existing elements (an innerHTML rebuild
|
||||||
|
* mid-drag destroys the dot being dragged; it loses pointer capture
|
||||||
|
* and the drag dies silently — user report). */
|
||||||
renderPdfAdjustHandles() {
|
renderPdfAdjustHandles() {
|
||||||
const ctx = this.pdfAdjust;
|
const ctx = this.pdfAdjust;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -1731,7 +1744,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
const map = this.pdfPageMapping(ctx.index);
|
const map = this.pdfPageMapping(ctx.index);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
layer.innerHTML = "";
|
|
||||||
const first = ctx.rects[0];
|
const first = ctx.rects[0];
|
||||||
const last = ctx.rects[ctx.rects.length - 1];
|
const last = ctx.rects[ctx.rects.length - 1];
|
||||||
if (!first || !last) return;
|
if (!first || !last) return;
|
||||||
@@ -1742,10 +1754,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
{ which: "end", fx: last[0] + last[2], fy: last[1] + last[3] / 2 },
|
{ which: "end", fx: last[0] + last[2], fy: last[1] + last[3] / 2 },
|
||||||
];
|
];
|
||||||
for (const pt of pts) {
|
for (const pt of pts) {
|
||||||
// Create each dot ONCE per session; every later call only
|
|
||||||
// REPOSITIONS the existing element. An innerHTML rebuild here
|
|
||||||
// destroys the dot mid-drag — it loses pointer capture and the
|
|
||||||
// drag dies silently ("handles not moving", user report).
|
|
||||||
let dot = layer.querySelector<HTMLDivElement>(
|
let dot = layer.querySelector<HTMLDivElement>(
|
||||||
`.adjust-handle[data-which="${pt.which}"]`,
|
`.adjust-handle[data-which="${pt.which}"]`,
|
||||||
);
|
);
|
||||||
@@ -1777,7 +1785,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.pdfAdjustRelease();
|
this.pdfAdjustRelease();
|
||||||
});
|
});
|
||||||
dot.addEventListener("pointercancel", () => {
|
dot.addEventListener("pointercancel", () => {
|
||||||
if (!dot!.dataset.dragging) return;
|
|
||||||
delete dot!.dataset.dragging;
|
delete dot!.dataset.dragging;
|
||||||
});
|
});
|
||||||
layer.appendChild(dot);
|
layer.appendChild(dot);
|
||||||
@@ -1787,10 +1794,49 @@ document.addEventListener("alpine:init", () => {
|
|||||||
dot.style.top = `${host.y - 13}px`;
|
dot.style.top = `${host.y - 13}px`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** Endpoint drag: the finger (host px) re-anchors the grabbed end at
|
/** Line boxes (page fractions) of a contiguous char-table span:
|
||||||
* the caret under it — character-precise (a trailing period or
|
* cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT
|
||||||
* line-break hyphen can be taken or shed), crossing lines freely —
|
* boxes (~one line gap tall), so the break threshold must sit
|
||||||
* and the rects rebuild from the range's own line boxes. */
|
* clearly below the inter-line gap yet above intra-line baseline
|
||||||
|
* variance — 0.6 of the char's own height does that (gap ≈ 1.0 ×
|
||||||
|
* height breaks; variance ≈ 0.1 × height stays). */
|
||||||
|
charSpanRects(
|
||||||
|
chars: { x0: number; x1: number; y0: number; y1: number }[],
|
||||||
|
): number[][] {
|
||||||
|
const rects: number[][] = [];
|
||||||
|
let line: typeof chars = [];
|
||||||
|
let lastCy = -9;
|
||||||
|
for (const c of chars) {
|
||||||
|
const cy = (c.y0 + c.y1) / 2;
|
||||||
|
const tol = (c.y1 - c.y0) * 0.6;
|
||||||
|
if (line.length && Math.abs(cy - lastCy) > tol) {
|
||||||
|
const x0 = Math.min(...line.map((w) => w.x0));
|
||||||
|
const y0 = Math.min(...line.map((w) => w.y0));
|
||||||
|
const x1 = Math.max(...line.map((w) => w.x1));
|
||||||
|
const y1 = Math.max(...line.map((w) => w.y1));
|
||||||
|
rects.push([x0, y0, x1 - x0, y1 - y0]);
|
||||||
|
line = [];
|
||||||
|
}
|
||||||
|
line.push(c);
|
||||||
|
lastCy = cy;
|
||||||
|
}
|
||||||
|
if (line.length) {
|
||||||
|
const x0 = Math.min(...line.map((w) => w.x0));
|
||||||
|
const y0 = Math.min(...line.map((w) => w.y0));
|
||||||
|
const x1 = Math.max(...line.map((w) => w.x1));
|
||||||
|
const y1 = Math.max(...line.map((w) => w.y1));
|
||||||
|
rects.push([x0, y0, x1 - x0, y1 - y0]);
|
||||||
|
}
|
||||||
|
return rects;
|
||||||
|
},
|
||||||
|
/** Endpoint drag: the finger (HOST px) maps to page fractions via
|
||||||
|
* the live mapping, then re-anchors the grabbed end at the nearest
|
||||||
|
* glyph in the cached char table — character-precise (a trailing
|
||||||
|
* period or line-break hyphen can be taken or shed), crossing
|
||||||
|
* lines freely — and the rects rebuild from the new char span as
|
||||||
|
* per-line unions. Pure fraction space after the mapping: no
|
||||||
|
* caretRangeFromPoint, no coordinate-space conversion, works on
|
||||||
|
* any browser engine (Firefox included). */
|
||||||
pdfAdjustHandleMove(
|
pdfAdjustHandleMove(
|
||||||
which: "start" | "end",
|
which: "start" | "end",
|
||||||
clientX: number,
|
clientX: number,
|
||||||
@@ -1801,68 +1847,29 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const map = this.pdfPageMapping(ctx.index);
|
const map = this.pdfPageMapping(ctx.index);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const { fx, fy } = map.toFraction(clientX, clientY);
|
const { fx, fy } = map.toFraction(clientX, clientY);
|
||||||
const pt = map.toDoc(fx, fy);
|
const chars = ctx.chars;
|
||||||
const caret = map.doc.caretRangeFromPoint?.(pt.x, pt.y) ?? null;
|
if (!chars.length) return;
|
||||||
if (!caret) return;
|
let best = 0;
|
||||||
const working = map.doc.createRange();
|
let bestD = Infinity;
|
||||||
try {
|
for (let i = 0; i < chars.length; i++) {
|
||||||
if (which === "start") {
|
const cx = (chars[i].x0 + chars[i].x1) / 2;
|
||||||
working.setStart(caret.startContainer, caret.startOffset);
|
const cy = (chars[i].y0 + chars[i].y1) / 2;
|
||||||
working.setEnd(ctx.range.endContainer, ctx.range.endOffset);
|
const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
|
||||||
} else {
|
if (d < bestD) {
|
||||||
working.setStart(ctx.range.startContainer, ctx.range.startOffset);
|
bestD = d;
|
||||||
working.setEnd(caret.startContainer, caret.startOffset);
|
best = i;
|
||||||
}
|
|
||||||
} 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;
|
let s = ctx.startI;
|
||||||
ctx.rects = this.rectsForRange(map.doc, working);
|
let e = ctx.endI;
|
||||||
|
if (which === "start") s = Math.min(best, e);
|
||||||
|
else e = Math.max(best, s);
|
||||||
|
if (s > e) return;
|
||||||
|
ctx.startI = s;
|
||||||
|
ctx.endI = e;
|
||||||
|
ctx.rects = this.charSpanRects(chars.slice(s, e + 1));
|
||||||
this.schedulePdfAdjustPreview();
|
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
|
/** Preview scheduling: coalesce pointermove bursts into one overlay
|
||||||
* repaint per frame. The preview replaces the highlight's OWN
|
* repaint per frame. The preview replaces the highlight's OWN
|
||||||
* overlay entry (addRectAnnotation is replace-by-key), so there is
|
* overlay entry (addRectAnnotation is replace-by-key), so there is
|
||||||
@@ -1882,48 +1889,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderPdfAdjustHandles();
|
this.renderPdfAdjustHandles();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/** Release: the range IS the extent — serialize its rects, recompute
|
/** Release: the extent's chars ARE the text — join them (line gaps
|
||||||
* the selection text from the deduped glyph boxes, and save through
|
* re-insert spaces, matching the creation path's normalized
|
||||||
* the popover's own PUT path. */
|
* selection text) and save through the popover's own PUT path. */
|
||||||
async pdfAdjustRelease() {
|
async pdfAdjustRelease() {
|
||||||
const ctx = this.pdfAdjust;
|
const ctx = this.pdfAdjust;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const map = this.pdfPageMapping(ctx.index);
|
const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1);
|
||||||
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 text = "";
|
||||||
let prev: (typeof chars)[number] | null = null;
|
let prev: (typeof chars)[number] | null = null;
|
||||||
for (const c of chars) {
|
for (const c of chars) {
|
||||||
@@ -1932,12 +1904,26 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const cc = (c.y0 + c.y1) / 2;
|
const cc = (c.y0 + c.y1) / 2;
|
||||||
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
|
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
|
||||||
if (Math.abs(cc - pc) > half) text += " ";
|
if (Math.abs(cc - pc) > half) text += " ";
|
||||||
else if (c.x0 - prev.x1 > medW * 0.3) text += " ";
|
else if (c.x0 - prev.x1 > 0.004) text += " ";
|
||||||
}
|
}
|
||||||
text += c.ch;
|
text += c.ch;
|
||||||
prev = c;
|
prev = c;
|
||||||
}
|
}
|
||||||
return text.replace(/\s+/g, " ").trim();
|
text = text.replace(/\s+/g, " ").trim();
|
||||||
|
const rects = ctx.rects;
|
||||||
|
const { id, page } = ctx;
|
||||||
|
ctx.committed = true;
|
||||||
|
this.cancelPdfAdjust();
|
||||||
|
if (!text) {
|
||||||
|
showToast("Highlights can't be empty", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = this.selectionPopover;
|
||||||
|
p.id = id;
|
||||||
|
p.pdfPage = page;
|
||||||
|
p.pdfRects = rects;
|
||||||
|
p.text = text;
|
||||||
|
await this.saveHighlightChanges();
|
||||||
},
|
},
|
||||||
/** Leave rect adjust: restore the stored paint unless the adjustment
|
/** Leave rect adjust: restore the stored paint unless the adjustment
|
||||||
* was committed (cancel runs after finalize too), and drop the mode. */
|
* was committed (cancel runs after finalize too), and drop the mode. */
|
||||||
@@ -2069,36 +2055,73 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
// Adjust re-selection (v2): a selection made during a rect-edge
|
// Adjust re-selection (v2): a selection made during a rect-edge
|
||||||
// session REPLACES the working extent — any lines, partial lines
|
// session REPLACES the working extent — any lines, partial lines
|
||||||
// (the exact gap the grips could not express). Rects rebuild from
|
// (the exact gap the handles alone could not express from stale
|
||||||
// the selection's line boxes, text re-reads, saved immediately:
|
// geometry). Chars inside the selection's line boxes become the
|
||||||
// the grips' release-to-save parity.
|
// new extent; text reads from them; saved immediately: the
|
||||||
|
// handles' release-to-save parity.
|
||||||
if (this.pdfAdjust) {
|
if (this.pdfAdjust) {
|
||||||
const canvasRect =
|
const canvasRect =
|
||||||
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||||||
(doc.querySelector("img") as HTMLElement);
|
(doc.querySelector("img") as HTMLElement);
|
||||||
const dr = canvasRect?.getBoundingClientRect();
|
const dr = canvasRect?.getBoundingClientRect();
|
||||||
if (!dr || !dr.width || !dr.height) return;
|
if (!dr || !dr.width || !dr.height) return;
|
||||||
const rects: number[][] = [];
|
const selRects: number[][] = [];
|
||||||
for (const r of range.getClientRects()) {
|
for (const r of range.getClientRects()) {
|
||||||
const x = (r.left - dr.left) / dr.width;
|
const x = (r.left - dr.left) / dr.width;
|
||||||
const y = (r.top - dr.top) / dr.height;
|
const y = (r.top - dr.top) / dr.height;
|
||||||
const w = r.width / dr.width;
|
const w = r.width / dr.width;
|
||||||
const h = r.height / dr.height;
|
const h = r.height / dr.height;
|
||||||
if (w > 0 && h > 0) rects.push([x, y, w, h]);
|
if (w > 0 && h > 0) selRects.push([x, y, w, h]);
|
||||||
}
|
}
|
||||||
if (!rects.length) return;
|
if (!selRects.length) return;
|
||||||
const t = this.pdfTextForRects(doc, rects);
|
const chars = this.pdfAdjust.chars;
|
||||||
if (!t) {
|
const picked: number[] = [];
|
||||||
|
chars.forEach((c, i) => {
|
||||||
|
const cx = (c.x0 + c.x1) / 2;
|
||||||
|
const cy = (c.y0 + c.y1) / 2;
|
||||||
|
if (
|
||||||
|
selRects.some(
|
||||||
|
(r) =>
|
||||||
|
cx >= r[0] &&
|
||||||
|
cx <= r[0] + r[2] &&
|
||||||
|
cy >= r[1] &&
|
||||||
|
cy <= r[1] + r[3],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
picked.push(i);
|
||||||
|
});
|
||||||
|
if (!picked.length) {
|
||||||
showToast("Highlights can't be empty", "error");
|
showToast("Highlights can't be empty", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = this.pdfAdjust;
|
const session = this.pdfAdjust;
|
||||||
|
const startI = picked[0];
|
||||||
|
const endI = picked[picked.length - 1];
|
||||||
|
const extentRects = this.charSpanRects(chars.slice(startI, endI + 1));
|
||||||
|
let t = "";
|
||||||
|
let prev: (typeof chars)[number] | null = null;
|
||||||
|
for (const c of chars.slice(startI, endI + 1)) {
|
||||||
|
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) t += " ";
|
||||||
|
else if (c.x0 - prev.x1 > 0.004) t += " ";
|
||||||
|
}
|
||||||
|
t += c.ch;
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
t = t.replace(/\s+/g, " ").trim();
|
||||||
|
if (!t) {
|
||||||
|
showToast("Highlights can't be empty", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
session.committed = true;
|
session.committed = true;
|
||||||
this.cancelPdfAdjust();
|
this.cancelPdfAdjust();
|
||||||
const p = this.selectionPopover;
|
const p = this.selectionPopover;
|
||||||
p.id = session.id;
|
p.id = session.id;
|
||||||
p.pdfPage = session.page;
|
p.pdfPage = session.page;
|
||||||
p.pdfRects = rects;
|
p.pdfRects = extentRects;
|
||||||
p.text = t;
|
p.text = t;
|
||||||
this.saveHighlightChanges();
|
this.saveHighlightChanges();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user