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.
402 lines
16 KiB
JavaScript
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;
|
|
}
|
|
}
|