115 lines
3.7 KiB
JavaScript
115 lines
3.7 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); }
|
||
|
|
|
||
|
|
_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)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|