feat(pdf-editor): select an area, and treat what it caught as one thing

Drag on empty page and a rubber band gathers every mark it sweeps over.
Touching counts rather than enclosing: a band you have to draw right
around a long arrow is a band you draw twice. Shift-click adds or removes
one, Ctrl+A takes the page.

What the group can then do is move, restyle, duplicate and delete, each as
a single undo step — six marks deleted is one thing the user did, so it
has to be one thing to undo. The selection bar offers only what is true of
every member: a group of shapes gets Fill, a group of stamps gets size and
weight, a mixed group gets neither, and none of them gets Edit, which
needs one mark to put a caret in.

No resize handles on a group. Stretching a mixed selection means deciding
what a stretch does to text, which scales by font size, and to a line,
which has no box at all; until there is an answer worth defending,
offering the grip would promise something this cannot keep.

Each member is outlined as well as the group, because a band that caught
one more mark than you meant is worth seeing before you press Delete.

A group lives on one page. A mark carries its page, and PDF user space
means nothing across two of them, so a band selects within the page it
was drawn on.

One trap found on the way: starting the band on a press means calling
preventDefault, which is also what gives a form field its caret — so the
band now keeps its hands off the annotation layer, and typing into a PDF
form still works.
This commit is contained in:
Local Dev 2026-09-27 13:45:04 +02:00
parent 10c83a1431
commit 62460cd3aa
7 changed files with 373 additions and 63 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pdf-editor", "id": "pdf-editor",
"name": "PDF Editor", "name": "PDF Editor",
"version": "0.5.0", "version": "0.6.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. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.", "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. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",

View file

@ -268,6 +268,15 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all;
.smOverlay .mark.sel[data-fill="1"] { pointer-events: all; } .smOverlay .mark.sel[data-fill="1"] { pointer-events: all; }
.smOverlay .selbox { fill: none; stroke: var(--acid); stroke-width: 1; .smOverlay .selbox { fill: none; stroke: var(--acid); stroke-width: 1;
stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; } stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
/* A group: one solid box round the lot, and a fainter one round each member
so it is obvious WHICH marks are in it — a marquee that caught one more
than you meant is worth seeing before you press Delete. */
.smOverlay .selbox.group { stroke-dasharray: 2 4; stroke-width: 1.5; }
.smOverlay .selbox.member { stroke-dasharray: none; stroke-opacity: .45; }
/* The rubber band itself. */
.smOverlay .marquee { fill: rgb(from var(--acid) r g b / .10); stroke: var(--acid);
stroke-width: 1; stroke-dasharray: 5 3;
vector-effect: non-scaling-stroke; }
.smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1; .smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1;
vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; } vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; }

View file

@ -21,7 +21,7 @@ import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js"; import { SignaturePad } from "./lib/signature.js";
import { InlineText } from "./lib/inline.js"; import { InlineText } from "./lib/inline.js";
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js"; import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js"; import { boundsOf, clampFont, unionOf } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js"; import { reflow } from "./lib/reflow.js";
import { buildDocx } from "./lib/docx.js"; import { buildDocx } from "./lib/docx.js";
import { imagesToPdf, mergePdfs } from "./lib/create.js"; import { imagesToPdf, mergePdfs } from "./lib/create.js";
@ -486,7 +486,7 @@ $("redact-ok").addEventListener("click", () => closeModal(true));
$("redact-cancel").addEventListener("click", () => closeModal(null)); $("redact-cancel").addEventListener("click", () => closeModal(null));
const TOOL_HINTS = { const TOOL_HINTS = {
select: "Click a mark to select it. Drag to move, drag a handle to resize, double-click text to edit.", select: "Click a mark, or drag an area around several. Shift-click adds one, Ctrl+A takes the page.",
highlight: "Drag across text to highlight it.", highlight: "Drag across text to highlight it.",
underline: "Drag across text to underline it.", underline: "Drag across text to underline it.",
strikeout: "Drag across text to strike it through.", strikeout: "Drag across text to strike it through.",
@ -682,6 +682,33 @@ function paintSelectionBar(id) {
delete selbar.dataset.mode; delete selbar.dataset.mode;
$("sb-dup").hidden = false; $("sb-dup").hidden = false;
$("sb-del").hidden = false; $("sb-del").hidden = false;
// Several at once: offer only what is true of every one of them. A bar
// showing a size for a group that is half shapes would be lying about what
// pressing it does.
const picked = session.tools?.selectedAnnots?.() || [a];
if (picked.length > 1) {
const isText = (m) => m.kind === "text" || m.kind === "textedit";
const allText = picked.every(isText);
const allShapes = picked.every((m) => m.kind === "rect" || m.kind === "ellipse");
$("sb-edit").hidden = true; // one caret, one mark
$("sb-textsize").hidden = !allText;
$("sb-bold").hidden = !allText;
$("sb-italic").hidden = !allText;
$("sb-fill").hidden = !allShapes;
if (allText) {
const sizes = new Set(picked.map((m) => Math.round(m.size || 12)));
$("sb-size").textContent = sizes.size === 1 ? String([...sizes][0]) : "—";
$("sb-bold").classList.toggle("active", picked.every((m) => m.bold));
$("sb-italic").classList.toggle("active", picked.every((m) => m.italic));
}
if (allShapes) $("sb-fill").classList.toggle("active", picked.every((m) => m.fill));
selbar.dataset.mode = "group";
selbar.hidden = false;
positionSelectionBar();
status(`${picked.length} marks selected — drag to move them, Del to remove them`);
return;
}
// A replaced run is text too. Leaving it out meant the one mark made of // A replaced run is text too. Leaving it out meant the one mark made of
// words offered no way to change them — the exact gap this bar exists for. // words offered no way to change them — the exact gap this bar exists for.
const isText = a.kind === "text" || a.kind === "textedit"; const isText = a.kind === "text" || a.kind === "textedit";
@ -708,7 +735,11 @@ function positionSelectionBar() {
const ov = strip?.overlayFor(a.page); const ov = strip?.overlayFor(a.page);
const view = strip?.views.get(a.page); const view = strip?.views.get(a.page);
if (!ov?.viewport || !view) { selbar.hidden = true; return; } if (!ov?.viewport || !view) { selbar.hidden = true; return; }
const b = boundsOf(a); // Over the whole group when there is one, so the bar does not cling to
// whichever mark happens to be the anchor.
const picked = (t.selectedAnnots?.() || [a]).filter((x) => x.page === a.page);
const b = picked.length > 1 ? unionOf(picked) : boundsOf(a);
if (!b) { selbar.hidden = true; return; }
const [ax, ay] = ov.toView(b.x, b.y + b.h); const [ax, ay] = ov.toView(b.x, b.y + b.h);
const [bx, by] = ov.toView(b.x + b.w, b.y); const [bx, by] = ov.toView(b.x + b.w, b.y);
const pr = view.pv.div.getBoundingClientRect(); const pr = view.pv.div.getBoundingClientRect();
@ -873,6 +904,19 @@ function finishInlineText(s) {
status(""); status("");
} }
/** Select every mark on the page the user is looking at. */
function selectAllOnPage() {
const t = session.tools, m = session.model, strip = session.strip;
if (!t || !m || !strip) return;
const uid = strip.current || m.visible()[0];
if (!uid) return;
const ids = m.annotsFor(uid).map((a) => a.id);
if (!ids.length) { toast("Nothing on this page to select"); return; }
selectTool("select");
t.selectMany(ids);
toast(`${ids.length} mark${ids.length === 1 ? "" : "s"} selected`);
}
/** The style buttons, while a caret is open rather than a mark selected. */ /** The style buttons, while a caret is open rather than a mark selected. */
function paintInlineBar() { function paintInlineBar() {
const st = inline?.state; const st = inline?.state;
@ -1412,6 +1456,14 @@ window.addEventListener("keydown", (e) => {
return; return;
} }
if (typing) return; if (typing) return;
// Everything you added to the page you are looking at. Not the whole
// document: a group can only be moved within one page, so selecting across
// pages would offer a group half of which cannot come along.
if (meta && e.key.toLowerCase() === "a") {
e.preventDefault();
selectAllOnPage();
return;
}
if (e.key === "Escape") { if (e.key === "Escape") {
// Step back one level at a time: drop the selection first, and only then // Step back one level at a time: drop the selection first, and only then
@ -1426,8 +1478,10 @@ window.addEventListener("keydown", (e) => {
} }
if (e.key === "Enter") { if (e.key === "Enter") {
const sel = session.tools?.selected; const sel = session.tools?.selected;
if (session.tools?.selection?.size > 1) return; // one caret, one mark
const a = sel && session.model?.annot(sel); const a = sel && session.model?.annot(sel);
if (a?.kind === "text" || a?.kind === "textedit") { onTextModal(sel, null); e.preventDefault(); } if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; }
if (a?.kind === "textedit") { onTextModal(sel, null); e.preventDefault(); }
return; return;
} }
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; } if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }

View file

@ -135,11 +135,18 @@ export class DocModel {
// ---- mutations ---------------------------------------------------- // ---- mutations ----------------------------------------------------
addAnnot(a) { addAnnot(a) {
return this.edit(a.kind, () => { return this.edit(a.kind, () => this.pushAnnot(a));
const full = { id: nextId("a"), ...a }; }
this.annots.push(full); /**
return full; * Add a mark WITHOUT journalling it.
}); *
* Only for callers already inside an `edit()` — duplicating six marks is
* one action and has to be one step, not six.
*/
pushAnnot(a) {
const full = { id: nextId("a"), ...a };
this.annots.push(full);
return full;
} }
removeAnnot(id) { removeAnnot(id) {
const a = this.annot(id); const a = this.annot(id);
@ -156,6 +163,34 @@ export class DocModel {
return this.edit("move " + a.kind, () => { Object.assign(a, patch); return true; }); return this.edit("move " + a.kind, () => { Object.assign(a, patch); return true; });
} }
// ---- several at once ----------------------------------------------
// One undo step for the whole group. Selecting six marks and pressing Del
// is one thing the user did, so it has to be one thing to undo.
removeAnnots(ids) {
const set = new Set(ids);
const hit = this.annots.filter((a) => set.has(a.id));
if (!hit.length) return false;
return this.edit(`delete ${hit.length} marks`, () => {
this.annots = this.annots.filter((a) => !set.has(a.id));
return true;
});
}
/**
* Apply a patch to several marks.
*
* `patch` may be a plain object, or a function of the mark — a move needs
* the second form, because every mark shifts from its own position.
*/
moveAnnots(ids, patch) {
const set = new Set(ids);
const hit = this.annots.filter((a) => set.has(a.id));
if (!hit.length) return false;
return this.edit(`change ${hit.length} marks`, () => {
for (const a of hit) Object.assign(a, typeof patch === "function" ? patch(a) : patch);
return true;
});
}
rotatePage(uid, deltaDeg) { rotatePage(uid, deltaDeg) {
return this.edit("rotate", () => { return this.edit("rotate", () => {
const p = this.pages.get(uid); const p = this.pages.get(uid);

View file

@ -11,7 +11,7 @@
// hit-testable, selectable and movable, and because it stays crisp when the // hit-testable, selectable and movable, and because it stays crisp when the
// user zooms without us re-rasterising anything. // user zooms without us re-rasterising anything.
import { arrowBarbs, boundsOf, editLayout, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js"; import { arrowBarbs, boundsOf, editLayout, handlesFor, signatureStrokes, textLayout, unionOf, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js";
const NS = "http://www.w3.org/2000/svg"; const NS = "http://www.w3.org/2000/svg";
const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom
@ -58,9 +58,15 @@ export class Overlay {
return [x, y]; return [x, y];
} }
render(viewport, annots, selectedId) { render(viewport, annots, selected) {
this.viewport = viewport; this.viewport = viewport;
this._selId = selectedId; // Callers pass either one id or a set of them; everything below works in
// sets, so a single id becomes a set of one here rather than at each of
// the dozen call sites.
const ids = selected instanceof Set ? selected
: new Set(Array.isArray(selected) ? selected : selected ? [selected] : []);
this._selIds = ids;
this._selId = ids.size === 1 ? [...ids][0] : null;
this.svg.setAttribute("width", viewport.width); this.svg.setAttribute("width", viewport.width);
this.svg.setAttribute("height", viewport.height); this.svg.setAttribute("height", viewport.height);
this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`); this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
@ -76,10 +82,10 @@ export class Overlay {
this._addHitArea(node, a); this._addHitArea(node, a);
node.classList.add("mark"); node.classList.add("mark");
node.dataset.id = a.id; node.dataset.id = a.id;
if (a.id === selectedId) node.classList.add("sel"); if (ids.has(a.id)) node.classList.add("sel");
this.marks.append(node); this.marks.append(node);
} }
this._drawChrome(annots.find((a) => a.id === selectedId && !a._editing) || null); this._drawChrome(annots.filter((a) => ids.has(a.id) && !a._editing));
} }
// Scale a user-space length (a stroke width, a font size) into CSS pixels. // Scale a user-space length (a stroke width, a font size) into CSS pixels.
@ -89,6 +95,17 @@ export class Overlay {
_draw(a) { _draw(a) {
const g = el("g", {}); const g = el("g", {});
switch (a.kind) { switch (a.kind) {
// The rubber band. Not a mark and never saved — it is drawn through the
// same path only so it lands in the page's own coordinates and stays
// put while the page scrolls under it.
case "marquee": {
const b = {
x: Math.min(a.x0, a.x1), y: Math.min(a.y0, a.y1),
w: Math.abs(a.x1 - a.x0), h: Math.abs(a.y1 - a.y0),
};
g.append(el("rect", { ...this._box(b), class: "marquee" }));
return g;
}
case "highlight": { case "highlight": {
for (const r of a.rects || []) { for (const r of a.rects || []) {
const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h }); const b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h });
@ -193,7 +210,7 @@ export class Overlay {
const w = widest / this.k; const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5; const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w; a._w = w;
if (changed && a.id === this._selId) this._drawChrome(a); if (changed && a.id === this._selId) this._drawChrome([a]);
} catch {} } catch {}
}); });
return g; return g;
@ -285,9 +302,12 @@ export class Overlay {
// so they stay grabbable at 50% zoom and do not swell into the artwork at // 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 // 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move. // resize from a move.
_drawChrome(sel) { _drawChrome(picked) {
this.chrome.textContent = ""; this.chrome.textContent = "";
if (!sel) return; const list = Array.isArray(picked) ? picked : picked ? [picked] : [];
if (!list.length) return;
if (list.length > 1) return this._drawGroupChrome(list);
const sel = list[0];
const b = boundsOf(sel); const b = boundsOf(sel);
const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h }); const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h });
const pad = 3; const pad = 3;
@ -317,6 +337,40 @@ export class Overlay {
} }
} }
/**
* Several marks at once: one box round the lot, and a fainter one round
* each member.
*
* The member boxes are the part that earns its keep — a single outline
* round six marks tells you how much is selected but not which, and a
* marquee that quietly caught a seventh is exactly the mistake worth
* seeing before you press Delete.
*
* No handles. Resizing a group means deciding what a stretch does to text,
* which scales by font size, and to a line, which has no box at all; until
* there is an answer worth defending, offering the grip would be a promise
* this cannot keep.
*/
_drawGroupChrome(list) {
for (const a of list) {
const b = boundsOf(a);
if (!b) continue;
const r = this._box(b);
this.chrome.append(el("rect", {
class: "selbox member", x: r.x - 2, y: r.y - 2,
width: r.width + 4, height: r.height + 4,
}));
}
const u = unionOf(list);
if (!u) return;
const r = this._box(u);
const pad = 6;
this.chrome.append(el("rect", {
class: "selbox group", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
}
_handle(name, cx, cy, cursor) { _handle(name, cx, cy, cursor) {
const h = el("rect", { const h = el("rect", {
class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2, class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2,

View file

@ -42,6 +42,22 @@ export function textLayout(a) {
})); }));
} }
/** The one box that holds all of them, in user space. */
export function unionOf(annots) {
const boxes = (annots || []).map(boundsOf).filter(Boolean);
if (!boxes.length) return null;
const x = Math.min(...boxes.map((b) => b.x));
const y = Math.min(...boxes.map((b) => b.y));
const x1 = Math.max(...boxes.map((b) => b.x + b.w));
const y1 = Math.max(...boxes.map((b) => b.y + b.h));
return { x, y, w: x1 - x, h: y1 - y };
}
/** Do two user-space boxes touch at all? */
export function boxesOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
/** Height of a text stamp's block, for hit-testing and the selection box. */ /** Height of a text stamp's block, for hit-testing and the selection box. */
export function textBlockHeight(a) { export function textBlockHeight(a) {
const lines = String(a.text ?? "").split("\n").length; const lines = String(a.text ?? "").split("\n").length;

View file

@ -18,7 +18,7 @@
// click stamps a second copy, which is almost never what was wanted, and it // 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. // puts the thing just placed immediately within reach of a nudge or a resize.
import { ASCENT, boundsOf, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js"; import { ASCENT, boundsOf, boxesOverlap, normBox, translatePatch, resizeBox, scaleToBox } from "./shape.js";
const MIN_DRAG = 3; // CSS px before a press counts as a drag const 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 DOUBLE_CLICK_MS = 450; // window for a second click on the same mark
@ -45,7 +45,8 @@ export class Tools {
this.width = 2.5; this.width = 2.5;
this.fill = false; // shapes: outline only, or filled this.fill = false; // shapes: outline only, or filled
this.fillOpacity = 0.25; this.fillOpacity = 0.25;
this.selected = null; this.selected = null; // the anchor of the selection, or null
this.selection = new Set(); // every selected mark
this.drag = null; this.drag = null;
// Opening a second document builds a second Tools over the same container. // Opening a second document builds a second Tools over the same container.
// Without a way to unbind, the first one stays subscribed, keeps pointing // Without a way to unbind, the first one stays subscribed, keeps pointing
@ -63,6 +64,7 @@ export class Tools {
this._abort.abort(); this._abort.abort();
this.drag = null; this.drag = null;
this.selected = null; this.selected = null;
this.selection = new Set();
} }
get mode() { get mode() {
@ -81,57 +83,98 @@ export class Tools {
} }
/** The one place selection changes, so the host never misses one. */ /** The one place selection changes, so the host never misses one. */
_setSelected(id) { _setSelected(id) { this._setSelection(id ? [id] : []); }
this.selected = id; /**
this.strip.refreshAllOverlays(id); * Select a set of marks.
this.host.onSelectionChange?.(id); *
* `selection` is the truth; `selected` is the one of them the single-mark
* paths work on — the anchor — and is null when nothing is selected. With
* exactly one mark the two say the same thing, which is why every existing
* caller kept working when this grew from an id into a set.
*/
_setSelection(ids) {
const list = [...new Set(ids)].filter((id) => !!this.model.annot(id));
this.selection = new Set(list);
this.selected = list.length ? list[0] : null;
this.strip.refreshAllOverlays(this.selection);
this.host.onSelectionChange?.(this.selected);
} }
select(id) { this._setSelected(id); } select(id) { this._setSelected(id); }
selectMany(ids) { this._setSelection(ids); }
/** Every selected mark, as objects. */
selectedAnnots() {
return [...this.selection].map((id) => this.model.annot(id)).filter(Boolean);
}
setColor(c) { setColor(c) {
this.color = c; this.color = c;
const a = this.model.annot(this.selected); // Redaction is black because it is a redaction; it does not take a colour.
if (a && a.kind !== "redact") this.patchSelected({ color: c }); const ids = this.selectedAnnots().filter((a) => a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ color: c }, ids);
} }
setFill(on) { setFill(on) {
this.fill = !!on; this.fill = !!on;
const a = this.model.annot(this.selected); const ids = this.selectedAnnots()
if (a && (a.kind === "rect" || a.kind === "ellipse")) this.patchSelected({ fill: this.fill }); .filter((a) => a.kind === "rect" || a.kind === "ellipse").map((a) => a.id);
if (ids.length) this.patchSelected({ fill: this.fill }, ids);
} }
setWidth(w) { setWidth(w) {
this.width = w; this.width = w;
const a = this.model.annot(this.selected); const ids = this.selectedAnnots()
if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w }); .filter((a) => a.kind !== "text" && a.kind !== "redact").map((a) => a.id);
if (ids.length) this.patchSelected({ width: w }, ids);
} }
/** Change the selected mark, as one undo step. */ /** Change the selected marks, as one undo step. */
patchSelected(patch) { patchSelected(patch, ids = null) {
if (!this.selected) return false; const list = ids || [...this.selection];
const ok = this.model.moveAnnot(this.selected, patch); if (!list.length) return false;
this.strip.refreshAllOverlays(this.selected); const ok = list.length === 1
? this.model.moveAnnot(list[0], patch)
: this.model.moveAnnots(list, patch);
this.strip.refreshAllOverlays(this.selection);
this.host.onSelectionChange?.(this.selected); this.host.onSelectionChange?.(this.selected);
this.host.onChange?.(); this.host.onChange?.();
return ok; return ok;
} }
deleteSelected() { deleteSelected() {
if (!this.selected) return false; const list = [...this.selection];
const ok = this.model.removeAnnot(this.selected); if (!list.length) return false;
const ok = list.length === 1
? this.model.removeAnnot(list[0])
: this.model.removeAnnots(list);
this._setSelected(null); this._setSelected(null);
this.host.onChange?.(); this.host.onChange?.();
return ok; return ok;
} }
/** Drop a copy of the selected mark, offset so it is visibly a second one. */ /** Drop a copy of each selected mark, offset so they are visibly seconds. */
duplicateSelected() { duplicateSelected() {
const a = this.model.annot(this.selected); const originals = this.selectedAnnots();
if (!a) return null; if (!originals.length) return null;
const off = 12; const off = 12;
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) }; const clone = (a) => {
delete copy.id; const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
delete copy._w; delete copy.id;
const made = this.model.addAnnot(copy); delete copy._w;
this._setSelected(made.id); delete copy._editing;
return copy;
};
if (originals.length === 1) {
const made = this.model.addAnnot(clone(originals[0]));
this._setSelected(made.id);
this.host.onChange?.();
return made;
}
// One step for the group, and the copies end up selected so the next
// drag moves the copies rather than the originals.
const made = [];
this.model.edit(`duplicate ${originals.length} marks`, () => {
for (const a of originals) made.push(this.model.pushAnnot(clone(a)));
return true;
});
this._setSelection(made.map((m) => m.id));
this.host.onChange?.(); this.host.onChange?.();
return made; return made;
} }
@ -181,8 +224,7 @@ export class Tools {
const ovm = a && this.strip.overlayFor(a.page); const ovm = a && this.strip.overlayFor(a.page);
if (a && spot && spot.uid === a.page && ovm?.viewport) { if (a && spot && spot.uid === a.page && ovm?.viewport) {
const [mx, my] = ovm.toPdf(spot.vx, spot.vy); const [mx, my] = ovm.toPdf(spot.vx, spot.vy);
this.drag = { kind: "move", uid: a.page, id: a.id, x0: mx, y0: my, this.drag = this._beginMove(a.page, a.id, mx, my, e);
orig: structuredClone(a), cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault(); e.preventDefault();
return; return;
} }
@ -222,18 +264,41 @@ export class Tools {
const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS; const double = !!id && this._lastClick?.id === id && now - this._lastClick.t < DOUBLE_CLICK_MS;
this._lastClick = id ? { id, t: now } : null; this._lastClick = id ? { id, t: now } : null;
this._setSelected(id); if (!id) {
if (!id) return; // Empty page: start a rubber band. A press that never moves is just
// a click on nothing, and onUp treats it as one — it clears the
// selection, exactly as it always did.
this._setSelected(null);
// ...unless the press is on one of the document's own form fields.
// Starting a band there means calling preventDefault, and that is
// what gives a text field its caret, so the field would go dead.
if (e.target?.closest?.(".annotationLayer")) return;
this.drag = { kind: "marquee", uid: hit.uid, x0: x, y0: y, x, y,
cx: e.clientX, cy: e.clientY, moved: false };
e.preventDefault();
return;
}
// Shift adds to, or takes away from, what is already selected.
if (e.shiftKey) {
const next = new Set(this.selection);
next.has(id) ? next.delete(id) : next.add(id);
this._setSelection([...next]);
e.preventDefault();
return;
}
// Pressing on a mark that is already part of a group keeps the group,
// so the drag moves all of it. Pressing on any other mark selects just
// that one.
if (!this.selection.has(id)) this._setSelected(id);
e.preventDefault(); e.preventDefault();
if (double) { if (double && this.selection.size === 1) {
const a = this.model.annot(id); const a = this.model.annot(id);
// A stamp is edited where it sits; a replaced run keeps its dialog, // A stamp is edited where it sits; a replaced run keeps its dialog,
// which is where it explains what it is replacing. // which is where it explains what it is replacing.
if (a?.kind === "text") { this.host.beginText?.({ id }); return; } if (a?.kind === "text") { this.host.beginText?.({ id }); return; }
if (a?.kind === "textedit") { this.host.editText?.(id, null); return; } if (a?.kind === "textedit") { this.host.editText?.(id, null); return; }
} }
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y, this.drag = this._beginMove(hit.uid, id, x, y, e);
orig: structuredClone(this.model.annot(id)), cx: e.clientX, cy: e.clientY, moved: false };
return; return;
} }
@ -256,6 +321,26 @@ export class Tools {
e.preventDefault(); e.preventDefault();
} }
/**
* A move drag, of one mark or of the whole group.
*
* `origs` holds every mark's starting geometry: each one shifts from where
* IT was, not from where the one under the cursor was.
*/
_beginMove(uid, id, x, y, e) {
const ids = this.selection.has(id) ? [...this.selection] : [id];
const origs = new Map();
for (const i of ids) {
const a = this.model.annot(i);
// A group can only be dragged within one page: a mark carries its page,
// and PDF user space means nothing across two of them.
if (a && a.page === uid) origs.set(i, structuredClone(a));
}
return { kind: "move", uid, id, x0: x, y0: y, origs,
orig: origs.get(id) || structuredClone(this.model.annot(id)),
cx: e.clientX, cy: e.clientY, moved: false };
}
onMove(e) { onMove(e) {
const d = this.drag; const d = this.drag;
if (!d) return; if (!d) return;
@ -273,14 +358,19 @@ export class Tools {
d.x = x; d.y = y; 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.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
if (d.kind === "marquee") {
this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y });
return;
}
if (d.kind === "move") { 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 // Live and un-journalled; the whole gesture is committed once on release
// so undo steps back over the drag, not over each mouse move. // so undo steps back over the drag, not over each mouse move.
Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0)); for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
if (a) Object.assign(a, translatePatch(orig, x - d.x0, y - d.y0));
}
d.moved = true; d.moved = true;
this.strip.refreshOverlay(d.uid, d.id); this.strip.refreshOverlay(d.uid, this.selection);
return; return;
} }
if (d.kind === "resize") { if (d.kind === "resize") {
@ -305,9 +395,8 @@ export class Tools {
onUp(e) { onUp(e) {
// A handle or move drag is finished here whatever tool is armed. Without // A handle or move drag is finished here whatever tool is armed. Without
// this the // this the text-markup tools would return on the line below and strand a
// text-markup tools would return on the line below and strand a resize // resize half-done: applied to the live mark, never journalled, and with
// half-done: applied to the live mark, never journalled, and with
// this.drag still set so the next press behaves oddly. // this.drag still set so the next press behaves oddly.
const inFlight = this.drag; const inFlight = this.drag;
const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move"); const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move");
@ -320,11 +409,20 @@ export class Tools {
if (!d) return; if (!d) return;
this.strip.setPreview(null, null); this.strip.setPreview(null, null);
if (d.kind === "marquee") {
// A press that never moved is a click on empty space: the selection is
// already cleared, and there is nothing to gather.
if (d.moved) this._selectWithin(d.uid, d.x0, d.y0, d.x, d.y);
else this.strip.refreshAllOverlays(this.selection);
this.host.onChange?.();
return;
}
if (d.kind === "move" || d.kind === "resize") { if (d.kind === "move" || d.kind === "resize") {
const a = this.model.annot(d.id); const a = this.model.annot(d.id);
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; } if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; }
this._commitLiveEdit(a, d.orig); if (d.origs && d.origs.size > 1) this._commitGroupMove(d);
this._setSelected(d.id); else this._commitLiveEdit(a, d.orig);
this._setSelection([...this.selection]);
this.host.onChange?.(); this.host.onChange?.();
return; return;
} }
@ -334,7 +432,7 @@ export class Tools {
const made = this.model.addAnnot(a); const made = this.model.addAnnot(a);
this._setSelected(made.id); this._setSelected(made.id);
} else { } else {
this.strip.refreshAllOverlays(this.selected); this.strip.refreshAllOverlays(this.selection);
} }
this.host.onChange?.(); this.host.onChange?.();
} }
@ -342,6 +440,50 @@ export class Tools {
// A live drag mutates the mark in place so the screen keeps up. To land it // 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 // on the undo stack as ONE step, put the original back and re-apply the
// result through the model. // result through the model.
/**
* Everything the rubber band touched, on the page it was drawn on.
*
* Touching counts, not enclosing. A band you have to draw right around a
* long arrow to catch it is a band you draw twice; catching what you swept
* over is what people expect from sweeping.
*/
_selectWithin(uid, x0, y0, x1, y1) {
const box = {
x: Math.min(x0, x1), y: Math.min(y0, y1),
w: Math.abs(x1 - x0), h: Math.abs(y1 - y0),
};
const hits = this.model.annotsFor(uid)
.filter((a) => { const b = boundsOf(a); return b && boxesOverlap(b, box); })
.map((a) => a.id);
this._setSelection(hits);
// Only the empty case is worth a toast; when it caught something, the
// boxes on screen and the status line have already said so.
if (!hits.length) this.host.toast?.("Nothing in that area");
}
/** A group drag, landed on the undo stack as one step. */
_commitGroupMove(d) {
const after = new Map();
for (const id of d.origs.keys()) {
const a = this.model.annot(id);
if (a) after.set(id, structuredClone(a));
}
// Put every mark back before re-applying through the model, so the
// journal's "before" is the state the drag actually started from.
for (const [id, orig] of d.origs) {
const a = this.model.annot(id);
if (a) Object.assign(a, orig);
}
this.model.moveAnnots([...after.keys()], (a) => {
const to = after.get(a.id);
const patch = {};
for (const k of Object.keys(to)) {
if (k !== "id" && k !== "page" && k !== "kind") patch[k] = to[k];
}
return patch;
});
}
_commitLiveEdit(a, orig) { _commitLiveEdit(a, orig) {
const after = structuredClone(a); const after = structuredClone(a);
Object.assign(a, orig); Object.assign(a, orig);