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.
This commit is contained in:
Local Dev 2026-09-21 03:13:06 +02:00
parent cb7ffafcce
commit 0684906f13
8 changed files with 2463 additions and 1821 deletions

View file

@ -1,8 +1,8 @@
{
"id": "pdf-editor",
"name": "PDF Editor",
"version": "0.1.2",
"description": "Open a PDF in a full tab: read it, highlight and mark it up, reorder or rotate or drop pages, fill in its form fields, then save a new copy. Annotations are written into the saved file as real PDF content. The original is never touched.",
"version": "0.2.0",
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js",

View file

@ -188,11 +188,11 @@ body[data-dragover="1"] .dz-inner { border-color: rgb(from var(--acid) r g b / .
/* Our overlay sits above the canvas but below the annotation (form) layer, so
a form field stays clickable even with marks drawn around it. */
/* Clipped to the page, because the saved file is too: a PDF viewer shows
nothing outside the crop box, so a mark that spilled past the edge on
screen would simply vanish on save. */
/* Marks are clipped to the page by a clipPath on the marks group, not by the
SVG's own overflow — the selection handles sit just outside a mark's bounds
and a mark flush with the page edge still has to be grabbable. */
.pdfViewer .page .smOverlay { position: absolute; inset: 0; z-index: 3;
pointer-events: none; overflow: hidden; }
pointer-events: none; overflow: visible; }
.pdfViewer .page .annotationLayer { z-index: 4; }
.pdfViewer .page .textLayer { z-index: 2; }
@ -225,3 +225,47 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all;
.annotationLayer .textWidgetAnnotation input,
.annotationLayer .textWidgetAnnotation textarea,
.annotationLayer .choiceWidgetAnnotation select { font: inherit; }
/* ---- selection bar --------------------------------------------------- */
/* Pinned to the current selection in viewport coordinates, so a page edge
never clips it and moving the selection to another page needs no
re-parenting. It steps aside mid-drag rather than jittering under the
cursor. */
.selbar {
position: fixed; z-index: 40; display: flex; align-items: center; gap: 2px;
padding: 3px; border-radius: 8px; background: var(--panel);
border: 1px solid var(--line); box-shadow: 0 6px 22px var(--shadow);
user-select: none;
}
body[data-dragging="1"] .selbar,
.selbar.offscreen { opacity: 0; pointer-events: none; }
.selbar .sb {
border: 1px solid transparent; background: transparent; color: var(--ink);
min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer; padding: 0 5px;
display: inline-flex; align-items: center; justify-content: center;
font: inherit; font-size: 13px; line-height: 1;
}
.selbar .sb svg { width: 15px; height: 15px; display: block; }
.selbar .sb:hover { background: var(--panel2); border-color: rgb(from var(--acid) r g b / .5); }
.selbar .sb.active { background: rgb(from var(--acid) r g b / .16);
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
.selbar .sb.danger:hover { border-color: rgba(255,91,91,.6); color: var(--danger); }
.selbar .sb-group { display: inline-flex; align-items: center; gap: 1px; }
.selbar .sb-val { min-width: 22px; text-align: center; font-size: 11.5px;
color: var(--mut); font-variant-numeric: tabular-nums; }
.selbar .sb-sep { width: 1px; height: 17px; background: var(--line); margin: 0 3px; }
/* Style toggles inside the text dialog. */
.btn.styl { width: 30px; font-size: 14px; line-height: 1; }
.btn.styl.active { background: rgb(from var(--acid) r g b / .16);
border-color: rgb(from var(--acid) r g b / .7); color: var(--acid); }
/* Resize handles. Only grabbable with the select tool armed, so a drawing
drag near a selected mark is never stolen by one. */
body[data-mode="select"] .smOverlay .handle { pointer-events: all; }
.smOverlay .handle { pointer-events: none; }
/* The invisible grab area under a thin stroke. `stroke` rather than
`visibleStroke`: the copy is transparent on purpose. */
body[data-mode="select"] .smOverlay .mark .hit { pointer-events: stroke; }
.smOverlay .mark .hit { pointer-events: none; }

View file

@ -38,6 +38,12 @@
<button class="tool" data-tool="highlight" title="Highlight — drag across text (H)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h10"/><path d="M5 9.5l4.5-6.5 3 2L8 11.5z"/></svg>
</button>
<button class="tool" data-tool="underline" title="Underline — drag across text (U)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 2v5a4 4 0 0 0 8 0V2"/><path d="M3 14h10"/></svg>
</button>
<button class="tool" data-tool="strikeout" title="Strike through — drag across text (K)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M4 3.5h8"/><path d="M8 3.5V12"/><path d="M2.5 8h11"/></svg>
</button>
<button class="tool" data-tool="pen" title="Pen — freehand (P)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14c1-3 3-6 6-8s5-3 6-3l-3 4c-2 1-4 3-6 4s-2 2-3 3z"/></svg>
</button>
@ -47,6 +53,9 @@
<button class="tool" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><ellipse cx="8" cy="8" rx="5.5" ry="4"/></svg>
</button>
<button class="tool" data-tool="line" title="Line (L)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 13L13 3"/></svg>
</button>
<button class="tool" data-tool="arrow" title="Arrow (A)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13L13 3M13 3H8M13 3v5"/></svg>
</button>
@ -73,6 +82,9 @@
<button class="width" data-width="1.5" title="Thin"><span class="dot" style="width:4px;height:4px"></span></button>
<button class="width active" data-width="2.5" title="Medium"><span class="dot" style="width:7px;height:7px"></span></button>
<button class="width" data-width="4.5" title="Thick"><span class="dot" style="width:11px;height:11px"></span></button>
<button class="btn" id="toggle-fill" title="Fill shapes as well as outline them">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/><path d="M2.5 9.5h11v3h-11z" fill="currentColor" stroke="none"/></svg>
</button>
</div>
<div class="tgroup" role="group" aria-label="History">
@ -176,16 +188,19 @@
</div>
<div class="modal" id="modal-text" hidden>
<h2>Text stamp</h2>
<h2 id="modal-text-title">Text stamp</h2>
<textarea id="text-body" rows="4" placeholder="Type the text to place…"></textarea>
<div class="mrow">
<label class="mfield">Size
<select id="text-size">
<option value="8">8</option><option value="10">10</option>
<option value="12" selected>12</option><option value="14">14</option>
<option value="18">18</option><option value="24">24</option><option value="36">36</option>
<option value="18">18</option><option value="24">24</option>
<option value="36">36</option><option value="48">48</option><option value="72">72</option>
</select>
</label>
<button class="btn styl" id="text-bold" type="button" title="Bold"><b>B</b></button>
<button class="btn styl" id="text-italic" type="button" title="Italic"><i>I</i></button>
<span class="spacer"></span>
<button class="btn wide" id="text-cancel">Cancel</button>
<button class="btn wide primary" id="text-ok">Place</button>
@ -207,6 +222,34 @@
</div>
</div>
<!-- Pinned to the current selection; see paintSelectionBar in editor.js. -->
<div class="selbar" id="selbar" hidden>
<button class="sb" id="sb-edit" title="Edit this text (or double-click it)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2.5l2.5 2.5L6 12.5 3 13l.5-3z"/></svg>
</button>
<span class="sb-group" id="sb-textsize">
<button class="sb" id="sb-smaller" title="Smaller">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3.5 8h9"/></svg>
</button>
<span class="sb-val" id="sb-size">12</span>
<button class="sb" id="sb-bigger" title="Bigger">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg>
</button>
</span>
<button class="sb" id="sb-bold" title="Bold"><b>B</b></button>
<button class="sb" id="sb-italic" title="Italic"><i>I</i></button>
<button class="sb" id="sb-fill" title="Fill">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="3.5" width="11" height="9"/><path d="M2.5 9.5h11v3h-11z" fill="currentColor" stroke="none"/></svg>
</button>
<span class="sb-sep"></span>
<button class="sb" id="sb-dup" title="Duplicate">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5.5" y="5.5" width="8" height="8" rx="1"/><path d="M3.5 10.5V3a1 1 0 0 1 1-1H11"/></svg>
</button>
<button class="sb danger" id="sb-del" title="Delete (Del)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h10"/><path d="M6 4V2h4v2"/><path d="M4 4l1 10h6l1-10"/><path d="M7 6.5v5M9 6.5v5"/></svg>
</button>
</div>
<div class="toast" id="toast"></div>
<input type="file" id="file-input" accept="application/pdf,.pdf" hidden>
<a id="download-link" style="display:none"></a>

View file

@ -20,6 +20,7 @@ import { Rail } from "./lib/rail.js";
import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js";
import { buildPdf, unencodableStamps } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
const $ = (id) => document.getElementById(id);
const sm = window.silentmode || null;
@ -191,8 +192,11 @@ async function loadBytes(bytes, name, scratchId = null) {
session.tools = new Tools({
strip: session.strip, model: session.model,
host: {
toast, onChange: () => { paintChrome(); },
openTextModal: onTextModal,
toast,
onChange: () => { paintChrome(); },
selectTool,
onSelectionChange: paintSelectionBar,
editText: onTextModal,
openSignaturePad: onSignaturePad,
signature: () => savedSignature,
},
@ -333,6 +337,16 @@ $("restore-pages").addEventListener("click", () => {
let redactAcknowledged = false;
let flattenRedactions = true;
/**
* Arm a tool and sync the toolbar. Tools calls this itself after placing a
* stamp, so the button highlight and the armed tool cannot drift apart.
*/
function selectTool(t) {
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o.dataset.tool === t);
session.tools?.setTool(t);
status(TOOL_HINTS[t] || "");
}
for (const b of document.querySelectorAll(".tool[data-tool]")) {
b.addEventListener("click", async () => {
const t = b.dataset.tool;
@ -343,20 +357,21 @@ for (const b of document.querySelectorAll(".tool[data-tool]")) {
flattenRedactions = $("redact-flatten").checked;
try { await sm?.storage?.set("redactAcknowledged", true); } catch {}
}
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o === b);
session.tools?.setTool(t);
status(TOOL_HINTS[t] || "");
selectTool(t);
});
}
$("redact-ok").addEventListener("click", () => closeModal(true));
$("redact-cancel").addEventListener("click", () => closeModal(null));
const TOOL_HINTS = {
select: "Click a mark to select it, drag to move it, Delete to remove it.",
select: "Click a mark to select it. Drag to move, drag a handle to resize, double-click text to edit.",
highlight: "Drag across text to highlight it.",
underline: "Drag across text to underline it.",
strikeout: "Drag across text to strike it through.",
pen: "Drag to draw.",
rect: "Drag to draw a rectangle.",
ellipse: "Drag to draw an ellipse.",
line: "Drag to draw a line.",
arrow: "Drag from tail to head.",
text: "Click where the text should start.",
signature: "Click to place your signature.",
@ -369,6 +384,13 @@ for (const s of document.querySelectorAll(".swatch")) {
session.tools?.setColor(s.dataset.color);
});
}
const fillBtn = $("toggle-fill");
if (fillBtn) fillBtn.addEventListener("click", () => {
const on = !fillBtn.classList.contains("active");
fillBtn.classList.toggle("active", on);
session.tools?.setFill(on);
});
for (const w of document.querySelectorAll(".width")) {
w.addEventListener("click", () => {
for (const o of document.querySelectorAll(".width")) o.classList.toggle("active", o === w);
@ -382,8 +404,9 @@ function afterHistory() {
if (!session.model) return;
session.strip.layout();
session.rail.build();
session.tools.selected = null;
session.strip.refreshAllOverlays(null);
// Through select() rather than by assignment, so the selection bar hears
// about it and does not hang over a mark that undo just removed.
session.tools.select(null);
paintChrome();
paintPageInfo();
}
@ -392,10 +415,12 @@ $("zoom").addEventListener("change", (e) => {
const v = e.target.value;
session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
paintPageInfo();
positionSelectionBar();
});
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
function syncZoomSelect() {
positionSelectionBar();
if (!session.strip) return;
const exact = String(session.strip.scale);
const opt = [...$("zoom").options].find((o) => o.value === exact);
@ -418,20 +443,141 @@ window.addEventListener("resize", () => {
if (!session.strip) return;
if (typeof session.strip.scaleMode === "string") { session.strip.computeScale(); session.strip.layout(); }
paintPageInfo();
positionSelectionBar();
}, 140);
});
// ---- selection bar ----------------------------------------------------
//
// A floating strip pinned to whatever is selected, carrying the actions that
// only make sense for that mark: edit and type controls for text, fill for a
// shape, and duplicate/delete for anything. Position is fixed rather than
// parented into the page, so it is never clipped by a page edge and never has
// to be re-homed when the selection moves to another page.
//
// It exists because Delete-on-the-keyboard was the only way to remove a mark,
// which is undiscoverable: nothing on screen said a selected mark could go.
const selbar = $("selbar");
function paintSelectionBar(id) {
const a = id && session.model ? session.model.annot(id) : null;
if (!a) { selbar.hidden = true; return; }
const isText = a.kind === "text";
const isShape = a.kind === "rect" || a.kind === "ellipse";
$("sb-edit").hidden = !isText;
$("sb-textsize").hidden = !isText;
$("sb-bold").hidden = !isText;
$("sb-italic").hidden = !isText;
$("sb-fill").hidden = !isShape;
if (isText) {
$("sb-size").textContent = String(Math.round(a.size || 12));
$("sb-bold").classList.toggle("active", !!a.bold);
$("sb-italic").classList.toggle("active", !!a.italic);
}
if (isShape) $("sb-fill").classList.toggle("active", !!a.fill);
selbar.hidden = false;
positionSelectionBar();
}
function positionSelectionBar() {
const t = session.tools, m = session.model, strip = session.strip;
const a = t && m ? m.annot(t.selected) : null;
if (!a || selbar.hidden) return;
const ov = strip?.overlayFor(a.page);
const view = strip?.views.get(a.page);
if (!ov?.viewport || !view) { selbar.hidden = true; return; }
const b = boundsOf(a);
const [ax, ay] = ov.toView(b.x, b.y + b.h);
const [bx, by] = ov.toView(b.x + b.w, b.y);
const pr = view.pv.div.getBoundingClientRect();
const left = pr.left + Math.min(ax, bx);
const right = pr.left + Math.max(ax, bx);
const top = pr.top + Math.min(ay, by);
const bottom = pr.top + Math.max(ay, by);
const box = selbar.getBoundingClientRect();
const cr = $("viewerContainer").getBoundingClientRect();
const GAP = 10;
// Above the mark by preference; below when there is no room up there.
let y = top - box.height - GAP;
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
let x = (left + right) / 2 - box.width / 2;
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
selbar.style.left = `${Math.round(x)}px`;
selbar.style.top = `${Math.round(y)}px`;
// A selection scrolled out of the viewport takes its bar with it.
selbar.classList.toggle("offscreen", bottom < cr.top || top > cr.bottom);
}
// The bar would jitter under the cursor mid-gesture, so it steps aside while
// a drag is in flight and comes back where the mark ended up.
$("viewerContainer").addEventListener("pointerdown", () => { document.body.dataset.dragging = "1"; });
window.addEventListener("pointerup", () => {
document.body.dataset.dragging = "";
positionSelectionBar();
});
$("viewerContainer").addEventListener("scroll", () => positionSelectionBar(), { passive: true });
$("sb-edit").addEventListener("click", () => {
const id = session.tools?.selected;
if (id) onTextModal(id, null);
});
$("sb-del").addEventListener("click", () => { session.tools?.deleteSelected(); paintChrome(); });
$("sb-dup").addEventListener("click", () => { session.tools?.duplicateSelected(); paintChrome(); });
$("sb-bold").addEventListener("click", () => toggleTextStyle("bold"));
$("sb-italic").addEventListener("click", () => toggleTextStyle("italic"));
$("sb-fill").addEventListener("click", () => {
const a = session.model?.annot(session.tools?.selected);
if (a) session.tools.setFill(!a.fill);
});
$("sb-smaller").addEventListener("click", () => stepTextSize(-1));
$("sb-bigger").addEventListener("click", () => stepTextSize(1));
function toggleTextStyle(which) {
const a = session.model?.annot(session.tools?.selected);
if (!a || a.kind !== "text") return;
delete a._w; // the glyphs change width; re-measure
session.tools.patchSelected({ [which]: !a[which] });
}
function stepTextSize(dir) {
const a = session.model?.annot(session.tools?.selected);
if (!a || a.kind !== "text") return;
const steps = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
const at = a.size || 12;
const next = dir > 0 ? steps.find((v) => v > at + 0.01) : [...steps].reverse().find((v) => v < at - 0.01);
if (next == null) return;
delete a._w;
session.tools.patchSelected({ size: clampFont(next) });
}
// ---- text stamp -------------------------------------------------------
async function onTextModal(at) {
$("text-body").value = "";
/**
* One dialog for both jobs. `id` set means rewrite that mark in place, which
* is what a double-click on placed text opens; `at` set means place a new one.
*/
async function onTextModal(id, at) {
const existing = id ? session.model?.annot(id) : null;
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp";
$("text-body").value = existing ? existing.text : "";
$("text-size").value = String(existing ? existing.size || 12 : 12);
$("text-bold").classList.toggle("active", !!existing?.bold);
$("text-italic").classList.toggle("active", !!existing?.italic);
$("text-warn").hidden = true;
const p = openModal("modal-text");
setTimeout(() => $("text-body").focus(), 30);
setTimeout(() => { $("text-body").focus(); $("text-body").select(); }, 30);
const ok = await p;
if (!ok) return null;
const text = $("text-body").value;
const size = parseFloat($("text-size").value) || 12;
return session.tools.placeText(at, text, size);
const props = {
text: $("text-body").value,
size: parseFloat($("text-size").value) || 12,
bold: $("text-bold").classList.contains("active"),
italic: $("text-italic").classList.contains("active"),
};
return existing ? session.tools.updateText(id, props) : session.tools.placeText(at, props);
}
for (const b of [$("text-bold"), $("text-italic")]) {
b?.addEventListener("click", () => b.classList.toggle("active"));
}
$("text-ok").addEventListener("click", () => closeModal(true));
$("text-cancel").addEventListener("click", () => closeModal(null));
@ -639,7 +785,10 @@ $("viewerContainer").addEventListener("click", (e) => {
// Ctrl+F, Ctrl+B and Ctrl +/-/0 never reach this page — Theseus claims them
// app-wide for find, the add-on sidebar and tab zoom. PDF zoom therefore lives
// on bare +/-/0, which is what desktop readers use anyway.
const TOOL_KEYS = { v: "select", h: "highlight", p: "pen", r: "rect", o: "ellipse", a: "arrow", t: "text", g: "signature" };
const TOOL_KEYS = {
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
};
window.addEventListener("keydown", (e) => {
// A modal owns the keyboard while it is up: Escape cancels, and Enter
// commits unless the focus is in the textarea, where it means a newline.
@ -656,6 +805,11 @@ window.addEventListener("keydown", (e) => {
if (meta && e.key.toLowerCase() === "s") { e.preventDefault(); doSave(); return; }
if (meta && e.key.toLowerCase() === "o") { e.preventDefault(); pick(); return; }
if (meta && e.key.toLowerCase() === "d") {
e.preventDefault();
if (session.tools?.duplicateSelected()) paintChrome();
return;
}
if (meta && e.key.toLowerCase() === "z") {
e.preventDefault();
if (e.shiftKey) session.model?.redo(); else session.model?.undo();
@ -665,16 +819,22 @@ window.addEventListener("keydown", (e) => {
if (typing) return;
if (e.key === "Escape") {
if (session.tools?.tool !== "select") {
document.querySelector('.tool[data-tool="select"]')?.click();
e.preventDefault();
}
// Step back one level at a time: drop the selection first, and only then
// disarm the tool. Doing both at once loses whichever the user meant.
if (session.tools?.selected) { session.tools.select(null); e.preventDefault(); return; }
if (session.tools?.tool !== "select") { selectTool("select"); e.preventDefault(); }
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (session.tools?.deleteSelected()) { paintChrome(); e.preventDefault(); }
return;
}
if (e.key === "Enter") {
const sel = session.tools?.selected;
const a = sel && session.model?.annot(sel);
if (a?.kind === "text") { onTextModal(sel, null); e.preventDefault(); }
return;
}
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }
if (e.key === "-") { session.strip?.stepScale(-1); syncZoomSelect(); e.preventDefault(); return; }
if (e.key === "0") { $("zoom").value = "page-width"; session.strip?.setScaleMode("page-width"); paintPageInfo(); e.preventDefault(); return; }

View file

@ -11,9 +11,12 @@
// hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything.
import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js";
import { arrowBarbs, boundsOf, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
function el(name, attrs) {
const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
@ -24,9 +27,20 @@ export class Overlay {
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
this.marks = el("g", { class: "marks" });
// Marks are clipped to the page, because a PDF reader shows nothing
// outside the crop box and a mark that spilled over the edge on screen
// would vanish on save. The selection chrome is NOT clipped: a mark
// flush against the edge still needs grabbable handles, and those sit
// just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
const defs = el("defs", {});
this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const clip = el("clipPath", { id: this.clipId });
clip.append(this.clipRect);
defs.append(clip);
this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(this.marks, this.chrome);
this.svg.append(defs, this.marks, this.chrome);
pageDiv.append(this.svg);
this.viewport = null;
}
@ -46,13 +60,17 @@ export class Overlay {
render(viewport, annots, selectedId) {
this.viewport = viewport;
this._selId = selectedId;
this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
this.clipRect.setAttribute("width", viewport.width);
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
node.classList.add("mark");
node.dataset.id = a.id;
if (a.id === selectedId) node.classList.add("sel");
@ -70,18 +88,32 @@ export class Overlay {
switch (a.kind) {
case "highlight": {
for (const r of a.rects || []) {
const [x0, y1] = this.toView(r.x, r.y + r.h);
const [x1, y0] = this.toView(r.x + r.w, r.y);
const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
g.append(el("rect", {
x: Math.min(x0, x1), y: Math.min(y0, y1),
width: Math.abs(x1 - x0), height: Math.abs(y0 - y1),
fill: a.color, "fill-opacity": a.opacity ?? 0.38,
...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
// Underline and strikeout share the highlight's geometry — one rect per
// line of selected text — and differ only in where the rule sits inside
// that rect. Keeping them on the same shape means a selection spanning a
// page break splits the same way for all three.
case "underline": case "strikeout": {
const frac = RULE_OFFSET[a.kind];
for (const r of a.rects || []) {
const yy = r.y + r.h * frac;
const [x1, y1] = this.toView(r.x, yy);
const [x2, y2] = this.toView(r.x + r.w, yy);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
}));
}
return g;
}
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
if (pts.length < 2) return null;
@ -94,7 +126,8 @@ export class Overlay {
}
case "rect": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k }));
g.append(el("rect", { ...b, ...this._paint(a) }));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "ellipse": {
@ -102,7 +135,17 @@ export class Overlay {
g.append(el("ellipse", {
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
...this._paint(a),
}));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "line": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
}));
return g;
}
@ -128,17 +171,26 @@ export class Overlay {
x: vx, y: vy, fill: a.color,
"font-family": "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// Measure once attached so the selection box is tight. Cached on the
// mark in user-space units; boundsOf() reads it.
// A string's width is only knowable once the browser has laid it out,
// so it is measured a frame later and cached on the mark in user-space
// units for boundsOf(). The selection box is drawn from that, and was
// drawn before the measurement existed — so redraw it once the real
// width is in, or the box and its handles sit at the fallback guess.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest > 0) a._w = widest / this.k;
if (widest <= 0) return;
const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w;
if (changed && a.id === this._selId) this._drawChrome(a);
} catch {}
});
return g;
@ -165,6 +217,33 @@ export class Overlay {
}
}
/**
* Give a mark something to grab.
*
* A 2 pt outline is about one screen pixel: selecting it means hitting a
* hairline exactly, which is miserable with a mouse and impossible with a
* trackpad. Each stroked shape gets an invisible copy of itself with a fat
* stroke underneath, purely to catch the pointer. `pointer-events: stroke`
* hit-tests the stroke area whatever the paint is, so a transparent one
* still counts.
*/
_addHitArea(g, a) {
if (g.dataset.fill === "1") return; // already a solid target
const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
const clones = [];
for (const child of g.children) {
if (child.tagName === "text") continue;
const c = child.cloneNode(false);
c.setAttribute("stroke", "transparent");
c.setAttribute("stroke-width", wide);
c.setAttribute("fill", "none");
c.removeAttribute("style");
c.setAttribute("class", "hit");
clones.push(c);
}
for (const c of clones) g.insertBefore(c, g.firstChild);
}
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
// corners so a rotated viewport lands the right way up.
_box(a) {
@ -174,6 +253,12 @@ export class Overlay {
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
}
// The selection outline and its grab handles.
//
// Handles are drawn at a fixed pixel size rather than scaled with the page,
// so they stay grabbable at 50% zoom and do not swell into the artwork at
// 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move.
_drawChrome(sel) {
this.chrome.textContent = "";
if (!sel) return;
@ -184,6 +269,46 @@ export class Overlay {
class: "selbox", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
const names = handlesFor(sel);
if (SEGMENT_KINDS.has(sel.kind)) {
// A line's ends are the only meaningful grips; a bounding box would let
// you stretch it in ways that never match what you were aiming at.
const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
return;
}
const L = r.x - pad, R = r.x + r.width + pad;
const T = r.y - pad, B = r.y + r.height + pad;
const MX = (L + R) / 2, MY = (T + B) / 2;
const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
for (const n of names) {
const p = at[n];
if (p) this._handle(n, p[0], p[1], cursor[n]);
}
}
_handle(name, cx, cy, cursor) {
const h = el("rect", {
class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,
width: HANDLE, height: HANDLE, rx: 1.5,
style: `cursor:${cursor}`,
});
h.dataset.handle = name;
this.chrome.append(h);
}
// Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) {
return {
fill: a.fill ? a.color : "none",
"fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
stroke: a.color,
"stroke-width": (a.width || 2) * this.k,
};
}
/** The mark id under a page-relative CSS pixel point, or null. */

View file

@ -27,7 +27,7 @@ import {
LineCapStyle, LineJoinStyle,
} from "../vendor/pdf-lib/pdf-lib.esm.min.js";
import { arrowBarbs, signatureStrokes, textLayout } from "./shape.js";
import { arrowBarbs, signatureStrokes, textLayout, RULE_OFFSET } from "./shape.js";
/** #rrggbb -> pdf-lib rgb(). */
function colorOf(hex) {
@ -97,14 +97,31 @@ function drawMark(page, a, font) {
case "pen":
strokePolyline(page, a.pts || [], a.color, a.width || 2);
return;
case "underline": case "strikeout": {
// Same offset the overlay drew it at, from the same constant.
const frac = RULE_OFFSET[a.kind];
const w = Math.max(0.5, a.width || 1.4);
for (const r of a.rects || []) {
const y = r.y + r.h * frac;
strokePolyline(page, [[r.x, y], [r.x + r.w, y]], a.color, w);
}
return;
}
case "rect":
page.drawRectangle({ x: a.x, y: a.y, width: a.w, height: a.h,
borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 0 });
borderColor: colorOf(a.color), borderWidth: a.width || 2,
color: a.fill ? colorOf(a.color) : undefined,
opacity: a.fill ? (a.opacity ?? 0.25) : 0 });
return;
case "ellipse":
page.drawEllipse({ x: a.x + a.w / 2, y: a.y + a.h / 2,
xScale: Math.max(0.5, a.w / 2), yScale: Math.max(0.5, a.h / 2),
borderColor: colorOf(a.color), borderWidth: a.width || 2, opacity: 0 });
borderColor: colorOf(a.color), borderWidth: a.width || 2,
color: a.fill ? colorOf(a.color) : undefined,
opacity: a.fill ? (a.opacity ?? 0.25) : 0 });
return;
case "line":
strokePolyline(page, [[a.x1, a.y1], [a.x2, a.y2]], a.color, a.width || 2);
return;
case "arrow": {
const w = a.width || 2;
@ -115,10 +132,11 @@ function drawMark(page, a, font) {
}
case "text": {
const size = a.size || 12;
const f = typeof font === "function" ? font(a) : font;
for (const line of textLayout(a)) {
const { text } = winAnsiSafe(line.text);
if (!text) continue;
page.drawText(text, { x: line.x, y: line.y, size, font, color: colorOf(a.color) });
page.drawText(text, { x: line.x, y: line.y, size, font: f, color: colorOf(a.color) });
}
return;
}
@ -219,7 +237,20 @@ export async function buildPdf({ originalBytes, model, formValues = [], flatten
// every document that passes through it.
updateMetadata: false,
});
const font = await doc.embedFont(StandardFonts.Helvetica);
// Four variants, because bold and italic are separate fonts in a PDF rather
// than a style applied to one. Embedded once up front; unused ones cost a
// few hundred bytes of font dictionary and nothing else.
const fonts = {
regular: await doc.embedFont(StandardFonts.Helvetica),
bold: await doc.embedFont(StandardFonts.HelveticaBold),
italic: await doc.embedFont(StandardFonts.HelveticaOblique),
boldItalic: await doc.embedFont(StandardFonts.HelveticaBoldOblique),
};
const font = fonts.regular;
const fontFor = (a) => a.bold && a.italic ? fonts.boldItalic
: a.bold ? fonts.bold
: a.italic ? fonts.italic
: fonts.regular;
const originalPages = doc.getPages();
// 1. form values
@ -248,8 +279,8 @@ export async function buildPdf({ originalBytes, model, formValues = [], flatten
if (!page) continue;
// Redaction boxes go on top of everything else on their page.
const marks = model.annotsFor(uid);
for (const a of marks) if (a.kind !== "redact") { drawMark(page, a, font); report.marks++; }
for (const a of marks) if (a.kind === "redact") { drawMark(page, a, font); report.marks++; }
for (const a of marks) if (a.kind !== "redact") { drawMark(page, a, fontFor); report.marks++; }
for (const a of marks) if (a.kind === "redact") { drawMark(page, a, fontFor); report.marks++; }
}
// 4. page tree

View file

@ -68,16 +68,31 @@ export function normBox(x0, y0, x1, y1) {
return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) };
}
/** Axis-aligned bounds of a mark, in user space. Used for selection and hit-testing. */
/**
* Where a rule sits inside a line's rectangle, as a fraction of its height
* from the baseline side. Shared by the renderer and the writer so that what
* is on screen and what lands in the file are the same picture two copies of
* this number is two pictures the first time either is touched.
*/
export const RULE_OFFSET = { underline: 0.06, strikeout: 0.42 };
/** Marks whose geometry is a list of text-line rectangles. */
export const RECT_LIST_KINDS = new Set(["highlight", "underline", "strikeout"]);
/** Marks whose geometry is a single box. */
export const BOX_KINDS = new Set(["rect", "ellipse", "redact", "signature"]);
/** Marks defined by two endpoints. */
export const SEGMENT_KINDS = new Set(["arrow", "line"]);
export function boundsOf(a) {
switch (a.kind) {
case "highlight": {
if (RECT_LIST_KINDS.has(a.kind)) {
const rs = a.rects || [];
if (!rs.length) return { x: 0, y: 0, w: 0, h: 0 };
const x0 = Math.min(...rs.map((r) => r.x)), y0 = Math.min(...rs.map((r) => r.y));
const x1 = Math.max(...rs.map((r) => r.x + r.w)), y1 = Math.max(...rs.map((r) => r.y + r.h));
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
if (SEGMENT_KINDS.has(a.kind)) return normBox(a.x1, a.y1, a.x2, a.y2);
switch (a.kind) {
case "pen": {
const pts = a.pts || [];
if (!pts.length) return { x: 0, y: 0, w: 0, h: 0 };
@ -86,7 +101,6 @@ export function boundsOf(a) {
return { x: Math.min(...xs) - pad, y: Math.min(...ys) - pad,
w: Math.max(...xs) - Math.min(...xs) + pad * 2, h: Math.max(...ys) - Math.min(...ys) + pad * 2 };
}
case "arrow": return normBox(a.x1, a.y1, a.x2, a.y2);
case "text": {
// Width is unknown without measuring the font; the renderer measures it
// and caches it on the mark as `_w` so selection boxes are tight.
@ -98,14 +112,97 @@ export function boundsOf(a) {
/** Shift a mark by (dx,dy) in user space. Returns the patch, does not mutate. */
export function translatePatch(a, dx, dy) {
switch (a.kind) {
case "highlight":
if (RECT_LIST_KINDS.has(a.kind)) {
return { rects: (a.rects || []).map((r) => ({ ...r, x: r.x + dx, y: r.y + dy })) };
case "pen":
return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
case "arrow":
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
default:
return { x: a.x + dx, y: a.y + dy };
}
if (SEGMENT_KINDS.has(a.kind)) {
return { x1: a.x1 + dx, y1: a.y1 + dy, x2: a.x2 + dx, y2: a.y2 + dy };
}
if (a.kind === "pen") return { pts: (a.pts || []).map(([x, y]) => [x + dx, y + dy]) };
return { x: a.x + dx, y: a.y + dy };
}
// ---- resizing ---------------------------------------------------------
//
// Handles are named by the corner or edge they sit on, in screen terms: "nw"
// is top-left as the reader sees it. User space has y running the other way,
// so "n" moves the box's y + h and "s" moves its y. Keeping the names in
// reader terms means the cursor CSS and the drag maths agree with what the
// user is doing with the mouse.
export const BOX_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
export const CORNER_HANDLES = ["nw", "ne", "se", "sw"];
/** Smallest box a resize may produce, in points. */
export const MIN_EXTENT = 4;
export const MIN_FONT = 4;
export const MAX_FONT = 288;
/** Which handles a mark offers. Segments get their two endpoints instead. */
export function handlesFor(a) {
if (SEGMENT_KINDS.has(a.kind)) return ["p1", "p2"];
if (a.kind === "text") return CORNER_HANDLES; // uniform scale only
return BOX_HANDLES;
}
/**
* The box a drag of `handle` produces, given the mark's current bounds and a
* user-space delta. Anchors the opposite corner or edge, and never inverts:
* dragging the top edge past the bottom stops at the minimum rather than
* flipping the mark over, which is disorienting mid-gesture.
*/
export function resizeBox(b, handle, dx, dy, { uniform = false } = {}) {
let { x, y, w, h } = b;
const east = handle.includes("e"), west = handle.includes("w");
const north = handle.includes("n"), south = handle.includes("s");
if (uniform && (east || west) && (north || south)) {
// Corner drag on a uniform mark: one scale factor, taken from whichever
// axis moved further, so the mark never distorts.
const sx = (w + (east ? dx : -dx)) / (w || 1);
const sy = (h + (north ? dy : -dy)) / (h || 1);
const k = Math.max(MIN_EXTENT / Math.max(w, h, 1), Math.abs(sx) > Math.abs(sy) ? sx : sy);
const nw = Math.max(MIN_EXTENT, w * k), nh = Math.max(MIN_EXTENT, h * k);
return {
x: west ? x + w - nw : x,
y: south ? y + h - nh : y,
w: nw, h: nh,
};
}
if (east) w = Math.max(MIN_EXTENT, w + dx);
if (west) { const nw2 = Math.max(MIN_EXTENT, w - dx); x = x + w - nw2; w = nw2; }
if (north) h = Math.max(MIN_EXTENT, h + dy);
if (south) { const nh2 = Math.max(MIN_EXTENT, h - dy); y = y + h - nh2; h = nh2; }
return { x, y, w, h };
}
/**
* Map a mark from one bounding box to another. One function for every kind,
* so a new mark type gets resizing for free as long as boundsOf knows it.
*/
export function scaleToBox(a, from, to) {
const sx = from.w > 0.001 ? to.w / from.w : 1;
const sy = from.h > 0.001 ? to.h / from.h : 1;
const fx = (x) => to.x + (x - from.x) * sx;
const fy = (y) => to.y + (y - from.y) * sy;
if (RECT_LIST_KINDS.has(a.kind)) {
return { rects: (a.rects || []).map((r) => ({ x: fx(r.x), y: fy(r.y), w: r.w * sx, h: r.h * sy })) };
}
if (SEGMENT_KINDS.has(a.kind)) {
return { x1: fx(a.x1), y1: fy(a.y1), x2: fx(a.x2), y2: fy(a.y2) };
}
if (a.kind === "pen") {
return { pts: (a.pts || []).map(([x, y]) => [fx(x), fy(y)]) };
}
if (a.kind === "text") {
// Text scales by font size, not by stretching glyphs. `y` is the top of
// the block, which is where the new box's top is.
const size = clampFont((a.size || 12) * (sy || 1));
return { size, x: to.x, y: to.y + to.h };
}
return { x: to.x, y: to.y, w: Math.max(MIN_EXTENT, to.w), h: Math.max(MIN_EXTENT, to.h) };
}
export function clampFont(n) {
return Math.min(MAX_FONT, Math.max(MIN_FONT, Math.round(n * 10) / 10));
}

View file

@ -7,24 +7,40 @@
// 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.
//
// Highlight is the deliberate exception. It works 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 from scratch over pdf.js's span soup would be worse at it.
// 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 } from "./shape.js";
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;
this.host = host; // editor.js: {toast, openTextModal, openSignaturePad, signature(), onChange}
// 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.
@ -47,62 +63,134 @@ export class Tools {
get mode() {
if (this.tool === "select") return "select";
if (this.tool === "highlight") return "highlight";
if (this.tool === "text" || this.tool === "signature") return "stamp";
if (TEXT_MARKUP.has(this.tool)) return "highlight";
if (STAMP_TOOLS.has(this.tool)) return "stamp";
return "draw";
}
setTool(t) {
this.tool = t;
this.selected = null;
this._setSelected(null);
document.body.dataset.mode = this.mode;
this.strip.refreshAllOverlays(null);
this.host.onChange?.();
}
setColor(c) { this.color = c; if (this.selected) this._recolorSelected(c); }
setWidth(w) { this.width = w; }
_recolorSelected(c) {
/** 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") return;
this.model.moveAnnot(a.id, { color: c });
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.selected = null;
this.strip.refreshAllOverlays(null);
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 (this.tool === "highlight") { this._selDirty = false; return; } // let the browser select
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;
this.selected = id;
this.strip.refreshAllOverlays(id);
if (id) {
const a = this.model.annot(id);
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y, orig: structuredClone(a), moved: false };
// 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.openTextModal({ uid: hit.uid, x, y });
this.host.editText?.(null, { uid: hit.uid, x, y });
e.preventDefault();
return;
}
@ -139,8 +227,25 @@ export class Tools {
if (d.kind === "move") {
const a = this.model.annot(d.id);
if (!a) return;
const patch = translatePatch(d.orig, x - d.x0, y - d.y0);
Object.assign(a, patch); // live, un-journalled; committed on up
// 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;
@ -150,35 +255,44 @@ export class Tools {
}
onUp(e) {
if (this.tool === "highlight") { this._commitHighlight(); return; }
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") {
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; }
// Put the mark back where it started, then move it through the model so
// the whole gesture lands on the undo stack as one step.
const moved = structuredClone(a);
Object.assign(a, d.orig);
const patch = {};
for (const k of Object.keys(moved)) if (k !== "id" && k !== "page" && k !== "kind") patch[k] = moved[k];
this.model.moveAnnot(d.id, patch);
this.strip.refreshAllOverlays(d.id);
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.selected = this.tool === "select" ? made.id : null;
}
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) {
@ -188,26 +302,27 @@ export class Tools {
if (final && d.pts.length < 2) return null;
return { kind: "pen", ...common, pts: d.pts.slice() };
}
case "arrow": {
case "arrow": case "line": {
if (final && !d.moved) return null;
return { kind: "arrow", ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
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 };
return { kind: d.kind, ...common, ...b, fill: !!this.fill, opacity: this.fillOpacity ?? 0.25 };
}
default: return null;
}
}
// ---- highlight ----------------------------------------------------
// 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 highlight mark per page, which is also what
// the PDF format wants.
_commitHighlight() {
// ---- 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();
@ -228,20 +343,41 @@ export class Tools {
}
}
if (!byPage.size) return;
let last = null;
for (const [uid, rects] of byPage) {
this.model.addAnnot({ kind: "highlight", page: uid, color: this.color, opacity: 0.38, rects: mergeRows(rects) });
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.strip.refreshAllOverlays(null);
this._setSelected(last ? last.id : null);
this.host.onChange?.();
}
// ---- stamps -------------------------------------------------------
placeText({ uid, x, y }, text, size) {
if (!String(text || "").trim()) return null;
const a = this.model.addAnnot({ kind: "text", page: uid, color: this.color, size, x, y, text });
this.strip.refreshAllOverlays(a.id);
this.selected = a.id;
/** 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;
}
@ -258,8 +394,14 @@ export class Tools {
kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
});
this.selected = a.id;
this.strip.refreshAllOverlays(a.id);
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?.();
}