diff --git a/bundled-addons/pdf-editor/addon.json b/bundled-addons/pdf-editor/addon.json index 61790177..8aa58882 100644 --- a/bundled-addons/pdf-editor/addon.json +++ b/bundled-addons/pdf-editor/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css index 633e91f4..4a974c9f 100644 --- a/bundled-addons/pdf-editor/editor.css +++ b/bundled-addons/pdf-editor/editor.css @@ -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 .selbox { fill: none; stroke: var(--acid); stroke-width: 1; 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; vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; } diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js index cd4a1c88..ba88b6cb 100644 --- a/bundled-addons/pdf-editor/editor.js +++ b/bundled-addons/pdf-editor/editor.js @@ -21,7 +21,7 @@ import { Tools } from "./lib/tools.js"; import { SignaturePad } from "./lib/signature.js"; import { InlineText } from "./lib/inline.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 { buildDocx } from "./lib/docx.js"; import { imagesToPdf, mergePdfs } from "./lib/create.js"; @@ -486,7 +486,7 @@ $("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, 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.", underline: "Drag across text to underline it.", strikeout: "Drag across text to strike it through.", @@ -682,6 +682,33 @@ function paintSelectionBar(id) { delete selbar.dataset.mode; $("sb-dup").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 // words offered no way to change them — the exact gap this bar exists for. const isText = a.kind === "text" || a.kind === "textedit"; @@ -708,7 +735,11 @@ function positionSelectionBar() { 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); + // 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 [bx, by] = ov.toView(b.x + b.w, b.y); const pr = view.pv.div.getBoundingClientRect(); @@ -873,6 +904,19 @@ function finishInlineText(s) { 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. */ function paintInlineBar() { const st = inline?.state; @@ -1412,6 +1456,14 @@ window.addEventListener("keydown", (e) => { 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") { // 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") { const sel = session.tools?.selected; + if (session.tools?.selection?.size > 1) return; // one caret, one mark 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; } if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; } diff --git a/bundled-addons/pdf-editor/lib/model.js b/bundled-addons/pdf-editor/lib/model.js index 5d3e673a..6f2dd475 100644 --- a/bundled-addons/pdf-editor/lib/model.js +++ b/bundled-addons/pdf-editor/lib/model.js @@ -135,11 +135,18 @@ export class DocModel { // ---- mutations ---------------------------------------------------- addAnnot(a) { - return this.edit(a.kind, () => { - const full = { id: nextId("a"), ...a }; - this.annots.push(full); - return full; - }); + return this.edit(a.kind, () => this.pushAnnot(a)); + } + /** + * 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) { const a = this.annot(id); @@ -156,6 +163,34 @@ export class DocModel { 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) { return this.edit("rotate", () => { const p = this.pages.get(uid); diff --git a/bundled-addons/pdf-editor/lib/overlay.js b/bundled-addons/pdf-editor/lib/overlay.js index 2b141e1c..6d1962c3 100644 --- a/bundled-addons/pdf-editor/lib/overlay.js +++ b/bundled-addons/pdf-editor/lib/overlay.js @@ -11,7 +11,7 @@ // hit-testable, selectable and movable, and because it stays crisp when the // 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 HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom @@ -58,9 +58,15 @@ export class Overlay { return [x, y]; } - render(viewport, annots, selectedId) { + render(viewport, annots, selected) { 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("height", viewport.height); this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`); @@ -76,10 +82,10 @@ export class Overlay { this._addHitArea(node, a); node.classList.add("mark"); 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._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. @@ -89,6 +95,17 @@ export class Overlay { _draw(a) { const g = el("g", {}); 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": { for (const r of a.rects || []) { 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 changed = Math.abs((a._w ?? 0) - w) > 0.5; a._w = w; - if (changed && a.id === this._selId) this._drawChrome(a); + if (changed && a.id === this._selId) this._drawChrome([a]); } catch {} }); return g; @@ -285,9 +302,12 @@ export class Overlay { // 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) { + _drawChrome(picked) { 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 r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h }); 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) { const h = el("rect", { class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2, diff --git a/bundled-addons/pdf-editor/lib/shape.js b/bundled-addons/pdf-editor/lib/shape.js index 8b1eff30..dc7c4a43 100644 --- a/bundled-addons/pdf-editor/lib/shape.js +++ b/bundled-addons/pdf-editor/lib/shape.js @@ -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. */ export function textBlockHeight(a) { const lines = String(a.text ?? "").split("\n").length; diff --git a/bundled-addons/pdf-editor/lib/tools.js b/bundled-addons/pdf-editor/lib/tools.js index 450b0ce4..2491ec36 100644 --- a/bundled-addons/pdf-editor/lib/tools.js +++ b/bundled-addons/pdf-editor/lib/tools.js @@ -18,7 +18,7 @@ // 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 { 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 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.fill = false; // shapes: outline only, or filled 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; // Opening a second document builds a second Tools over the same container. // Without a way to unbind, the first one stays subscribed, keeps pointing @@ -63,6 +64,7 @@ export class Tools { this._abort.abort(); this.drag = null; this.selected = null; + this.selection = new Set(); } get mode() { @@ -81,57 +83,98 @@ export class Tools { } /** The one place selection changes, so the host never misses one. */ - _setSelected(id) { - this.selected = id; - this.strip.refreshAllOverlays(id); - this.host.onSelectionChange?.(id); + _setSelected(id) { this._setSelection(id ? [id] : []); } + /** + * Select a set of marks. + * + * `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); } + selectMany(ids) { this._setSelection(ids); } + /** Every selected mark, as objects. */ + selectedAnnots() { + return [...this.selection].map((id) => this.model.annot(id)).filter(Boolean); + } setColor(c) { this.color = c; - const a = this.model.annot(this.selected); - if (a && a.kind !== "redact") this.patchSelected({ color: c }); + // Redaction is black because it is a redaction; it does not take a colour. + const ids = this.selectedAnnots().filter((a) => a.kind !== "redact").map((a) => a.id); + if (ids.length) this.patchSelected({ color: c }, ids); } 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 }); + const ids = this.selectedAnnots() + .filter((a) => a.kind === "rect" || a.kind === "ellipse").map((a) => a.id); + if (ids.length) this.patchSelected({ fill: this.fill }, ids); } setWidth(w) { this.width = w; - const a = this.model.annot(this.selected); - if (a && a.kind !== "text" && a.kind !== "redact") this.patchSelected({ width: w }); + const ids = this.selectedAnnots() + .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. */ - patchSelected(patch) { - if (!this.selected) return false; - const ok = this.model.moveAnnot(this.selected, patch); - this.strip.refreshAllOverlays(this.selected); + /** Change the selected marks, as one undo step. */ + patchSelected(patch, ids = null) { + const list = ids || [...this.selection]; + if (!list.length) return false; + 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.onChange?.(); return ok; } deleteSelected() { - if (!this.selected) return false; - const ok = this.model.removeAnnot(this.selected); + const list = [...this.selection]; + if (!list.length) return false; + const ok = list.length === 1 + ? this.model.removeAnnot(list[0]) + : this.model.removeAnnots(list); this._setSelected(null); this.host.onChange?.(); 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() { - const a = this.model.annot(this.selected); - if (!a) return null; + const originals = this.selectedAnnots(); + if (!originals.length) 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); + const clone = (a) => { + const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) }; + delete copy.id; + delete copy._w; + 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?.(); return made; } @@ -181,8 +224,7 @@ export class Tools { const ovm = a && this.strip.overlayFor(a.page); if (a && spot && spot.uid === a.page && ovm?.viewport) { const [mx, my] = ovm.toPdf(spot.vx, spot.vy); - this.drag = { kind: "move", uid: a.page, id: a.id, x0: mx, y0: my, - orig: structuredClone(a), cx: e.clientX, cy: e.clientY, moved: false }; + this.drag = this._beginMove(a.page, a.id, mx, my, e); e.preventDefault(); return; } @@ -222,18 +264,41 @@ export class Tools { 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; + if (!id) { + // 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(); - if (double) { + if (double && this.selection.size === 1) { const a = this.model.annot(id); // A stamp is edited where it sits; a replaced run keeps its dialog, // which is where it explains what it is replacing. if (a?.kind === "text") { this.host.beginText?.({ id }); return; } if (a?.kind === "textedit") { 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 }; + this.drag = this._beginMove(hit.uid, id, x, y, e); return; } @@ -256,6 +321,26 @@ export class Tools { 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) { const d = this.drag; if (!d) return; @@ -273,14 +358,19 @@ export class Tools { d.x = x; d.y = y; if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true; + if (d.kind === "marquee") { + this.strip.setPreview(d.uid, { kind: "marquee", x0: d.x0, y0: d.y0, x1: x, y1: y }); + return; + } if (d.kind === "move") { - const a = this.model.annot(d.id); - if (!a) return; // Live and un-journalled; the whole gesture is committed once on release // so undo steps back over the drag, not over each mouse move. - Object.assign(a, translatePatch(d.orig, x - d.x0, y - d.y0)); + 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; - this.strip.refreshOverlay(d.uid, d.id); + this.strip.refreshOverlay(d.uid, this.selection); return; } if (d.kind === "resize") { @@ -305,9 +395,8 @@ export class Tools { onUp(e) { // A handle or move drag is finished here whatever tool is armed. Without - // this the - // text-markup tools would return on the line below and strand a resize - // half-done: applied to the live mark, never journalled, and with + // this the text-markup tools would return on the line below and strand a + // resize half-done: applied to the live mark, never journalled, and with // this.drag still set so the next press behaves oddly. const inFlight = this.drag; const live = inFlight && (inFlight.kind === "resize" || inFlight.kind === "move"); @@ -320,11 +409,20 @@ export class Tools { if (!d) return; 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") { const a = this.model.annot(d.id); - if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; } - this._commitLiveEdit(a, d.orig); - this._setSelected(d.id); + if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selection); return; } + if (d.origs && d.origs.size > 1) this._commitGroupMove(d); + else this._commitLiveEdit(a, d.orig); + this._setSelection([...this.selection]); this.host.onChange?.(); return; } @@ -334,7 +432,7 @@ export class Tools { const made = this.model.addAnnot(a); this._setSelected(made.id); } else { - this.strip.refreshAllOverlays(this.selected); + this.strip.refreshAllOverlays(this.selection); } 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 // on the undo stack as ONE step, put the original back and re-apply the // 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) { const after = structuredClone(a); Object.assign(a, orig);