fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design
This commit is contained in:
1 parent
f69e659d94
commit
db3b5b3d62
1 file changed
+158
-252
+158
-252
@@ -512,25 +512,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
color: string;
|
color: string;
|
||||||
rects: number[][];
|
rects: number[][];
|
||||||
original: 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
|
/** True once finalize handed off to saveHighlightChanges — cancel
|
||||||
* must not restore the original paint over the saved new one. */
|
* must not restore the original paint over the saved new one. */
|
||||||
committed: boolean;
|
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,
|
pdfAdjustRaf: 0,
|
||||||
pdfAdjustOnRelocate: null as null | (() => void),
|
pdfAdjustOnRelocate: null as null | (() => void),
|
||||||
pdfAdjustOnZoom: null as null | (() => void),
|
pdfAdjustOnZoom: null as null | (() => void),
|
||||||
@@ -1564,9 +1553,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
this.adjustContext = null;
|
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
|
/** 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() {
|
startAnyAdjust() {
|
||||||
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
|
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
|
||||||
else this.startAdjust();
|
else this.startAdjust();
|
||||||
@@ -1612,6 +1602,10 @@ 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) whose center sits inside a
|
||||||
@@ -1677,276 +1671,190 @@ document.addEventListener("alpine:init", () => {
|
|||||||
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,
|
||||||
* take a working copy of the anchor rects, put edge grips up over
|
* reconstruct the extent's range from the stored rects via caret
|
||||||
* the page, and arm the cancel paths. */
|
* probes, 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);
|
||||||
|
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 = {
|
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: p.pdfRects.map((r) => [...r]),
|
rects,
|
||||||
original: p.pdfRects.map((r) => [...r]),
|
original: p.pdfRects.map((r) => [...r]),
|
||||||
|
range,
|
||||||
committed: false,
|
committed: false,
|
||||||
};
|
};
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
const layer = this.ensureAdjustLayer();
|
const layer = this.ensureAdjustLayer();
|
||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
this.renderPdfAdjustGrips();
|
this.renderPdfAdjustHandles();
|
||||||
// A page turn ends the session safely — the preview is discarded,
|
// A page turn ends the session safely — the preview is discarded,
|
||||||
// nothing saved (same contract as the reflowable adjust).
|
// nothing saved (same contract as the reflowable adjust).
|
||||||
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
|
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
|
||||||
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
|
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
|
||||||
once: true,
|
once: true,
|
||||||
});
|
});
|
||||||
// Zoom re-renders the page — re-derive the grips from live rects.
|
// Zoom re-renders the page — re-derive the handles from live rects.
|
||||||
this.pdfAdjustOnZoom = () => this.renderPdfAdjustGrips();
|
this.pdfAdjustOnZoom = () => this.renderPdfAdjustHandles();
|
||||||
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
|
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
|
||||||
},
|
},
|
||||||
/** (Re)draw the edge grips: one per edge of every working rect,
|
/** (Re)draw the two endpoint handles, positioned through the live
|
||||||
* positioned through the live page mapping. Reuses existing grip
|
* page mapping. Reuses the elements so drag-capture survives a
|
||||||
* elements when the set matches so drag-capture survives a repaint. */
|
* repaint. */
|
||||||
renderPdfAdjustGrips() {
|
renderPdfAdjustHandles() {
|
||||||
const ctx = this.pdfAdjust;
|
const ctx = this.pdfAdjust;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const layer = document.getElementById("adjust-handles");
|
const layer = document.getElementById("adjust-handles");
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
const map = this.pdfPageMapping(ctx.index);
|
const map = this.pdfPageMapping(ctx.index);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const wanted: { rect: number; edge: string; dir: string }[] = [];
|
layer.innerHTML = "";
|
||||||
ctx.rects.forEach((_r, ri) => {
|
const first = ctx.rects[0];
|
||||||
wanted.push(
|
const last = ctx.rects[ctx.rects.length - 1];
|
||||||
{ rect: ri, edge: "left", dir: "v" },
|
if (!first || !last) return;
|
||||||
{ rect: ri, edge: "right", dir: "v" },
|
// Start dot at the first rect's LEFT-MIDDLE, end dot at the last
|
||||||
{ rect: ri, edge: "top", dir: "h" },
|
// rect's RIGHT-MIDDLE — the range boundary points.
|
||||||
{ rect: ri, edge: "bottom", dir: "h" },
|
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 },
|
||||||
const existing = Array.from(
|
];
|
||||||
layer.querySelectorAll<HTMLDivElement>(".adjust-grip"),
|
for (const pt of pts) {
|
||||||
);
|
const host = map.toHost(pt.fx, pt.fy);
|
||||||
while (existing.length > wanted.length) {
|
const dot = document.createElement("div");
|
||||||
existing.pop()?.remove();
|
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
|
/** Endpoint drag: the finger (host px) re-anchors the grabbed end at
|
||||||
* for x-edges, band line tops/bottoms for y-edges) and snapshot the
|
* the caret under it — character-precise (a trailing period or
|
||||||
* rect for drag-cancel. */
|
* line-break hyphen can be taken or shed), crossing lines freely —
|
||||||
pdfAdjustGrabStart(
|
* and the rects rebuild from the range's own line boxes. */
|
||||||
ri: number,
|
pdfAdjustHandleMove(
|
||||||
edge: "left" | "right" | "top" | "bottom",
|
which: "start" | "end",
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
) {
|
) {
|
||||||
const ctx = this.pdfAdjust;
|
const ctx = this.pdfAdjust;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const map = this.pdfPageMapping(ctx.index);
|
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;
|
if (!map) return;
|
||||||
const { fx, fy } = map.toFraction(clientX, clientY);
|
const { fx, fy } = map.toFraction(clientX, clientY);
|
||||||
const r = ctx.rects[grab.rect];
|
const pt = map.toDoc(fx, fy);
|
||||||
if (!r) return;
|
const caret = map.doc.caretRangeFromPoint?.(pt.x, pt.y) ?? null;
|
||||||
const MIN = 0.002; // never let a drag collapse a rect away
|
if (!caret) return;
|
||||||
if (grab.edge === "left" || grab.edge === "right") {
|
const working = map.doc.createRange();
|
||||||
const isStart = grab.edge === "left";
|
try {
|
||||||
let best: number | null = null;
|
if (which === "start") {
|
||||||
let bestD = Infinity;
|
working.setStart(caret.startContainer, caret.startOffset);
|
||||||
const consider = (cand: number) => {
|
working.setEnd(ctx.range.endContainer, ctx.range.endOffset);
|
||||||
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];
|
|
||||||
} else {
|
} 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 {
|
} catch {
|
||||||
let best = fy;
|
return;
|
||||||
let bestD = Infinity;
|
}
|
||||||
for (const ln of grab.lines) {
|
// Handles cannot cross: clamp the moving end at the opposite
|
||||||
for (const by of [ln.top, ln.bottom]) {
|
// boundary.
|
||||||
const d = Math.abs(by - fy);
|
if (working.collapsed) {
|
||||||
if (d < bestD) {
|
try {
|
||||||
bestD = d;
|
if (which === "start")
|
||||||
best = by;
|
working.setStart(ctx.range.endContainer, ctx.range.endOffset);
|
||||||
}
|
else working.setEnd(ctx.range.startContainer, ctx.range.startOffset);
|
||||||
}
|
} catch {
|
||||||
}
|
return;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.range = working;
|
||||||
|
ctx.rects = this.rectsForRange(map.doc, working);
|
||||||
this.schedulePdfAdjustPreview();
|
this.schedulePdfAdjustPreview();
|
||||||
},
|
},
|
||||||
/** Drop a cancelled drag (pointercancel): restore the snapshot. */
|
/** Line boxes of a range (page fractions), duplicate copy boxes
|
||||||
pdfAdjustAbortGrab() {
|
* collapsed: pdf.js re-render span sets duplicate glyphs at
|
||||||
const grab = this.pdfAdjustGrab;
|
* sub-pixel shifts, so a naive getClientRects sweep returns the
|
||||||
const ctx = this.pdfAdjust;
|
* same line twice — harmless to paint, noise in the payload. */
|
||||||
if (!grab || !ctx) return;
|
rectsForRange(doc: Document, range: Range): number[][] {
|
||||||
const r = ctx.rects[grab.rect];
|
const canvas =
|
||||||
if (r) Object.assign(r, grab.snapshot);
|
(doc.querySelector("#canvas canvas") as HTMLElement) ||
|
||||||
this.pdfAdjustGrab = null;
|
(doc.querySelector("img") as HTMLElement);
|
||||||
this.schedulePdfAdjustPreview();
|
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
|
||||||
@@ -1964,15 +1872,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
rects: ctx.rects,
|
rects: ctx.rects,
|
||||||
color: ctx.color,
|
color: ctx.color,
|
||||||
});
|
});
|
||||||
this.renderPdfAdjustGrips();
|
this.renderPdfAdjustHandles();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/** Release: recompute the anchor + selection text from the working
|
/** Release: the range IS the extent — serialize its rects, recompute
|
||||||
* rects, save through the popover's own PUT path, clean up. */
|
* the selection text from the deduped glyph boxes, and save through
|
||||||
|
* the popover's own PUT path. */
|
||||||
async pdfAdjustRelease() {
|
async pdfAdjustRelease() {
|
||||||
const grab = this.pdfAdjustGrab;
|
|
||||||
if (!grab) return;
|
|
||||||
this.pdfAdjustGrab = null;
|
|
||||||
const ctx = this.pdfAdjust;
|
const ctx = this.pdfAdjust;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const map = this.pdfPageMapping(ctx.index);
|
const map = this.pdfPageMapping(ctx.index);
|
||||||
@@ -2040,7 +1946,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.pdfAdjust = null;
|
this.pdfAdjust = null;
|
||||||
this.pdfAdjustGrab = null;
|
|
||||||
if (this.pdfAdjustRaf) {
|
if (this.pdfAdjustRaf) {
|
||||||
cancelAnimationFrame(this.pdfAdjustRaf);
|
cancelAnimationFrame(this.pdfAdjustRaf);
|
||||||
this.pdfAdjustRaf = 0;
|
this.pdfAdjustRaf = 0;
|
||||||
@@ -2058,6 +1963,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
document.getElementById("adjust-handles")?.remove();
|
document.getElementById("adjust-handles")?.remove();
|
||||||
},
|
},
|
||||||
|
|
||||||
scheduleSelectionPopover(doc: any, index: number) {
|
scheduleSelectionPopover(doc: any, index: number) {
|
||||||
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||||
this.selPopTimer = setTimeout(() => {
|
this.selPopTimer = setTimeout(() => {
|
||||||
|
|||||||
Reference in new issue
Block a user