Select does what selecting does in every editor people already know. Double-click bare page and a caret opens there; double-click the document's own words and they open for replacement; what is selected copies with Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a drag on empty page still gathers an area, and a drag that starts on words still selects words to copy. Type text and Edit text were two buttons for one question the click already answers. They are one Text tool: land on the document's own words and it offers to replace them, land anywhere else and it starts new text. The words light up under the cursor so which is which is visible before clicking, not after. The toolbar says what it is for. Select, Text and Sign are labelled and set apart; the drawing kit and the markup kit are their own groups. Sign gets a pen icon over a signature rather than a squiggle that could have been anything. Signatures take ink — black, blue, red, green — chosen while drawing and kept with the signature, because people sign in a particular colour and it belongs to the signature, not to whichever swatch was armed. And they turn: a grip above the box, free rotation, Shift to snap to 15°, for the signing line that is not square to the page. Two faults the tests found, both invisible by eye: The rotate grip was drawn in the right place and could not be grabbed — the selection bar floats directly above a mark, which is exactly where the grip sits, and it swallowed every click. The bar now stands clear of it. Undo would not undo a first rotation. Restoring a mark with Object.assign copies the keys the original HAD, so a property the drag introduced survived the restore; the journal then recorded the rotated state as the state to go back to. Restoring now forgets keys the original never had, which fixes every future property with the same shape. Also: building a document from pictures or joins refuses to start a second one on top of the first, and says so rather than failing quietly.
417 lines
17 KiB
JavaScript
417 lines
17 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
|
|
const ROTATE_ARM = 22; // CSS px between the box and its rotate grip
|
|
/** Kinds that can be turned. A signature, because signing lines are not
|
|
* always square to the page; nothing else has asked for it. */
|
|
const ROTATABLE = new Set(["signature"]);
|
|
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]);
|
|
}
|
|
// A grip above the box, for the marks it makes sense to turn. A signature
|
|
// goes on a line that is not always square to the page — a scanned form,
|
|
// a photographed contract — and straightening one by hand beats
|
|
// straightening the whole page.
|
|
if (ROTATABLE.has(sel.kind)) {
|
|
const ry = T - ROTATE_ARM;
|
|
this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry }));
|
|
const g = this._handle("rotate", MX, ry, "grab");
|
|
if (g) g.classList.add("rot");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return 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;
|
|
}
|
|
}
|