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.
429 lines
17 KiB
JavaScript
429 lines
17 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 { 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;
|
|
}
|