Three things the editor made you work around. Text was typed into a dialog and then placed, so you chose a size and a weight for words you could not see against the page they were going on. The click now opens a caret where you clicked, in the font, size and colour the words will have, with the style bar over it; double-clicking a stamp reopens it in place. Lining a CSS line box up with a PDF baseline is measured from the font's own metrics, not guessed. A signature lived in a single slot. There was nowhere to keep initials as well as a name, nowhere to change the one you had, and reaching for the tool again simply stamped the first one — which is the same fault three times: one slot. It is a library now, with redraw, rename and delete, and the choice is made when the tool is picked up, so placing stays one click. An existing single signature is carried into it rather than dropped. The mark you had just drawn could be resized by its handles and not moved by its middle, because only the select tool let marks be hit-tested at all. The SELECTED mark now takes a press whatever tool is armed. A press anywhere else still draws, and an unfilled shape is still grabbed by its outline — the same rule select has always followed. Also: words default to dark ink rather than highlighter yellow, which was unreadable on white and is now impossible to miss, since you watch yourself type it.
134 lines
4.5 KiB
JavaScript
134 lines
4.5 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;
|
|
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 = "#101418";
|
|
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)),
|
|
};
|
|
}
|
|
}
|