// 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)), }; } }