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:
John O'Keefe committed 2026-10-11 13:01:16 -04:00
1 parent 93f020f2b5
commit 1c209408cc
1 file changed
+222 -199
+222 -199
View File
@@ -1553,7 +1553,7 @@ document.addEventListener("alpine:init", () => {
}
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
* adjust with two endpoint handles, reflowable ones with range
* handles. */
@@ -1561,12 +1561,17 @@ document.addEventListener("alpine:init", () => {
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. */
/** Page→host mapping for a fixed-layout index. Basis: the page's
* WRAPPER element (the iframe's parent — the same box the fork's
* own rect-annotation overlay fills with percentage-positioned
* children). Measuring it is a single same-context host measurement:
* the previous version combined the iframe-internal canvas rect
* 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) {
// The fixed-layout renderer's getContents() carries no index — map
// 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,
);
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;
const wrapper = frame?.parentElement;
const wr = wrapper?.getBoundingClientRect();
if (!wr || !wr.width || !wr.height) return null;
const left = wr.left;
const top = wr.top;
const width = wr.width;
const height = wr.height;
return {
doc: doc as Document,
toHost(fx: number, fy: number) {
@@ -1602,23 +1602,23 @@ 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
* 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 }[] {
/** Character glyph boxes (page fractions) in reading order, with
* dedupe: 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 (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 never reach that. */
pdfCharBoxes(doc: Document): {
x0: number;
x1: number;
y0: number;
y1: number;
ch: string;
}[] {
const canvas =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
@@ -1626,21 +1626,9 @@ document.addEventListener("alpine:init", () => {
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, 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 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 ?? "";
@@ -1650,61 +1638,84 @@ document.addEventListener("alpine:init", () => {
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;
const w = x1 - x0;
const box = {
x0: (r.left - dr.left) / dr.width,
x1: (r.right - dr.left) / dr.width,
y0: (r.top - dr.top) / dr.height,
y1: (r.bottom - dr.top) / dr.height,
ch: text[i],
};
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) => {
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 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;
});
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);
return out;
},
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
* reconstruct the extent's range from the stored rects via caret
* probes, put the two endpoint handles up, and arm the cancel
* paths. */
* build the page's character table (once — every drag reads the
* cached table in pure fraction space; no DOM reads, no
* 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() {
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;
const chars = this.pdfCharBoxes(map.doc);
if (!chars.length) return;
// The extent's char range: chars whose centers sit inside the
// stored rects (creation- and adjust-made extents both cover their
// own glyphs).
let startI = -1;
let endI = -1;
chars.forEach((c, i) => {
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
if (
p.pdfRects.some(
(r) =>
cx >= r[0] &&
cx <= r[0] + r[2] &&
cy >= r[1] &&
cy <= r[1] + r[3],
)
) {
if (startI < 0) startI = i;
endI = i;
}
});
if (startI < 0) return;
this.pdfAdjust = {
id: p.id,
index: p.pdfPage,
page: p.pdfPage,
color: p.color,
rects,
rects: p.pdfRects.map((r) => [...r]),
original: p.pdfRects.map((r) => [...r]),
range,
chars,
startI,
endI,
committed: false,
};
this.hideSelectionPopover();
@@ -1722,8 +1733,10 @@ document.addEventListener("alpine:init", () => {
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
},
/** (Re)draw the two endpoint handles, positioned through the live
* page mapping. Reuses the elements so drag-capture survives a
* repaint. */
* page mapping. The dots are created ONCE per session — every later
* 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() {
const ctx = this.pdfAdjust;
if (!ctx) return;
@@ -1731,7 +1744,6 @@ document.addEventListener("alpine:init", () => {
if (!layer) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
layer.innerHTML = "";
const first = ctx.rects[0];
const last = ctx.rects[ctx.rects.length - 1];
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 },
];
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>(
`.adjust-handle[data-which="${pt.which}"]`,
);
@@ -1777,7 +1785,6 @@ document.addEventListener("alpine:init", () => {
this.pdfAdjustRelease();
});
dot.addEventListener("pointercancel", () => {
if (!dot!.dataset.dragging) return;
delete dot!.dataset.dragging;
});
layer.appendChild(dot);
@@ -1787,10 +1794,49 @@ document.addEventListener("alpine:init", () => {
dot.style.top = `${host.y - 13}px`;
}
},
/** 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. */
/** Line boxes (page fractions) of a contiguous char-table span:
* cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT
* boxes (~one line gap tall), so the break threshold must sit
* 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(
which: "start" | "end",
clientX: number,
@@ -1801,68 +1847,29 @@ document.addEventListener("alpine:init", () => {
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const { fx, fy } = map.toFraction(clientX, clientY);
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 {
working.setStart(ctx.range.startContainer, ctx.range.startOffset);
working.setEnd(caret.startContainer, caret.startOffset);
}
} 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;
const chars = ctx.chars;
if (!chars.length) return;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < chars.length; i++) {
const cx = (chars[i].x0 + chars[i].x1) / 2;
const cy = (chars[i].y0 + chars[i].y1) / 2;
const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
if (d < bestD) {
bestD = d;
best = i;
}
}
ctx.range = working;
ctx.rects = this.rectsForRange(map.doc, working);
let s = ctx.startI;
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();
},
/** 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
* overlay entry (addRectAnnotation is replace-by-key), so there is
@@ -1882,48 +1889,13 @@ document.addEventListener("alpine:init", () => {
this.renderPdfAdjustHandles();
});
},
/** 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. */
/** Release: the extent's chars ARE the text — join them (line gaps
* re-insert spaces, matching the creation path's normalized
* selection text) and save through the popover's own PUT path. */
async pdfAdjustRelease() {
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;
const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1);
let text = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars) {
@@ -1932,12 +1904,26 @@ document.addEventListener("alpine:init", () => {
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 += " ";
else if (c.x0 - prev.x1 > 0.004) text += " ";
}
text += c.ch;
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
* was committed (cancel runs after finalize too), and drop the mode. */
@@ -2069,36 +2055,73 @@ document.addEventListener("alpine:init", () => {
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.
// (the exact gap the handles alone could not express from stale
// geometry). Chars inside the selection's line boxes become the
// new extent; text reads from them; saved immediately: the
// handles' 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[][] = [];
const selRects: 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 (w > 0 && h > 0) selRects.push([x, y, w, h]);
}
if (!rects.length) return;
const t = this.pdfTextForRects(doc, rects);
if (!t) {
if (!selRects.length) return;
const chars = this.pdfAdjust.chars;
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");
return;
}
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;
this.cancelPdfAdjust();
const p = this.selectionPopover;
p.id = session.id;
p.pdfPage = session.page;
p.pdfRects = rects;
p.pdfRects = extentRects;
p.text = t;
this.saveHighlightChanges();
try {