theseus/bundled-addons/pdf-editor/lib/overlay.js
Local Dev 62460cd3aa 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.
2026-09-27 13:45:04 +02:00

402 lines
16 KiB
JavaScript

// The on-screen layer that draws marks over a rendered page.
//
// One SVG per page, sized to that page's current viewport in CSS pixels.
// Every mark is stored in PDF user space, so drawing means running each point
// through `viewport.convertToViewportPoint` — which already accounts for the
// page's scale AND its rotation. That is why rotating a page needs no special
// handling here: the viewport changes, we re-render, and the marks turn with
// the content they were drawn on.
//
// SVG rather than a canvas because marks have to be individually
// 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, 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
let clipSeq = 0; // clipPath ids must be unique across every page in the document
const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke
function el(name, attrs) {
const n = document.createElementNS(NS, name);
for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
return n;
}
export class Overlay {
/** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */
constructor(pageDiv) {
this.svg = el("svg", { class: "smOverlay", xmlns: NS });
// Marks are clipped to the page, because a PDF reader shows nothing
// outside the crop box and a mark that spilled over the edge on screen
// would vanish on save. The selection chrome is NOT clipped: a mark
// flush against the edge still needs grabbable handles, and those sit
// just outside its bounds.
this.clipId = `smclip-${++clipSeq}`;
const defs = el("defs", {});
this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 });
const clip = el("clipPath", { id: this.clipId });
clip.append(this.clipRect);
defs.append(clip);
this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` });
this.chrome = el("g", { class: "chrome" }); // selection box + handles
this.svg.append(defs, this.marks, this.chrome);
pageDiv.append(this.svg);
this.viewport = null;
}
destroy() { this.svg.remove(); }
/** Map a user-space point to CSS pixels inside the page div. */
toView(x, y) {
const [vx, vy] = this.viewport.convertToViewportPoint(x, y);
return [vx, vy];
}
/** Map a page-relative CSS pixel point back to user space. */
toPdf(vx, vy) {
const [x, y] = this.viewport.convertToPdfPoint(vx, vy);
return [x, y];
}
render(viewport, annots, selected) {
this.viewport = viewport;
// 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}`);
this.clipRect.setAttribute("width", viewport.width);
this.clipRect.setAttribute("height", viewport.height);
this.marks.textContent = "";
for (const a of annots) {
// A stamp being typed is drawn by the caret instead; two copies of the
// same words, one stale, reads as a rendering fault.
if (a._editing) continue;
const node = this._draw(a);
if (!node) continue;
this._addHitArea(node, a);
node.classList.add("mark");
node.dataset.id = a.id;
if (ids.has(a.id)) node.classList.add("sel");
this.marks.append(node);
}
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.
// The viewport's scale already carries zoom; rotation does not change length.
get k() { return this.viewport ? this.viewport.scale : 1; }
_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 });
g.append(el("rect", {
...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38,
style: "mix-blend-mode:multiply",
}));
}
g.dataset.fill = "1";
return g;
}
// Underline and strikeout share the highlight's geometry — one rect per
// line of selected text — and differ only in where the rule sits inside
// that rect. Keeping them on the same shape means a selection spanning a
// page break splits the same way for all three.
case "underline": case "strikeout": {
const frac = RULE_OFFSET[a.kind];
for (const r of a.rects || []) {
const yy = r.y + r.h * frac;
const [x1, y1] = this.toView(r.x, yy);
const [x2, y2] = this.toView(r.x + r.w, yy);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt",
}));
}
return g;
}
case "pen": {
const pts = (a.pts || []).map(([x, y]) => this.toView(x, y));
if (pts.length < 2) return null;
g.append(el("polyline", {
points: pts.map((p) => `${p[0]},${p[1]}`).join(" "),
fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "rect": {
const b = this._box(a);
g.append(el("rect", { ...b, ...this._paint(a) }));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "ellipse": {
const b = this._box(a);
g.append(el("ellipse", {
cx: b.x + b.width / 2, cy: b.y + b.height / 2,
rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2),
...this._paint(a),
}));
if (a.fill) g.dataset.fill = "1";
return g;
}
case "line": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
g.append(el("line", {
x1, y1, x2, y2, stroke: a.color,
"stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round",
}));
return g;
}
case "arrow": {
const [x1, y1] = this.toView(a.x1, a.y1);
const [x2, y2] = this.toView(a.x2, a.y2);
const w = (a.width || 2) * this.k;
const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y));
g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" }));
g.append(el("polyline", {
points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`,
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
return g;
}
case "text": {
const size = (a.size || 12) * this.k;
let widest = 0;
for (const line of textLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color,
"font-family": "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
// A string's width is only knowable once the browser has laid it out,
// so it is measured a frame later and cached on the mark in user-space
// units for boundsOf(). The selection box is drawn from that, and was
// drawn before the measurement existed — so redraw it once the real
// width is in, or the box and its handles sit at the fallback guess.
requestAnimationFrame(() => {
try {
for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength());
if (widest <= 0) return;
const w = widest / this.k;
const changed = Math.abs((a._w ?? 0) - w) > 0.5;
a._w = w;
if (changed && a.id === this._selId) this._drawChrome([a]);
} catch {}
});
return g;
}
case "signature": {
const w = (a.width || 1.6) * this.k;
for (const stroke of signatureStrokes(a)) {
if (stroke.length < 2) continue;
g.append(el("polyline", {
points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "),
fill: "none", stroke: a.color, "stroke-width": w,
"stroke-linecap": "round", "stroke-linejoin": "round",
}));
}
return g;
}
// A replaced text run: the original covered in the page's own background
// colour, the replacement drawn on the original's baseline. Rendering it
// here from the same numbers the writer uses is what makes the preview
// honest — the cover is the part people need to trust.
case "textedit": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: a.cover || "#ffffff" }));
const size = (a.size || 12) * this.k;
for (const line of editLayout(a)) {
const [vx, vy] = this.toView(line.x, line.y);
const t = el("text", {
x: vx, y: vy, fill: a.color || "#000000",
"font-family": a.family || "Helvetica, Arial, sans-serif",
"font-size": size, "xml:space": "preserve",
"font-weight": a.bold ? "bold" : "normal",
"font-style": a.italic ? "italic" : "normal",
});
t.textContent = line.text;
g.append(t);
}
g.dataset.fill = "1";
return g;
}
case "redact": {
const b = this._box(a);
g.append(el("rect", { ...b, fill: "#000" }));
g.dataset.fill = "1";
return g;
}
default: return null;
}
}
/**
* Give a mark something to grab.
*
* A 2 pt outline is about one screen pixel: selecting it means hitting a
* hairline exactly, which is miserable with a mouse and impossible with a
* trackpad. Each stroked shape gets an invisible copy of itself with a fat
* stroke underneath, purely to catch the pointer. `pointer-events: stroke`
* hit-tests the stroke area whatever the paint is, so a transparent one
* still counts.
*/
_addHitArea(g, a) {
if (g.dataset.fill === "1") return; // already a solid target
const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8);
const clones = [];
for (const child of g.children) {
if (child.tagName === "text") continue;
const c = child.cloneNode(false);
c.setAttribute("stroke", "transparent");
c.setAttribute("stroke-width", wide);
c.setAttribute("fill", "none");
c.removeAttribute("style");
c.setAttribute("class", "hit");
clones.push(c);
}
for (const c of clones) g.insertBefore(c, g.firstChild);
}
// A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both
// corners so a rotated viewport lands the right way up.
_box(a) {
const [ax, ay] = this.toView(a.x, a.y);
const [bx, by] = this.toView(a.x + a.w, a.y + a.h);
return { x: Math.min(ax, bx), y: Math.min(ay, by),
width: Math.abs(bx - ax), height: Math.abs(by - ay) };
}
// The selection outline and its grab handles.
//
// Handles are drawn at a fixed pixel size rather than scaled with the page,
// so they stay grabbable at 50% zoom and do not swell into the artwork at
// 400%. Each carries data-handle, which is what tools.js hit-tests to tell a
// resize from a move.
_drawChrome(picked) {
this.chrome.textContent = "";
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;
this.chrome.append(el("rect", {
class: "selbox", x: r.x - pad, y: r.y - pad,
width: r.width + pad * 2, height: r.height + pad * 2,
}));
const names = handlesFor(sel);
if (SEGMENT_KINDS.has(sel.kind)) {
// A line's ends are the only meaningful grips; a bounding box would let
// you stretch it in ways that never match what you were aiming at.
const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) };
for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move");
return;
}
const L = r.x - pad, R = r.x + r.width + pad;
const T = r.y - pad, B = r.y + r.height + pad;
const MX = (L + R) / 2, MY = (T + B) / 2;
const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY],
se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] };
const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
for (const n of names) {
const p = at[n];
if (p) this._handle(n, p[0], p[1], cursor[n]);
}
}
/**
* 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,
width: HANDLE, height: HANDLE, rx: 1.5,
style: `cursor:${cursor}`,
});
h.dataset.handle = name;
this.chrome.append(h);
}
// Stroke and fill for a shape, honouring its fill and opacity.
_paint(a) {
return {
fill: a.fill ? a.color : "none",
"fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0,
stroke: a.color,
"stroke-width": (a.width || 2) * this.k,
};
}
/** The mark id under a page-relative CSS pixel point, or null. */
hitTest(vx, vy) {
const node = document.elementFromPoint(
vx + this.svg.getBoundingClientRect().left,
vy + this.svg.getBoundingClientRect().top);
const mark = node?.closest?.(".mark");
return mark ? mark.dataset.id : null;
}
}