theseus/bundled-addons/pdf-editor/lib/signature.js
Local Dev ce066bb4ca feat(pdf-editor): one select, one text tool, and a signature you can ink and turn
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.
2026-09-27 15:09:17 +02:00

145 lines
4.9 KiB
JavaScript

// The signature pad.
//
// What comes out is a list of strokes, each a list of points normalised to
// 0..1 inside the pad's box, y measured downwards. Not a picture. That matters
// for three reasons: the same signature can be dropped at any size on any page
// and stay sharp, it survives into the saved PDF as vector line art rather
// than a fuzzy PNG, and it is a few hundred bytes in storage instead of tens
// of kilobytes of base64.
//
// The pad also trims the drawing to its own bounding box before normalising,
// so a signature scrawled in the top-left corner of the pad still fills the
// box it gets placed in.
export class SignaturePad {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.strokes = []; // in canvas pixel space while drawing
this.cur = null;
// The ink. Kept with the signature rather than taken from the toolbar at
// placing time: people sign in a particular colour — blue to show a
// scan is an original, black to match the form — and that belongs to the
// signature, not to whatever swatch happened to be armed.
this.color = "#101418";
this._onChange = null;
canvas.addEventListener("pointerdown", (e) => this._down(e));
canvas.addEventListener("pointermove", (e) => this._move(e));
window.addEventListener("pointerup", () => this._up());
this.clear();
}
onChange(fn) { this._onChange = fn; }
clear() {
this.strokes = [];
this.cur = null;
this._paint();
this._onChange?.(false);
}
get isEmpty() { return !this.strokes.some((s) => s.length > 1); }
/**
* Put a saved signature back on the pad so it can be redrawn or added to.
*
* `result()` throws away where in the pad the signature was drawn — it
* keeps only the shape and its aspect — so this lays it out afresh:
* across the pad's width, centred, at the aspect it was saved with.
*/
load(strokes, aspect = 0.34) {
const margin = 24;
const w = this.canvas.width - margin * 2;
const h = Math.min(this.canvas.height - margin * 2, w * aspect);
const top = (this.canvas.height - h) / 2;
this.strokes = (strokes || [])
.filter((s) => Array.isArray(s) && s.length > 1)
.map((s) => s.map(([x, y]) => [margin + x * w, top + y * h]));
this.cur = null;
this._paint();
this._onChange?.(!this.isEmpty);
}
_pt(e) {
const r = this.canvas.getBoundingClientRect();
// The canvas is CSS-scaled to the modal's width, so a client point has to
// come back through that ratio to land in the backing store's pixels.
return [
(e.clientX - r.left) * (this.canvas.width / r.width),
(e.clientY - r.top) * (this.canvas.height / r.height),
];
}
_down(e) {
if (e.button !== 0) return;
this.cur = [this._pt(e)];
this.strokes.push(this.cur);
this.canvas.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
_move(e) {
if (!this.cur) return;
this.cur.push(this._pt(e));
this._paint();
}
_up() {
if (!this.cur) return;
if (this.cur.length < 2) this.strokes.pop();
this.cur = null;
this._paint();
this._onChange?.(!this.isEmpty);
}
_paint() {
const { ctx, canvas } = this;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// A baseline, so people sign at a sensible height rather than in a corner.
ctx.strokeStyle = "rgba(20,30,50,.18)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(24, canvas.height * 0.72);
ctx.lineTo(canvas.width - 24, canvas.height * 0.72);
ctx.stroke();
ctx.strokeStyle = this.color;
ctx.lineWidth = 2.6;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of this.strokes) {
if (s.length < 2) continue;
ctx.beginPath();
ctx.moveTo(s[0][0], s[0][1]);
for (let i = 1; i < s.length; i++) ctx.lineTo(s[i][0], s[i][1]);
ctx.stroke();
}
ctx.restore();
}
/**
* @returns {{strokes:[number,number][][], aspect:number}|null}
* strokes normalised into 0..1 over the drawing's own bounds; `aspect` is
* height/width of those bounds, so the caller can place it undistorted.
*/
result() {
const live = this.strokes.filter((s) => s.length > 1);
if (!live.length) return null;
const xs = live.flat().map((p) => p[0]);
const ys = live.flat().map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
const y0 = Math.min(...ys), y1 = Math.max(...ys);
// A single horizontal or vertical line has zero extent on one axis; floor
// it so the division below cannot produce Infinity.
const w = Math.max(1, x1 - x0), h = Math.max(1, y1 - y0);
return {
strokes: live.map((s) => s.map(([x, y]) => [(x - x0) / w, (y - y0) / h])),
aspect: Math.min(1.2, Math.max(0.12, h / w)),
color: this.color,
};
}
setColor(c) {
this.color = c;
this._paint();
}
}