@modernman00/shared-js-lib 1.2.87 → 1.2.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ /**
2
+ * DigitalSignaturePad.js
3
+ *
4
+ * Enterprise Responsive HTML5 Canvas E-Signature Pad with:
5
+ * - Touch, Stylus & Mouse Pointer Events support
6
+ * - High-DPI / Retina display crisp scaling (devicePixelRatio compensation)
7
+ * - Bezier curve & midpoint quadratic stroke smoothing
8
+ * - Touch-action isolation (prevents accidental scrolling on mobile)
9
+ * - Dynamic baseline guide & placeholder
10
+ * - Clean Base64 PNG, SVG, and Blob export (isolated from UI guide markers)
11
+ * - Form binding & minimum-stroke validation (accidental tap rejection)
12
+ * - Biometric coordinate & velocity telemetry capture
13
+ *
14
+ * Compliant with eIDAS AES & UK Electronic Communications Act 2000.
15
+ */
16
+ export class DigitalSignaturePad {
17
+ /**
18
+ * @param {HTMLCanvasElement|string} canvas - Canvas element or selector
19
+ * @param {Object} [options={}] - Configuration options
20
+ */
21
+ constructor(canvas, options = {}) {
22
+ this.canvas = typeof canvas === 'string' ? document.querySelector(canvas) : canvas;
23
+ if (!this.canvas || !(this.canvas instanceof HTMLElement)) {
24
+ throw new Error(`[DigitalSignaturePad] Canvas element not found: ${canvas}`);
25
+ }
26
+
27
+ this.ctx = this.canvas.getContext('2d');
28
+ if (!this.ctx) {
29
+ throw new Error('[DigitalSignaturePad] Unable to get 2D rendering context from canvas.');
30
+ }
31
+
32
+ // Configuration options
33
+ this.options = {
34
+ strokeColor: options.strokeColor || '#0f172a',
35
+ strokeWidth: options.strokeWidth || 2.5,
36
+ minStrokeWidth: options.minStrokeWidth || 1.5,
37
+ maxStrokeWidth: options.maxStrokeWidth || 3.5,
38
+ velocityFilterWeight: options.velocityFilterWeight || 0.7,
39
+ backgroundColor: options.backgroundColor || 'rgba(0, 0, 0, 0)',
40
+ guide: options.guide !== false, // default true
41
+ guideText: options.guideText !== undefined ? options.guideText : 'Sign above the line ✍️',
42
+ guideColor: options.guideColor || '#94a3b8',
43
+ minPoints: options.minPoints || 8, // Minimum point samples required for valid signature
44
+ onBegin: typeof options.onBegin === 'function' ? options.onBegin : null,
45
+ onEnd: typeof options.onEnd === 'function' ? options.onEnd : null,
46
+ onChange: typeof options.onChange === 'function' ? options.onChange : null,
47
+ readOnly: !!options.readOnly
48
+ };
49
+
50
+ // State tracking
51
+ this.strokes = []; // Array of strokes, each stroke is an array of point objects
52
+ this.currentStroke = null;
53
+ this.isDrawing = false;
54
+ this.dpr = (typeof window !== 'undefined' && window.devicePixelRatio) ? window.devicePixelRatio : 1;
55
+
56
+ // Apply mobile touch isolation
57
+ this.canvas.style.touchAction = 'none';
58
+
59
+ // Bound listeners for clean destruction
60
+ this._handlePointerDown = this._onPointerDown.bind(this);
61
+ this._handlePointerMove = this._onPointerMove.bind(this);
62
+ this._handlePointerUp = this._onPointerUp.bind(this);
63
+ this._handleResize = this._onResize.bind(this);
64
+
65
+ this._initCanvas();
66
+ this._attachEvents();
67
+ }
68
+
69
+ /**
70
+ * Initializes high-DPI scaling and baseline guide.
71
+ * @private
72
+ */
73
+ _initCanvas() {
74
+ const rect = this.canvas.getBoundingClientRect();
75
+ const width = rect.width || this.canvas.width || 400;
76
+ const height = rect.height || this.canvas.height || 200;
77
+
78
+ this.width = width;
79
+ this.height = height;
80
+
81
+ this.canvas.width = Math.round(width * this.dpr);
82
+ this.canvas.height = Math.round(height * this.dpr);
83
+
84
+ if (this.ctx) {
85
+ this.ctx.scale(this.dpr, this.dpr);
86
+ }
87
+
88
+ this._redraw();
89
+ }
90
+
91
+ /**
92
+ * Attaches pointer and window events.
93
+ * @private
94
+ */
95
+ _attachEvents() {
96
+ if (this.options.readOnly) return;
97
+
98
+ this.canvas.addEventListener('pointerdown', this._handlePointerDown, { passive: false });
99
+ this.canvas.addEventListener('pointermove', this._handlePointerMove, { passive: false });
100
+ this.canvas.addEventListener('pointerup', this._handlePointerUp);
101
+ this.canvas.addEventListener('pointercancel', this._handlePointerUp);
102
+ this.canvas.addEventListener('pointerleave', this._handlePointerUp);
103
+
104
+ if (typeof window !== 'undefined') {
105
+ window.addEventListener('resize', this._handleResize);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Detaches event listeners.
111
+ */
112
+ destroy() {
113
+ this.canvas.removeEventListener('pointerdown', this._handlePointerDown);
114
+ this.canvas.removeEventListener('pointermove', this._handlePointerMove);
115
+ this.canvas.removeEventListener('pointerup', this._handlePointerUp);
116
+ this.canvas.removeEventListener('pointercancel', this._handlePointerUp);
117
+ this.canvas.removeEventListener('pointerleave', this._handlePointerUp);
118
+
119
+ if (typeof window !== 'undefined') {
120
+ window.removeEventListener('resize', this._handleResize);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handles window resize with high-DPI preservation.
126
+ * @private
127
+ */
128
+ _onResize() {
129
+ const rect = this.canvas.getBoundingClientRect();
130
+ if (!rect.width || !rect.height) return;
131
+
132
+ this.dpr = (typeof window !== 'undefined' && window.devicePixelRatio) ? window.devicePixelRatio : 1;
133
+ this.width = rect.width;
134
+ this.height = rect.height;
135
+
136
+ this.canvas.width = Math.round(this.width * this.dpr);
137
+ this.canvas.height = Math.round(this.height * this.dpr);
138
+
139
+ if (this.ctx) {
140
+ this.ctx.scale(this.dpr, this.dpr);
141
+ }
142
+
143
+ this._redraw();
144
+ }
145
+
146
+ /**
147
+ * Normalizes client coordinates into canvas coordinate space.
148
+ * @private
149
+ */
150
+ _getPoint(event) {
151
+ const rect = this.canvas.getBoundingClientRect();
152
+ const x = event.clientX - rect.left;
153
+ const y = event.clientY - rect.top;
154
+ const time = Date.now();
155
+ const pressure = event.pressure !== undefined && event.pressure > 0 ? event.pressure : 0.5;
156
+
157
+ return { x, y, time, pressure };
158
+ }
159
+
160
+ /**
161
+ * Pointer down handler.
162
+ * @private
163
+ */
164
+ _onPointerDown(event) {
165
+ if (this.options.readOnly || event.button !== 0) return;
166
+ event.preventDefault();
167
+
168
+ this.isDrawing = true;
169
+ const point = this._getPoint(event);
170
+ this.currentStroke = [point];
171
+ this.strokes.push(this.currentStroke);
172
+
173
+ if (this.options.onBegin) {
174
+ this.options.onBegin(point);
175
+ }
176
+
177
+ this._redraw();
178
+ }
179
+
180
+ /**
181
+ * Pointer move handler with midpoint curve smoothing.
182
+ * @private
183
+ */
184
+ _onPointerMove(event) {
185
+ if (!this.isDrawing || !this.currentStroke) return;
186
+ event.preventDefault();
187
+
188
+ const point = this._getPoint(event);
189
+ const stroke = this.currentStroke;
190
+ const prevPoint = stroke[stroke.length - 1];
191
+
192
+ // Ignore jitter/micro-movement (< 1.5px)
193
+ const dist = Math.hypot(point.x - prevPoint.x, point.y - prevPoint.y);
194
+ if (dist < 1.5) return;
195
+
196
+ stroke.push(point);
197
+
198
+ this._drawLastSegment(stroke);
199
+
200
+ if (this.options.onChange) {
201
+ this.options.onChange();
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Pointer up / release handler.
207
+ * @private
208
+ */
209
+ _onPointerUp(event) {
210
+ if (!this.isDrawing) return;
211
+ this.isDrawing = false;
212
+ this.currentStroke = null;
213
+
214
+ if (this.options.onEnd) {
215
+ this.options.onEnd();
216
+ }
217
+ if (this.options.onChange) {
218
+ this.options.onChange();
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Draws the latest curve segment for fluid 60fps rendering without redrawing the whole canvas.
224
+ * @private
225
+ */
226
+ _drawLastSegment(stroke) {
227
+ if (!this.ctx || stroke.length < 2) return;
228
+
229
+ const ctx = this.ctx;
230
+ const p1 = stroke[stroke.length - 2];
231
+ const p2 = stroke[stroke.length - 1];
232
+
233
+ ctx.save();
234
+ ctx.strokeStyle = this.options.strokeColor;
235
+ ctx.lineCap = 'round';
236
+ ctx.lineJoin = 'round';
237
+
238
+ // Calculate dynamic stroke width based on distance & velocity
239
+ const width = this._calculateStrokeWidth(p1, p2);
240
+ ctx.lineWidth = width;
241
+
242
+ ctx.beginPath();
243
+ if (stroke.length === 2) {
244
+ ctx.moveTo(p1.x, p1.y);
245
+ ctx.lineTo(p2.x, p2.y);
246
+ } else {
247
+ const p0 = stroke[stroke.length - 3];
248
+ const mid1 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
249
+ const mid2 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
250
+ ctx.moveTo(mid1.x, mid1.y);
251
+ ctx.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y);
252
+ }
253
+ ctx.stroke();
254
+ ctx.restore();
255
+ }
256
+
257
+ /**
258
+ * Calculates velocity-based dynamic stroke width.
259
+ * @private
260
+ */
261
+ _calculateStrokeWidth(p1, p2) {
262
+ const timeDelta = Math.max(p2.time - p1.time, 1);
263
+ const distance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
264
+ const velocity = distance / timeDelta;
265
+
266
+ const { minStrokeWidth, maxStrokeWidth, strokeWidth } = this.options;
267
+ // Faster velocity -> thinner line (fountain pen stroke dynamics)
268
+ const dynamicWidth = Math.max(
269
+ minStrokeWidth,
270
+ Math.min(maxStrokeWidth, strokeWidth / (velocity * 0.5 + 1))
271
+ );
272
+
273
+ return dynamicWidth;
274
+ }
275
+
276
+ /**
277
+ * Full canvas redraw: background, guide (if empty), and all strokes.
278
+ * @param {boolean} [includeGuide=true] - Whether to draw the baseline guide
279
+ * @private
280
+ */
281
+ _redraw(includeGuide = true) {
282
+ if (!this.ctx) return;
283
+ const ctx = this.ctx;
284
+
285
+ ctx.save();
286
+ ctx.clearRect(0, 0, this.width, this.height);
287
+
288
+ // 1. Draw background
289
+ if (this.options.backgroundColor && this.options.backgroundColor !== 'rgba(0, 0, 0, 0)') {
290
+ ctx.fillStyle = this.options.backgroundColor;
291
+ ctx.fillRect(0, 0, this.width, this.height);
292
+ }
293
+
294
+ // 2. Draw placeholder guide if empty and enabled
295
+ if (includeGuide && this.options.guide && this.isEmpty()) {
296
+ this._drawGuide();
297
+ }
298
+
299
+ // 3. Draw all recorded strokes with midpoint quadratic curves
300
+ for (const stroke of this.strokes) {
301
+ if (stroke.length === 0) continue;
302
+
303
+ ctx.save();
304
+ ctx.strokeStyle = this.options.strokeColor;
305
+ ctx.lineCap = 'round';
306
+ ctx.lineJoin = 'round';
307
+
308
+ if (stroke.length === 1) {
309
+ // Single tap dot
310
+ ctx.fillStyle = this.options.strokeColor;
311
+ ctx.beginPath();
312
+ ctx.arc(stroke[0].x, stroke[0].y, this.options.strokeWidth / 2, 0, Math.PI * 2);
313
+ ctx.fill();
314
+ } else {
315
+ for (let i = 1; i < stroke.length; i++) {
316
+ const p1 = stroke[i - 1];
317
+ const p2 = stroke[i];
318
+ ctx.lineWidth = this._calculateStrokeWidth(p1, p2);
319
+
320
+ ctx.beginPath();
321
+ if (i === 1) {
322
+ ctx.moveTo(p1.x, p1.y);
323
+ ctx.lineTo(p2.x, p2.y);
324
+ } else {
325
+ const p0 = stroke[i - 2];
326
+ const mid1 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
327
+ const mid2 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
328
+ ctx.moveTo(mid1.x, mid1.y);
329
+ ctx.quadraticCurveTo(p1.x, p1.y, mid2.x, mid2.y);
330
+ }
331
+ ctx.stroke();
332
+ }
333
+ }
334
+ ctx.restore();
335
+ }
336
+
337
+ ctx.restore();
338
+ }
339
+
340
+ /**
341
+ * Draws a subtle dashed baseline and prompt text for guidance.
342
+ * @private
343
+ */
344
+ _drawGuide() {
345
+ if (!this.ctx) return;
346
+ const ctx = this.ctx;
347
+ const guideY = Math.round(this.height * 0.75);
348
+
349
+ ctx.save();
350
+ ctx.strokeStyle = this.options.guideColor;
351
+ ctx.lineWidth = 1;
352
+ if (typeof ctx.setLineDash === 'function') {
353
+ ctx.setLineDash([4, 4]);
354
+ }
355
+
356
+ // Baseline
357
+ ctx.beginPath();
358
+ ctx.moveTo(20, guideY);
359
+ ctx.lineTo(this.width - 20, guideY);
360
+ ctx.stroke();
361
+
362
+ // Helper text
363
+ if (this.options.guideText) {
364
+ ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
365
+ ctx.fillStyle = this.options.guideColor;
366
+ ctx.textAlign = 'center';
367
+ ctx.fillText(this.options.guideText, this.width / 2, guideY + 18);
368
+ }
369
+
370
+ ctx.restore();
371
+ }
372
+
373
+ /**
374
+ * Returns true if no valid strokes have been drawn or if points are under the minimum threshold.
375
+ * Rejects accidental 1-pixel taps to ensure genuine biometric intent.
376
+ *
377
+ * @returns {boolean}
378
+ */
379
+ isEmpty() {
380
+ let totalPoints = 0;
381
+ for (const stroke of this.strokes) {
382
+ totalPoints += stroke.length;
383
+ }
384
+ return totalPoints < this.options.minPoints;
385
+ }
386
+
387
+ /**
388
+ * Boolean check whether the signature satisfies minimum valid biometric threshold.
389
+ * @returns {boolean}
390
+ */
391
+ isValid() {
392
+ return !this.isEmpty();
393
+ }
394
+
395
+ /**
396
+ * Clears all strokes, resets canvas, and restores the guide placeholder.
397
+ */
398
+ clear() {
399
+ this.strokes = [];
400
+ this.currentStroke = null;
401
+ this.isDrawing = false;
402
+ this._redraw(true);
403
+
404
+ if (this.options.onChange) {
405
+ this.options.onChange();
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Exports signature as Base64 Data URL (PNG by default).
411
+ * Guaranteed to isolate the pure signature from UI guide placeholders.
412
+ *
413
+ * @param {string} [type='image/png']
414
+ * @param {number} [quality=0.92]
415
+ * @returns {string}
416
+ */
417
+ toDataURL(type = 'image/png', quality = 0.92) {
418
+ if (this.isEmpty()) {
419
+ return '';
420
+ }
421
+
422
+ // Redraw without guide line for clean legal artifact export
423
+ this._redraw(false);
424
+ const dataUrl = this.canvas.toDataURL(type, quality);
425
+ // Restore guide line if needed
426
+ this._redraw(true);
427
+
428
+ return dataUrl;
429
+ }
430
+
431
+ /**
432
+ * Exports signature as a Blob.
433
+ *
434
+ * @param {Function} callback - (blob) => void
435
+ * @param {string} [type='image/png']
436
+ * @param {number} [quality=0.92]
437
+ */
438
+ toBlob(callback, type = 'image/png', quality = 0.92) {
439
+ if (this.isEmpty()) {
440
+ callback(null);
441
+ return;
442
+ }
443
+
444
+ this._redraw(false);
445
+ this.canvas.toBlob((blob) => {
446
+ this._redraw(true);
447
+ callback(blob);
448
+ }, type, quality);
449
+ }
450
+
451
+ /**
452
+ * Exports strokes as an SVG vector string.
453
+ * Ideal for high-resolution print agreements and PDF rendering.
454
+ *
455
+ * @returns {string}
456
+ */
457
+ toSVG() {
458
+ if (this.isEmpty()) {
459
+ return '';
460
+ }
461
+
462
+ let paths = '';
463
+ for (const stroke of this.strokes) {
464
+ if (stroke.length === 0) continue;
465
+ let d = `M ${stroke[0].x.toFixed(1)} ${stroke[0].y.toFixed(1)}`;
466
+ for (let i = 1; i < stroke.length; i++) {
467
+ const p1 = stroke[i - 1];
468
+ const p2 = stroke[i];
469
+ const midX = ((p1.x + p2.x) / 2).toFixed(1);
470
+ const midY = ((p1.y + p2.y) / 2).toFixed(1);
471
+ d += ` Q ${p1.x.toFixed(1)} ${p1.y.toFixed(1)} ${midX} ${midY}`;
472
+ }
473
+ paths += `<path d="${d}" fill="none" stroke="${this.options.strokeColor}" stroke-width="${this.options.strokeWidth}" stroke-linecap="round" stroke-linejoin="round" />\n`;
474
+ }
475
+
476
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">\n${paths}</svg>`;
477
+ }
478
+
479
+ /**
480
+ * Returns biometric telemetry coordinates for audit records.
481
+ * @returns {Array<Array<{x: number, y: number, time: number, pressure: number}>>}
482
+ */
483
+ getStrokes() {
484
+ return JSON.parse(JSON.stringify(this.strokes));
485
+ }
486
+
487
+ /**
488
+ * Restores strokes from serialized biometric coordinates.
489
+ * @param {Array<Array<{x: number, y: number, time: number, pressure: number}>>} strokes
490
+ */
491
+ fromStrokes(strokes) {
492
+ if (!Array.isArray(strokes)) return;
493
+ this.strokes = JSON.parse(JSON.stringify(strokes));
494
+ this._redraw(true);
495
+ if (this.options.onChange) {
496
+ this.options.onChange();
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Seamlessly binds the signature pad to an HTML form.
502
+ * On form submit:
503
+ * - Validates that a genuine signature is present (!isEmpty()).
504
+ * - Injects the Base64 signature into a hidden input.
505
+ * - Blocks submit and invokes onValidationError callback if empty.
506
+ *
507
+ * @param {HTMLFormElement|string} form - Form element or selector
508
+ * @param {Object} [bindOptions={}]
509
+ * @param {string} [bindOptions.inputName='signature_base64'] - Name/id of hidden input
510
+ * @param {Function} [bindOptions.onValidationError] - Invoked when user attempts submit without signing
511
+ * @returns {HTMLInputElement} The bound hidden input
512
+ */
513
+ bindToForm(form, bindOptions = {}) {
514
+ const formEl = typeof form === 'string' ? document.querySelector(form) : form;
515
+ if (!formEl || !(formEl instanceof HTMLFormElement)) {
516
+ throw new Error(`[DigitalSignaturePad] Target form element not found: ${form}`);
517
+ }
518
+
519
+ const inputName = bindOptions.inputName || 'signature_base64';
520
+ let hiddenInput = formEl.querySelector(`input[name="${inputName}"]`);
521
+ if (!hiddenInput) {
522
+ hiddenInput = document.createElement('input');
523
+ hiddenInput.type = 'hidden';
524
+ hiddenInput.name = inputName;
525
+ formEl.appendChild(hiddenInput);
526
+ }
527
+
528
+ formEl.addEventListener('submit', (e) => {
529
+ if (this.isEmpty()) {
530
+ e.preventDefault();
531
+ if (typeof bindOptions.onValidationError === 'function') {
532
+ bindOptions.onValidationError('Please sign the agreement above before proceeding.');
533
+ } else {
534
+ alert('Please provide your signature before submitting.');
535
+ }
536
+ return false;
537
+ }
538
+
539
+ hiddenInput.value = this.toDataURL();
540
+ return true;
541
+ });
542
+
543
+ return hiddenInput;
544
+ }
545
+ }
@@ -1,3 +1,3 @@
1
1
  <?php
2
- define('APP_VERSION', 'v1.2.87');
2
+ define('APP_VERSION', 'v1.2.88');
3
3
 
package/index.js CHANGED
@@ -31,4 +31,5 @@ export * from './CardCanvasGenerator.js';
31
31
  export * from './ShareManager.js';
32
32
  export * from './WebAuthnClient.js';
33
33
  export * from './OtpInputManager.js';
34
- export * from './AddressAutocomplete.js';
34
+ export * from './AddressAutocomplete.js';
35
+ export * from './DigitalSignaturePad.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modernman00/shared-js-lib",
3
- "version": "1.2.87",
3
+ "version": "1.2.88",
4
4
  "description": "Reusable JS utilities for numerous js problems",
5
5
  "homepage": "https://github.com/modernman00/shared-js-lib#readme",
6
6
  "keywords": [
@@ -64,6 +64,7 @@
64
64
  "ShareManager.js",
65
65
  "OtpInputManager.js",
66
66
  "AddressAutocomplete.js",
67
+ "DigitalSignaturePad.js",
67
68
  "templates/"
68
69
  ],
69
70
  "devDependencies": {
@@ -158,3 +158,8 @@
158
158
  - feat(pwa): harden PwaManager badging sync, support diverse envelopes, and dispatch pwa:badge-updated event _(by modernman00)_
159
159
  - 🔖 Release v1.2.84 _(by modernman00)_
160
160
  - 🧹 Auto-commit before release _(by modernman00)_
161
+
162
+ ## v1.2.88 — 2026-10-14 18:00
163
+ ### Changelog for v1.2.88
164
+
165
+ - 🧹 Auto-commit before release _(by modernman00)_