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:
parent
10c83a1431
commit
62460cd3aa
7 changed files with 373 additions and 63 deletions
|
|
@ -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+",
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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 };
|
const full = { id: nextId("a"), ...a };
|
||||||
this.annots.push(full);
|
this.annots.push(full);
|
||||||
return 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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,60 +83,101 @@ 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 clone = (a) => {
|
||||||
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
|
const copy = { ...structuredClone(a), ...translatePatch(a, off, -off) };
|
||||||
delete copy.id;
|
delete copy.id;
|
||||||
delete copy._w;
|
delete copy._w;
|
||||||
const made = this.model.addAnnot(copy);
|
delete copy._editing;
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
if (originals.length === 1) {
|
||||||
|
const made = this.model.addAnnot(clone(originals[0]));
|
||||||
this._setSelected(made.id);
|
this._setSelected(made.id);
|
||||||
this.host.onChange?.();
|
this.host.onChange?.();
|
||||||
return made;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- pointer ------------------------------------------------------
|
// ---- pointer ------------------------------------------------------
|
||||||
onDown(e) {
|
onDown(e) {
|
||||||
|
|
@ -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();
|
e.preventDefault();
|
||||||
if (double) {
|
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 && 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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue