// 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, 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) { 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) { // A handle 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; if (!(inFlight && inFlight.kind === "resize") && 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 === "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; }