Everything the editor put on a page was final. A text stamp could not be corrected without deleting it and typing it again, nothing could be resized, and the only way to remove a mark was a Delete key nobody had been told about — the selection drew a dashed box and offered no action at all. Placing a stamp also left its tool armed, so the next click stamped a second copy. Marks are now editable objects. Selecting one gives it grab handles and a small bar pinned above it: delete and duplicate for anything, and for text an edit button, a size stepper and bold and italic. Double-clicking text reopens it for rewriting in place rather than adding a second one. Placing a text stamp or a signature drops straight back to the select tool with the new mark live, which is both what people expect and what puts it immediately within reach of a nudge. Resizing is one function over every mark type rather than a special case per kind: a handle drag produces a new bounding box, and the mark is mapped from its old box into that one. Text scales by font size instead of stretching its glyphs, signatures keep their aspect on a corner, and lines offer their two endpoints instead of a box that would let you stretch them in ways you never aimed at. A whole gesture lands on the undo stack as one step. Selecting a thin mark used to mean clicking its outline exactly — about one screen pixel. Each stroked mark now carries an invisible fat copy of itself purely to catch the pointer. New marks to go with it: underline and strike-through, which share the highlight's text-selection geometry and differ only in where the rule sits; a plain line; and a fill toggle for rectangles and ellipses. Bold and italic mean three more Helvetica variants embedded at save time, since a PDF treats them as separate fonts rather than as a style. Double-click is detected from the pointer stream rather than from a dblclick listener, because selecting a mark calls preventDefault() on the pointerdown and that suppresses the compatibility mouse events the browser would have synthesised the dblclick from.
322 lines
13 KiB
JavaScript
322 lines
13 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, handlesFor, signatureStrokes, textLayout, 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, selectedId) {
|
|
this.viewport = viewport;
|
|
this._selId = selectedId;
|
|
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) {
|
|
const node = this._draw(a);
|
|
if (!node) continue;
|
|
this._addHitArea(node, a);
|
|
node.classList.add("mark");
|
|
node.dataset.id = a.id;
|
|
if (a.id === selectedId) node.classList.add("sel");
|
|
this.marks.append(node);
|
|
}
|
|
this._drawChrome(annots.find((a) => a.id === selectedId) || null);
|
|
}
|
|
|
|
// 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) {
|
|
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;
|
|
}
|
|
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(sel) {
|
|
this.chrome.textContent = "";
|
|
if (!sel) return;
|
|
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]);
|
|
}
|
|
}
|
|
|
|
_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;
|
|
}
|
|
}
|