theseus/bundled-addons/pdf-editor/lib/tools.js
Local Dev 333f7988a8 fix(pdf-editor): a page of solid text had nowhere to start an area selection
A band starts from the space between things, so that a drag across words
still selects words to copy. On a page that is wall-to-wall text there is
no such space, and the gesture simply did nothing — which is how it failed
on a real document at 276% zoom, where every candidate starting point had
a line of text under it.

Alt-drag starts a band anywhere, text or not. The select hint says so.
2026-09-27 18:34:07 +02:00

786 lines
33 KiB
JavaScript

// Pointer behaviour for every tool.
//
// The editor is modal: one tool is armed, and what a drag does depends only on
// which. The alternative — inferring intent from what is under the cursor — is
// what makes PDF annotators infuriating, because a drag meant to draw a box
// selects a paragraph instead. Modality is enforced in CSS (see the
// body[data-mode] rules): with a drawing tool armed, the text and form layers
// stop taking pointer events entirely, so a drag cannot be stolen.
//
// The text-markup tools are the deliberate exception. Highlight, underline and
// strikeout all work BY selecting text — the text layer stays live, the browser
// does the selection, and on release we convert the selection's client rects
// into user space. Rebuilding text selection over pdf.js's span soup would be
// worse at it.
//
// Placing something one-shot — a text stamp, a signature — drops straight back
// to the select tool with the new mark selected. Staying armed means the next
// click stamps a second copy, which is almost never what was wanted, and it
// puts the thing just placed immediately within reach of a nudge or a resize.
import { ASCENT, boundsOf, boxesOverlap, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
const MIN_DRAG = 3; // CSS px before a press counts as a drag
const DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
const SIG_WIDTH_PT = 170; // default placed width of a signature
const DESCENDER = 0.26; // how far below the baseline a glyph can reach, as a fraction of size
/** Tools that mark up existing text rather than drawing free-hand. */
export const TEXT_MARKUP = new Set(["highlight", "underline", "strikeout"]);
/** Tools that place something with a single click. */
const STAMP_TOOLS = new Set(["signature"]);
/**
* Put `a` back exactly as `orig` was — including forgetting keys `orig`
* never had.
*
* Object.assign alone copies the keys the original HAD, so a property the
* drag introduced survives the restore. That is not a tidiness point: the
* journal snapshots the restored mark as the "before", so a first rotation
* of a never-rotated signature was recorded as already rotated, and undo
* had nothing to undo.
*/
function restoreExactly(a, orig) {
for (const k of Object.keys(a)) if (!(k in orig)) delete a[k];
Object.assign(a, orig);
}
export class Tools {
constructor({ strip, model, host }) {
this.strip = strip;
this.model = model;
// host (editor.js): { toast, onChange, selectTool, onSelectionChange,
// beginText, editText, editRun, chooseSignature,
// signature }
this.host = host;
this.tool = "select";
this.color = "#ffd400";
this.width = 2.5;
this.fill = false; // shapes: outline only, or filled
this.fillOpacity = 0.25;
this.selected = null; // the anchor of the selection, or null
this.selection = new Set(); // every selected mark
this.drag = null;
// Opening a second document builds a second Tools over the same container.
// Without a way to unbind, the first one stays subscribed, keeps pointing
// at a torn-down strip, and throws on the next click — so every listener
// goes on one signal and destroy() drops the lot.
this._abort = new AbortController();
const opts = { signal: this._abort.signal };
const c = strip.container;
c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
c.addEventListener("pointermove", (e) => this.onMove(e), opts);
window.addEventListener("pointerup", (e) => this.onUp(e), opts);
}
destroy() {
this._abort.abort();
this.drag = null;
this.selected = null;
this.selection = new Set();
}
get mode() {
if (this.tool === "select") return "select";
if (TEXT_MARKUP.has(this.tool)) return "highlight";
// The text tool needs the document's own words to be hit-testable, so a
// click on them can replace them. A stamp tool must NOT, so its click
// lands on the page rather than inside a text selection.
if (this.tool === "text") return "text";
if (STAMP_TOOLS.has(this.tool)) return "stamp";
return "draw";
}
setTool(t) {
this.tool = t;
this._setSelected(null);
document.body.dataset.mode = this.mode;
this.host.onChange?.();
}
/** The one place selection changes, so the host never misses one. */
_setSelected(id) { this._setSelection(id ? [id] : []); }
/**
* Select a set of marks.
*
* `selection` is the truth; `selected` is the one of them the single-mark
* paths work on — the anchor — and is null when nothing is selected. With
* exactly one mark the two say the same thing, which is why every existing
* caller kept working when this grew from an id into a set.
*/
_setSelection(ids) {
const list = [...new Set(ids)].filter((id) => !!this.model.annot(id));
this.selection = new Set(list);
this.selected = list.length ? list[0] : null;
this.strip.refreshAllOverlays(this.selection);
this.host.onSelectionChange?.(this.selected);
}
select(id) { this._setSelected(id); }
selectMany(ids) { this._setSelection(ids); }
/** Every selected mark, as objects. */
selectedAnnots() {
return [...this.selection].map((id) => this.model.annot(id)).filter(Boolean);
}
setColor(c) {
this.color = c;
// Redaction is black because it is a redaction; it does not take a colour.
const ids = this.selectedAnnots().filter((a) => a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ color: c }, ids);
}
setFill(on) {
this.fill = !!on;
const ids = this.selectedAnnots()
.filter((a) => a.kind === "rect" || a.kind === "ellipse").map((a) => a.id);
if (ids.length) this.patchSelected({ fill: this.fill }, ids);
}
setWidth(w) {
this.width = w;
const ids = this.selectedAnnots()
.filter((a) => a.kind !== "text" && a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ width: w }, ids);
}
/** Change the selected marks, as one undo step. */
patchSelected(patch, ids = null) {
const list = ids || [...this.selection];
if (!list.length) return false;
const ok = list.length === 1
? this.model.moveAnnot(list[0], patch)
: this.model.moveAnnots(list, patch);
this.strip.refreshAllOverlays(this.selection);
this.host.onSelectionChange?.(this.selected);
this.host.onChange?.();
return ok;
}
deleteSelected() {
const list = [...this.selection];
if (!list.length) return false;
const ok = list.length === 1
? this.model.removeAnnot(list[0])
: this.model.removeAnnots(list);
this._setSelected(null);
this.host.onChange?.();
return ok;
}
/** Drop a copy of each selected mark, offset so they are visibly seconds. */
duplicateSelected() {
const originals = this.selectedAnnots();
if (!originals.length) return null;
const off = 12;
const clone = (a) => {
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
delete copy.id;
delete copy._w;
delete copy._editing;
return copy;
};
if (originals.length === 1) {
const made = this.model.addAnnot(clone(originals[0]));
this._setSelected(made.id);
this.host.onChange?.();
return made;
}
// One step for the group, and the copies end up selected so the next
// drag moves the copies rather than the originals.
const made = [];
this.model.edit(`duplicate ${originals.length} marks`, () => {
for (const a of originals) made.push(this.model.pushAnnot(clone(a)));
return true;
});
this._setSelection(made.map((m) => m.id));
this.host.onChange?.();
return made;
}
// ---- pointer ------------------------------------------------------
onDown(e) {
if (e.button !== 0) return;
// A resize handle is checked before anything else, and whatever tool is
// armed. Two reasons it comes first: it sits outside the mark's own
// bounds, so the page under it may not even be the page the mark is on;
// and a shape is selected the instant it is drawn, so grabbing a corner
// has to work right then, without a detour through the select tool to
// resize the thing you are still looking at.
//
// It cannot be confused with drawing. A handle is a 9 px square that
// exists only while something is selected; nobody aims at one by
// accident, and Escape drops the selection if the space is wanted back.
const handle = e.target?.closest?.(".handle")?.dataset?.handle;
if (handle && this.selected) {
const a = this.model.annot(this.selected);
const ov = a && this.strip.overlayFor(a.page);
if (a && ov?.viewport && handle === "rotate") {
// Turning is measured from the mark's centre, so the grip follows the
// pointer round rather than jumping to it.
const b = boundsOf(a);
this.drag = {
kind: "rotate", uid: a.page, id: a.id, orig: structuredClone(a),
cx0: a.x + a.w / 2, cy0: a.y + a.h / 2,
from: null, cx: e.clientX, cy: e.clientY, moved: false, box: b,
};
e.preventDefault();
return;
}
if (a && ov?.viewport) {
const view = this.strip.views.get(a.page);
const r = view.pv.div.getBoundingClientRect();
const [hx, hy] = ov.toPdf(e.clientX - r.left, e.clientY - r.top);
this.drag = {
kind: "resize", uid: a.page, id: a.id, handle,
x0: hx, y0: hy, orig: structuredClone(a), from: boundsOf(a),
cx: e.clientX, cy: e.clientY, moved: false,
};
e.preventDefault();
return;
}
}
// Dragging what is already selected moves it, whatever tool is armed.
// A tool drops back to select after it stamps, but a shape tool stays
// armed for the next shape — so the mark you just drew could be resized
// by its handles and not moved by its middle, which is a strange half of
// an editor. Only the SELECTED mark does this: a press anywhere else
// still draws.
if (this.selected && this.tool !== "select" &&
e.target?.closest?.(".mark")?.dataset?.id === this.selected) {
const a = this.model.annot(this.selected);
const spot = this.strip.pageAt(e.clientX, e.clientY);
const ovm = a && this.strip.overlayFor(a.page);
if (a && spot && spot.uid === a.page && ovm?.viewport) {
const [mx, my] = ovm.toPdf(spot.vx, spot.vy);
this.drag = this._beginMove(a.page, a.id, mx, my, e);
e.preventDefault();
return;
}
}
const hit = this.strip.pageAt(e.clientX, e.clientY);
if (!hit) return;
const ov = this.strip.overlayFor(hit.uid);
if (!ov || !ov.viewport) return;
if (TEXT_MARKUP.has(this.tool)) return; // let the browser select text
const [x, y] = ov.toPdf(hit.vx, hit.vy);
if (this.tool === "select") {
const id = e.target?.closest?.(".mark")?.dataset?.id || null;
// Double-click is detected here rather than from a `dblclick` listener.
// Selecting a mark calls preventDefault() on the pointerdown, and that
// suppresses the compatibility mouse events the browser would otherwise
// synthesise a dblclick from — so the native event never arrives.
const now = Date.now();
const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
this._lastClick = id ? { id, t: now } : null;
// A second kind of double-click, by POSITION rather than by mark: the
// page itself and the document's own words are not marks, and both
// answer to being double-clicked.
const near = this._lastPress &&
Math.hypot(e.clientX - this._lastPress.x, e.clientY - this._lastPress.y) < 6;
const doubleAnywhere = !!near && now - this._lastPress.t < DOUBLE_CLICK_MS;
this._lastPress = { x: e.clientX, y: e.clientY, t: now };
if (!id) {
const span = e.target?.closest?.(".textLayer span");
// Double-click on the document's own words: replace that run, the
// same thing the text tool does, because "edit what is there" and
// "select something" are one gesture in every editor people already
// know.
if (doubleAnywhere && span && span.textContent.trim()) {
this._setSelected(null);
e.preventDefault();
this._replaceRunAt(e, hit.uid);
return;
}
// Double-click on bare page: start typing there.
if (doubleAnywhere && !span) {
this._setSelected(null);
e.preventDefault();
this.host.beginText?.({ uid: hit.uid, x, y });
return;
}
// Empty page: start a rubber band. A press that never moves is just
// a click on nothing, and onUp treats it as one — it clears the
// selection, exactly as it always did.
this._setSelected(null);
// ...unless the press is on one of the document's own form fields.
// Starting a band there means calling preventDefault, and that is
// what gives a text field its caret, so the field would go dead.
if (e.target?.closest?.(".annotationLayer")) return;
// ...or on the document's own words, where a drag means selecting
// them to copy. A band is started from the space between things,
// which is where a hand reaches for one anyway — except on a page
// that is wall-to-wall text and has no such space, which is what
// Alt is for.
if (span && !e.altKey) return;
this.drag = { kind: "marquee", uid: hit.uid, x0: x, y0: y, x, y,
cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault();
return;
}
// Shift adds to, or takes away from, what is already selected.
if (e.shiftKey) {
const next = new Set(this.selection);
next.has(id) ? next.delete(id) : next.add(id);
this._setSelection([...next]);
e.preventDefault();
return;
}
// Pressing on a mark that is already part of a group keeps the group,
// so the drag moves all of it. Pressing on any other mark selects just
// that one.
if (!this.selection.has(id)) this._setSelected(id);
e.preventDefault();
if (double && this.selection.size === 1) {
const a = this.model.annot(id);
// A stamp is edited where it sits; a replaced run keeps its dialog,
// which is where it explains what it is replacing.
if (a?.kind === "text") { this.host.beginText?.({ id }); return; }
if (a?.kind === "textedit") { this.host.editText?.(id, null); return; }
}
this.drag = this._beginMove(hit.uid, id, x, y, e);
return;
}
if (this.tool === "text") {
// One text tool, two jobs, told apart by what is under the click:
// words that are already in the document get replaced, and anywhere
// else gets a caret. They were two buttons, which asked the user to
// decide in advance something the click already says.
e.preventDefault();
if (this._replaceRunAt(e, hit.uid)) return;
this.host.beginText?.({ uid: hit.uid, x, y });
return;
}
if (this.tool === "signature") {
this._placeSignature(hit.uid, x, y);
e.preventDefault();
return;
}
// Drawing tools: start a drag with a live preview.
this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
pts: this.tool === "pen" ? [[x, y]] : null,
cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault();
}
/**
* If the press landed on a run of the document's own text, open it for
* replacement and say so.
*
* A text-layer span IS a run, which is why this is a one-line test: pdf.js
* groups glyphs into spans by the same runs the PDF itself declares.
* @returns true when it took the click.
*/
_replaceRunAt(e, uid) {
const span = e.target?.closest?.(".textLayer span");
if (!span || !span.textContent.trim()) return false;
const run = this.describeRun(span, uid);
if (!run) return false;
// A press on text starts a browser selection; the dialog is about to
// cover it, and a half-highlighted line underneath reads as a fault.
try { window.getSelection()?.removeAllRanges(); } catch {}
this.host.editRun?.(run);
return true;
}
/**
* A move drag, of one mark or of the whole group.
*
* `origs` holds every mark's starting geometry: each one shifts from where
* IT was, not from where the one under the cursor was.
*/
_beginMove(uid, id, x, y, e) {
const ids = this.selection.has(id) ? [...this.selection] : [id];
const origs = new Map();
for (const i of ids) {
const a = this.model.annot(i);
// A group can only be dragged within one page: a mark carries its page,
// and PDF user space means nothing across two of them.
if (a && a.page === uid) origs.set(i, structuredClone(a));
}
return { kind: "move", uid, id, x0: x, y0: y, origs,
orig: origs.get(id) || structuredClone(this.model.annot(id)),
cx: e.clientX, cy: e.clientY, moved: false };
}
onMove(e) {
const d = this.drag;
if (!d) return;
const ov = this.strip.overlayFor(d.uid);
if (!ov || !ov.viewport) return;
const view = this.strip.views.get(d.uid);
if (!view) { this.drag = null; return; }
const rect = view.pv.div.getBoundingClientRect();
// A drag that wanders off the page stays on it. Marks outside the page
// box are invisible in any PDF reader, so letting one be drawn there
// would only produce something that disappears on save.
const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
d.x = x; d.y = y;
if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
if (d.kind === "marquee") {
this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y });
return;
}
if (d.kind === "rotate") {
const a = this.model.annot(d.id);
if (!a) return;
// User space has y upwards, so a turn that looks clockwise on screen is
// counter-clockwise here; negating keeps "drag right" meaning "turn
// right" to the person doing it.
const angle = -Math.atan2(y - d.cy0, x - d.cx0) * 180 / Math.PI - 90;
if (d.from === null) d.from = angle - (d.orig.rotate || 0);
let deg = angle - d.from;
// Shift snaps, for the common case of straightening something by a few
// degrees or turning it exactly a quarter.
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
a.rotate = ((deg % 360) + 360) % 360;
d.moved = true;
this.strip.refreshOverlay(d.uid, this.selection);
this.host.rotating?.(a.rotate);
return;
}
if (d.kind === "move") {
// Live and un-journalled; the whole gesture is committed once on release
// so undo steps back over the drag, not over each mouse move.
for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
if (a) Object.assign(a, translatePatch(orig, x - d.x0, y - d.y0));
}
d.moved = true;
this.strip.refreshOverlay(d.uid, this.selection);
return;
}
if (d.kind === "resize") {
const a = this.model.annot(d.id);
if (!a) return;
// Always mapped from the ORIGINAL geometry, never from the last frame,
// so a drag back to where it started restores the mark exactly.
if (d.handle === "p1" || d.handle === "p2") {
Object.assign(a, d.handle === "p1" ? { x1: x, y1: y } : { x2: x, y2: y });
} else {
const uniform = d.orig.kind === "text" || d.orig.kind === "signature";
const to = resizeBox(d.from, d.handle, x - d.x0, y - d.y0, { uniform });
Object.assign(a, scaleToBox(d.orig, d.from, to));
}
d.moved = true;
this.strip.refreshOverlay(d.uid, d.id);
return;
}
if (d.kind === "pen") { d.pts.push([x, y]); }
this.strip.setPreview(d.uid, this._previewAnnot(d));
}
onUp(e) {
// A handle or move drag is finished here whatever tool is armed. Without
// this the text-markup tools would return on the line below and strand a
// resize half-done: applied to the live mark, never journalled, and with
// this.drag still set so the next press behaves oddly.
const inFlight = this.drag;
const live = inFlight && ["resize", "move", "rotate", "marquee"].includes(inFlight.kind);
if (!live && TEXT_MARKUP.has(this.tool)) {
this._commitTextMarkup();
return;
}
const d = inFlight;
this.drag = null;
if (!d) return;
this.strip.setPreview(null, null);
if (d.kind === "marquee") {
// A press that never moved is a click on empty space: the selection is
// already cleared, and there is nothing to gather.
if (d.moved) this._selectWithin(d.uid, d.x0, d.y0, d.x, d.y);
else this.strip.refreshAllOverlays(this.selection);
this.host.onChange?.();
return;
}
if (d.kind === "move" || d.kind === "resize" || d.kind === "rotate") {
const a = this.model.annot(d.id);
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; }
if (d.origs && d.origs.size > 1) this._commitGroupMove(d);
else this._commitLiveEdit(a, d.orig);
this._setSelection([...this.selection]);
this.host.onChange?.();
return;
}
const a = this._previewAnnot(d, true);
if (a) {
const made = this.model.addAnnot(a);
this._setSelected(made.id);
} else {
this.strip.refreshAllOverlays(this.selection);
}
this.host.onChange?.();
}
// A live drag mutates the mark in place so the screen keeps up. To land it
// on the undo stack as ONE step, put the original back and re-apply the
// result through the model.
/**
* Everything the rubber band touched, on the page it was drawn on.
*
* Touching counts, not enclosing. A band you have to draw right around a
* long arrow to catch it is a band you draw twice; catching what you swept
* over is what people expect from sweeping.
*/
_selectWithin(uid, x0, y0, x1, y1) {
const box = {
x: Math.min(x0, x1), y: Math.min(y0, y1),
w: Math.abs(x1 - x0), h: Math.abs(y1 - y0),
};
const hits = this.model.annotsFor(uid)
.filter((a) => { const b = boundsOf(a); return b && boxesOverlap(b, box); })
.map((a) => a.id);
this._setSelection(hits);
// Only the empty case is worth a toast; when it caught something, the
// boxes on screen and the status line have already said so.
if (!hits.length) this.host.toast?.("Nothing in that area");
}
/** A group drag, landed on the undo stack as one step. */
_commitGroupMove(d) {
const after = new Map();
for (const id of d.origs.keys()) {
const a = this.model.annot(id);
if (a) after.set(id, structuredClone(a));
}
// Put every mark back before re-applying through the model, so the
// journal's "before" is the state the drag actually started from.
for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
if (a) restoreExactly(a, orig);
}
this.model.moveAnnots([...after.keys()], (a) => {
const to = after.get(a.id);
const patch = {};
for (const k of Object.keys(to)) {
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = to[k];
}
return patch;
});
}
_commitLiveEdit(a, orig) {
const after = structuredClone(a);
restoreExactly(a, orig);
const patch = {};
for (const k of Object.keys(after)) {
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = after[k];
}
this.model.moveAnnot(a.id, patch);
}
// The mark a drag currently describes. `final` rejects degenerate ones so a
// stray click does not leave an invisible zero-size rectangle behind.
_previewAnnot(d, final = false) {
const common = { page: d.uid, color: this.color, width: this.width };
switch (d.kind) {
case "pen": {
if (final && d.pts.length < 2) return null;
return { kind: "pen", ...common, pts: d.pts.slice() };
}
case "arrow": case "line": {
if (final && !d.moved) return null;
return { kind: d.kind, ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
}
case "rect": case "ellipse": case "redact": {
const b = normBox(d.x0, d.y0, d.x, d.y);
if (final && (b.w < 2 || b.h < 2)) return null;
if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b };
return { kind: d.kind, ...common, ...b, fill: !!this.fill, opacity: this.fillOpacity ?? 0.25 };
}
default: return null;
}
}
/**
* Everything needed to replace one run of the document's text, in the
* coordinates the rest of the editor speaks.
*
* The text layer is the source: pdf.js has already placed a span over every
* run, and its `--font-height` carries the run's size in unscaled PDF points
* — so the size survives zooming, which reading it off the rendered box
* would not.
*/
describeRun(span, uid) {
const ov = this.strip.overlayFor(uid);
const view = this.strip.views.get(uid);
if (!ov?.viewport || !view) return null;
const pr = view.pv.div.getBoundingClientRect();
const r = span.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return null;
const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top);
const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top);
const box = normBox(ax, ay, bx, by);
const declared = parseFloat(getComputedStyle(span).getPropertyValue("--font-height"));
const size = Number.isFinite(declared) && declared > 0 ? declared : box.h * 0.8;
// PDF text sits on a baseline; the span's top is the top of the em box,
// so the baseline hangs one ascent below it.
const baseline = box.y + box.h - size * ASCENT;
// The span does NOT enclose the glyphs. pdf.js sizes it to the em box, so
// every descender — the tail of a y, a comma — falls below it, and a cover
// cut to the span leaves a row of little hooks showing under the
// replacement. Reach below the baseline far enough to take them, and a
// hair above and to the sides for accents and side bearings.
const bottom = Math.min(box.y, baseline - size * DESCENDER);
const top = box.y + box.h + size * 0.06;
return {
uid,
text: span.textContent,
box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom },
size,
baseline,
clientRect: { left: r.left, top: r.top, width: r.width, height: r.height },
};
}
/** Commit a replaced run as a mark, selected and ready to nudge. */
replaceRun(run, props) {
const a = this.model.addAnnot({
kind: "textedit", page: run.uid,
x: run.box.x, y: run.box.y, w: run.box.w, h: run.box.h,
baseline: run.baseline,
text: props.text, size: props.size ?? run.size,
color: props.color || "#000000",
cover: props.cover || "#ffffff",
bold: !!props.bold, italic: !!props.italic,
});
this._afterStamp(a);
return a;
}
// ---- text markup --------------------------------------------------
// Take the live selection's client rects, drop them onto whichever page each
// one sits on, and convert to user space. A selection spanning a page break
// therefore produces one mark per page, which is also what the PDF format
// wants. Highlight, underline and strikeout differ only in `kind`.
_commitTextMarkup() {
const kind = this.tool;
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
const byPage = new Map();
for (let i = 0; i < sel.rangeCount; i++) {
for (const cr of sel.getRangeAt(i).getClientRects()) {
if (cr.width < 0.5 || cr.height < 0.5) continue;
const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2);
if (!hit) continue;
const ov = this.strip.overlayFor(hit.uid);
if (!ov || !ov.viewport) continue;
const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect();
const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top);
const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top);
const box = normBox(ax, ay, bx, by);
if (box.w < 0.5 || box.h < 0.5) continue;
if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
byPage.get(hit.uid).push(box);
}
}
if (!byPage.size) return;
let last = null;
for (const [uid, rects] of byPage) {
last = this.model.addAnnot({
kind, page: uid, color: this.color,
opacity: kind === "highlight" ? 0.38 : 1,
width: kind === "highlight" ? undefined : Math.max(0.8, this.width * 0.55),
rects: mergeRows(rects),
});
}
sel.removeAllRanges();
this._setSelected(last ? last.id : null);
this.host.onChange?.();
}
// ---- stamps -------------------------------------------------------
/** Place a new text mark, or rewrite an existing one, as one undo step. */
/**
* The colour to write WORDS in.
*
* The palette's default is highlighter yellow, which is right for a
* highlight and unreadable as text on a white page. A signature already
* made this swap; text was left behind, and you now watch yourself type
* in it, so it is impossible to miss.
*/
get ink() { return this.color === "#ffd400" ? "#1a1f2b" : this.color; }
placeText({ uid, x, y }, props) {
if (!String(props.text || "").trim()) return null;
const a = this.model.addAnnot({
kind: "text", page: uid, color: this.ink, x, y,
size: props.size, text: props.text, bold: !!props.bold, italic: !!props.italic,
});
this._afterStamp(a);
return a;
}
updateText(id, props) {
const a = this.model.annot(id);
if (!a) return null;
if (!String(props.text || "").trim()) { this.deleteSelected(); return null; }
this.model.moveAnnot(id, {
text: props.text, size: props.size, bold: !!props.bold, italic: !!props.italic,
});
delete a._w; // width changed; let the renderer re-measure
this._setSelected(id);
this.host.onChange?.();
return a;
}
async _placeSignature(uid, x, y) {
// Which signature is decided when the tool is armed, not on every click,
// so placing three initials in three boxes is three clicks. This is only
// the fallback for a tool armed with nothing chosen yet.
let sig = this.host.signature();
if (!sig) {
sig = await this.host.chooseSignature?.();
if (!sig) return;
}
const w = SIG_WIDTH_PT;
const h = w * (sig.aspect || 0.34);
const a = this.model.addAnnot({
// The ink the signature was drawn in, not whatever swatch is armed.
kind: "signature", page: uid, color: sig.color || this.ink,
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
});
this._afterStamp(a);
}
// Drop back to select with the new mark live, so it can be nudged, resized
// or deleted straight away and a second click does not stamp a duplicate.
_afterStamp(a) {
this.host.selectTool?.("select");
this._setSelected(a.id);
this.host.onChange?.();
}
/** Bounds of the current selection, for the status line. */
selectedBounds() {
const a = this.model.annot(this.selected);
return a ? boundsOf(a) : null;
}
}
// Client rects for a text selection come one per text run, so a single
// highlighted line arrives as a dozen slivers. Merging the ones that share a
// baseline turns them back into one band per line — fewer objects in the saved
// file, and no seams where two slivers overlap at 38% opacity.
function mergeRows(rects) {
const rows = [];
for (const r of rects.slice().sort((a, b) => b.y - a.y || a.x - b.x)) {
const row = rows.find((q) => Math.abs((q.y + q.h / 2) - (r.y + r.h / 2)) < Math.min(q.h, r.h) * 0.5);
if (!row) { rows.push({ ...r }); continue; }
const x0 = Math.min(row.x, r.x), x1 = Math.max(row.x + row.w, r.x + r.w);
const y0 = Math.min(row.y, r.y), y1 = Math.max(row.y + row.h, r.y + r.h);
row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0;
}
return rows;
}