feat(pdf-editor): a mark you placed is something you can still work on
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.
2026-09-21 03:13:06 +02:00
|
|
|
// 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 { boundsOf, 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
|
|
|
|
|
|
|
|
|
|
/** 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(["text", "signature"]);
|
|
|
|
|
|
|
|
|
|
export class Tools {
|
|
|
|
|
constructor({ strip, model, host }) {
|
|
|
|
|
this.strip = strip;
|
|
|
|
|
this.model = model;
|
|
|
|
|
// host (editor.js): { toast, onChange, selectTool, onSelectionChange,
|
|
|
|
|
// editText, openSignaturePad, 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;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get mode() {
|
|
|
|
|
if (this.tool === "select") return "select";
|
|
|
|
|
if (TEXT_MARKUP.has(this.tool)) return "highlight";
|
|
|
|
|
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.selected = id;
|
|
|
|
|
this.strip.refreshAllOverlays(id);
|
|
|
|
|
this.host.onSelectionChange?.(id);
|
|
|
|
|
}
|
|
|
|
|
select(id) { this._setSelected(id); }
|
|
|
|
|
|
|
|
|
|
setColor(c) {
|
|
|
|
|
this.color = c;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && a.kind !== "redact") this.patchSelected({ color: c });
|
|
|
|
|
}
|
|
|
|
|
setFill(on) {
|
|
|
|
|
this.fill = !!on;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && (a.kind === "rect" || a.kind === "ellipse")) this.patchSelected({ fill: this.fill });
|
|
|
|
|
}
|
|
|
|
|
setWidth(w) {
|
|
|
|
|
this.width = w;
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Change the selected mark, as one undo step. */
|
|
|
|
|
patchSelected(patch) {
|
|
|
|
|
if (!this.selected) return false;
|
|
|
|
|
const ok = this.model.moveAnnot(this.selected, patch);
|
|
|
|
|
this.strip.refreshAllOverlays(this.selected);
|
|
|
|
|
this.host.onSelectionChange?.(this.selected);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteSelected() {
|
|
|
|
|
if (!this.selected) return false;
|
|
|
|
|
const ok = this.model.removeAnnot(this.selected);
|
|
|
|
|
this._setSelected(null);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Drop a copy of the selected mark, offset so it is visibly a second one. */
|
|
|
|
|
duplicateSelected() {
|
|
|
|
|
const a = this.model.annot(this.selected);
|
|
|
|
|
if (!a) return null;
|
|
|
|
|
const off = 12;
|
|
|
|
|
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
|
|
|
|
|
delete copy.id;
|
|
|
|
|
delete copy._w;
|
|
|
|
|
const made = this.model.addAnnot(copy);
|
|
|
|
|
this._setSelected(made.id);
|
|
|
|
|
this.host.onChange?.();
|
|
|
|
|
return made;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- pointer ------------------------------------------------------
|
|
|
|
|
onDown(e) {
|
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
|
|
|
|
|
// A resize handle is checked before anything else: it sits outside the
|
|
|
|
|
// mark's own bounds, so the page under it may not even be the page the
|
|
|
|
|
// mark is on.
|
|
|
|
|
if (this.tool === "select") {
|
|
|
|
|
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) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
this._setSelected(id);
|
|
|
|
|
if (!id) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (double) {
|
|
|
|
|
const a = this.model.annot(id);
|
|
|
|
|
if (a?.kind === "text") { this.host.editText?.(id, null); return; }
|
|
|
|
|
}
|
|
|
|
|
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y,
|
|
|
|
|
orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.tool === "text") {
|
|
|
|
|
this.host.editText?.(null, { uid: hit.uid, x, y });
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 === "move") {
|
|
|
|
|
const a = this.model.annot(d.id);
|
|
|
|
|
if (!a) return;
|
|
|
|
|
// Live and un-journalled; the whole gesture is committed once on release
|
|
|
|
|
// so undo steps back over the drag, not over each mouse move.
|
|
|
|
|
Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0));
|
|
|
|
|
d.moved = true;
|
|
|
|
|
this.strip.refreshOverlay(d.uid, d.id);
|
|
|
|
|
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) {
|
|
|
|
|
if (TEXT_MARKUP.has(this.tool)) { this._commitTextMarkup(); return; }
|
|
|
|
|
const d = this.drag;
|
|
|
|
|
this.drag = null;
|
|
|
|
|
if (!d) return;
|
|
|
|
|
this.strip.setPreview(null, null);
|
|
|
|
|
|
|
|
|
|
if (d.kind === "move" || d.kind === "resize") {
|
|
|
|
|
const a = this.model.annot(d.id);
|
|
|
|
|
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
|
|
|
|
|
this._commitLiveEdit(a, d.orig);
|
|
|
|
|
this._setSelected(d.id);
|
|
|
|
|
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.selected);
|
|
|
|
|
}
|
|
|
|
|
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.
|
|
|
|
|
_commitLiveEdit(a, orig) {
|
|
|
|
|
const after = structuredClone(a);
|
|
|
|
|
Object.assign(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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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. */
|
|
|
|
|
placeText({ uid, x, y }, props) {
|
|
|
|
|
if (!String(props.text || "").trim()) return null;
|
|
|
|
|
const a = this.model.addAnnot({
|
|
|
|
|
kind: "text", page: uid, color: this.color, 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) {
|
|
|
|
|
let sig = this.host.signature();
|
|
|
|
|
if (!sig) {
|
|
|
|
|
sig = await this.host.openSignaturePad();
|
|
|
|
|
if (!sig) return;
|
|
|
|
|
}
|
|
|
|
|
const w = SIG_WIDTH_PT;
|
|
|
|
|
const h = w * (sig.aspect || 0.34);
|
|
|
|
|
const a = this.model.addAnnot({
|
|
|
|
|
kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
|
|
|
|
|
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;
|
|
|
|
|
}
|