feat(pdf-editor): a mark you placed is something you can still work on
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.
2026-09-21 03:13:06 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|