1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 | import { Bezier } from './bezier';
|
13 | import { BasicPoint, Point } from './point';
|
14 | import { SignatureEventTarget } from './signature_event_target';
|
15 | import { throttle } from './throttle';
|
16 |
|
17 | declare global {
|
18 | interface CSSStyleDeclaration {
|
19 | msTouchAction: string | null;
|
20 | }
|
21 | }
|
22 |
|
23 | export type SignatureEvent = MouseEvent | Touch | PointerEvent;
|
24 |
|
25 | export interface FromDataOptions {
|
26 | clear?: boolean;
|
27 | }
|
28 |
|
29 | export interface PointGroupOptions {
|
30 | dotSize: number;
|
31 | minWidth: number;
|
32 | maxWidth: number;
|
33 | penColor: string;
|
34 | }
|
35 |
|
36 | export interface Options extends Partial<PointGroupOptions> {
|
37 | minDistance?: number;
|
38 | velocityFilterWeight?: number;
|
39 | backgroundColor?: string;
|
40 | throttle?: number;
|
41 | }
|
42 |
|
43 | export interface PointGroup extends PointGroupOptions {
|
44 | points: BasicPoint[];
|
45 | }
|
46 |
|
47 | export default class SignaturePad extends SignatureEventTarget {
|
48 |
|
49 | public dotSize: number;
|
50 | public minWidth: number;
|
51 | public maxWidth: number;
|
52 | public penColor: string;
|
53 | public minDistance: number;
|
54 | public velocityFilterWeight: number;
|
55 | public backgroundColor: string;
|
56 | public throttle: number;
|
57 |
|
58 |
|
59 |
|
60 | private _ctx: CanvasRenderingContext2D;
|
61 | private _drawningStroke: boolean;
|
62 | private _isEmpty: boolean;
|
63 | private _lastPoints: Point[];
|
64 | private _data: PointGroup[];
|
65 | private _lastVelocity: number;
|
66 | private _lastWidth: number;
|
67 | private _strokeMoveUpdate: (event: SignatureEvent) => void;
|
68 |
|
69 |
|
70 | constructor(private canvas: HTMLCanvasElement, options: Options = {}) {
|
71 | super();
|
72 | this.velocityFilterWeight = options.velocityFilterWeight || 0.7;
|
73 | this.minWidth = options.minWidth || 0.5;
|
74 | this.maxWidth = options.maxWidth || 2.5;
|
75 | this.throttle = ('throttle' in options ? options.throttle : 16) as number;
|
76 | this.minDistance = (
|
77 | 'minDistance' in options ? options.minDistance : 5
|
78 | ) as number;
|
79 | this.dotSize = options.dotSize || 0;
|
80 | this.penColor = options.penColor || 'black';
|
81 | this.backgroundColor = options.backgroundColor || 'rgba(0,0,0,0)';
|
82 |
|
83 | this._strokeMoveUpdate = this.throttle
|
84 | ? throttle(SignaturePad.prototype._strokeUpdate, this.throttle)
|
85 | : SignaturePad.prototype._strokeUpdate;
|
86 | this._ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
87 |
|
88 | this.clear();
|
89 |
|
90 |
|
91 | this.on();
|
92 | }
|
93 |
|
94 | public clear(): void {
|
95 | const { _ctx: ctx, canvas } = this;
|
96 |
|
97 |
|
98 | ctx.fillStyle = this.backgroundColor;
|
99 | ctx.clearRect(0, 0, canvas.width, canvas.height);
|
100 | ctx.fillRect(0, 0, canvas.width, canvas.height);
|
101 |
|
102 | this._data = [];
|
103 | this._reset();
|
104 | this._isEmpty = true;
|
105 | }
|
106 |
|
107 | public fromDataURL(
|
108 | dataUrl: string,
|
109 | options: {
|
110 | ratio?: number;
|
111 | width?: number;
|
112 | height?: number;
|
113 | xOffset?: number;
|
114 | yOffset?: number;
|
115 | } = {},
|
116 | ): Promise<void> {
|
117 | return new Promise((resolve, reject) => {
|
118 | const image = new Image();
|
119 | const ratio = options.ratio || window.devicePixelRatio || 1;
|
120 | const width = options.width || this.canvas.width / ratio;
|
121 | const height = options.height || this.canvas.height / ratio;
|
122 | const xOffset = options.xOffset || 0;
|
123 | const yOffset = options.yOffset || 0;
|
124 |
|
125 | this._reset();
|
126 |
|
127 | image.onload = (): void => {
|
128 | this._ctx.drawImage(image, xOffset, yOffset, width, height);
|
129 | resolve();
|
130 | };
|
131 | image.onerror = (error): void => {
|
132 | reject(error);
|
133 | };
|
134 | image.crossOrigin = 'anonymous';
|
135 | image.src = dataUrl;
|
136 |
|
137 | this._isEmpty = false;
|
138 | });
|
139 | }
|
140 |
|
141 | public toDataURL(type = 'image/png', encoderOptions?: number): string {
|
142 | switch (type) {
|
143 | case 'image/svg+xml':
|
144 | return this._toSVG();
|
145 | default:
|
146 | return this.canvas.toDataURL(type, encoderOptions);
|
147 | }
|
148 | }
|
149 |
|
150 | public on(): void {
|
151 |
|
152 | this.canvas.style.touchAction = 'none';
|
153 | this.canvas.style.msTouchAction = 'none';
|
154 |
|
155 | const isIOS =
|
156 | /Macintosh/.test(navigator.userAgent) && 'ontouchstart' in document;
|
157 |
|
158 |
|
159 |
|
160 | if (window.PointerEvent && !isIOS) {
|
161 | this._handlePointerEvents();
|
162 | } else {
|
163 | this._handleMouseEvents();
|
164 |
|
165 | if ('ontouchstart' in window) {
|
166 | this._handleTouchEvents();
|
167 | }
|
168 | }
|
169 | }
|
170 |
|
171 | public off(): void {
|
172 |
|
173 | this.canvas.style.touchAction = 'auto';
|
174 | this.canvas.style.msTouchAction = 'auto';
|
175 |
|
176 | this.canvas.removeEventListener('pointerdown', this._handlePointerStart);
|
177 | this.canvas.removeEventListener('pointermove', this._handlePointerMove);
|
178 | document.removeEventListener('pointerup', this._handlePointerEnd);
|
179 |
|
180 | this.canvas.removeEventListener('mousedown', this._handleMouseDown);
|
181 | this.canvas.removeEventListener('mousemove', this._handleMouseMove);
|
182 | document.removeEventListener('mouseup', this._handleMouseUp);
|
183 |
|
184 | this.canvas.removeEventListener('touchstart', this._handleTouchStart);
|
185 | this.canvas.removeEventListener('touchmove', this._handleTouchMove);
|
186 | this.canvas.removeEventListener('touchend', this._handleTouchEnd);
|
187 | }
|
188 |
|
189 | public isEmpty(): boolean {
|
190 | return this._isEmpty;
|
191 | }
|
192 |
|
193 | public fromData(
|
194 | pointGroups: PointGroup[],
|
195 | { clear = true }: FromDataOptions = {},
|
196 | ): void {
|
197 | if (clear) {
|
198 | this.clear();
|
199 | }
|
200 |
|
201 | this._fromData(
|
202 | pointGroups,
|
203 | this._drawCurve.bind(this),
|
204 | this._drawDot.bind(this),
|
205 | );
|
206 |
|
207 | this._data = clear ? pointGroups : this._data.concat(pointGroups);
|
208 | }
|
209 |
|
210 | public toData(): PointGroup[] {
|
211 | return this._data;
|
212 | }
|
213 |
|
214 |
|
215 | private _handleMouseDown = (event: MouseEvent): void => {
|
216 | if (event.buttons === 1) {
|
217 | this._drawningStroke = true;
|
218 | this._strokeBegin(event);
|
219 | }
|
220 | };
|
221 |
|
222 | private _handleMouseMove = (event: MouseEvent): void => {
|
223 | if (this._drawningStroke) {
|
224 | this._strokeMoveUpdate(event);
|
225 | }
|
226 | };
|
227 |
|
228 | private _handleMouseUp = (event: MouseEvent): void => {
|
229 | if (event.buttons === 1 && this._drawningStroke) {
|
230 | this._drawningStroke = false;
|
231 | this._strokeEnd(event);
|
232 | }
|
233 | };
|
234 |
|
235 | private _handleTouchStart = (event: TouchEvent): void => {
|
236 |
|
237 | event.preventDefault();
|
238 |
|
239 | if (event.targetTouches.length === 1) {
|
240 | const touch = event.changedTouches[0];
|
241 | this._strokeBegin(touch);
|
242 | }
|
243 | };
|
244 |
|
245 | private _handleTouchMove = (event: TouchEvent): void => {
|
246 |
|
247 | event.preventDefault();
|
248 |
|
249 | const touch = event.targetTouches[0];
|
250 | this._strokeMoveUpdate(touch);
|
251 | };
|
252 |
|
253 | private _handleTouchEnd = (event: TouchEvent): void => {
|
254 | const wasCanvasTouched = event.target === this.canvas;
|
255 | if (wasCanvasTouched) {
|
256 | event.preventDefault();
|
257 |
|
258 | const touch = event.changedTouches[0];
|
259 | this._strokeEnd(touch);
|
260 | }
|
261 | };
|
262 |
|
263 | private _handlePointerStart = (event: PointerEvent): void => {
|
264 | this._drawningStroke = true;
|
265 | event.preventDefault();
|
266 | this._strokeBegin(event);
|
267 | };
|
268 |
|
269 | private _handlePointerMove = (event: PointerEvent): void => {
|
270 | if (this._drawningStroke) {
|
271 | event.preventDefault();
|
272 | this._strokeMoveUpdate(event);
|
273 | }
|
274 | };
|
275 |
|
276 | private _handlePointerEnd = (event: PointerEvent): void => {
|
277 | this._drawningStroke = false;
|
278 | const wasCanvasTouched = event.target === this.canvas;
|
279 | if (wasCanvasTouched) {
|
280 | event.preventDefault();
|
281 | this._strokeEnd(event);
|
282 | }
|
283 | };
|
284 |
|
285 |
|
286 | private _strokeBegin(event: SignatureEvent): void {
|
287 | this.dispatchEvent(new CustomEvent('beginStroke', { detail: event }));
|
288 |
|
289 | const newPointGroup: PointGroup = {
|
290 | dotSize: this.dotSize,
|
291 | minWidth: this.minWidth,
|
292 | maxWidth: this.maxWidth,
|
293 | penColor: this.penColor,
|
294 | points: [],
|
295 | };
|
296 |
|
297 | this._data.push(newPointGroup);
|
298 | this._reset();
|
299 | this._strokeUpdate(event);
|
300 | }
|
301 |
|
302 | private _strokeUpdate(event: SignatureEvent): void {
|
303 | if (this._data.length === 0) {
|
304 |
|
305 |
|
306 | this._strokeBegin(event);
|
307 | return;
|
308 | }
|
309 |
|
310 | this.dispatchEvent(
|
311 | new CustomEvent('beforeUpdateStroke', { detail: event }),
|
312 | );
|
313 |
|
314 | const x = event.clientX;
|
315 | const y = event.clientY;
|
316 | const pressure =
|
317 | (event as PointerEvent).pressure !== undefined
|
318 | ? (event as PointerEvent).pressure
|
319 | : (event as Touch).force !== undefined
|
320 | ? (event as Touch).force
|
321 | : 0;
|
322 |
|
323 | const point = this._createPoint(x, y, pressure);
|
324 | const lastPointGroup = this._data[this._data.length - 1];
|
325 | const lastPoints = lastPointGroup.points;
|
326 | const lastPoint =
|
327 | lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
|
328 | const isLastPointTooClose = lastPoint
|
329 | ? point.distanceTo(lastPoint) <= this.minDistance
|
330 | : false;
|
331 | const { penColor, dotSize, minWidth, maxWidth } = lastPointGroup;
|
332 |
|
333 |
|
334 | if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
|
335 | const curve = this._addPoint(point);
|
336 |
|
337 | if (!lastPoint) {
|
338 | this._drawDot(point, {
|
339 | penColor,
|
340 | dotSize,
|
341 | minWidth,
|
342 | maxWidth,
|
343 | });
|
344 | } else if (curve) {
|
345 | this._drawCurve(curve, {
|
346 | penColor,
|
347 | dotSize,
|
348 | minWidth,
|
349 | maxWidth,
|
350 | });
|
351 | }
|
352 |
|
353 | lastPoints.push({
|
354 | time: point.time,
|
355 | x: point.x,
|
356 | y: point.y,
|
357 | pressure: point.pressure,
|
358 | });
|
359 | }
|
360 |
|
361 | this.dispatchEvent(new CustomEvent('afterUpdateStroke', { detail: event }));
|
362 | }
|
363 |
|
364 | private _strokeEnd(event: SignatureEvent): void {
|
365 | this._strokeUpdate(event);
|
366 |
|
367 | this.dispatchEvent(new CustomEvent('endStroke', { detail: event }));
|
368 | }
|
369 |
|
370 | private _handlePointerEvents(): void {
|
371 | this._drawningStroke = false;
|
372 |
|
373 | this.canvas.addEventListener('pointerdown', this._handlePointerStart);
|
374 | this.canvas.addEventListener('pointermove', this._handlePointerMove);
|
375 | document.addEventListener('pointerup', this._handlePointerEnd);
|
376 | }
|
377 |
|
378 | private _handleMouseEvents(): void {
|
379 | this._drawningStroke = false;
|
380 |
|
381 | this.canvas.addEventListener('mousedown', this._handleMouseDown);
|
382 | this.canvas.addEventListener('mousemove', this._handleMouseMove);
|
383 | document.addEventListener('mouseup', this._handleMouseUp);
|
384 | }
|
385 |
|
386 | private _handleTouchEvents(): void {
|
387 | this.canvas.addEventListener('touchstart', this._handleTouchStart);
|
388 | this.canvas.addEventListener('touchmove', this._handleTouchMove);
|
389 | this.canvas.addEventListener('touchend', this._handleTouchEnd);
|
390 | }
|
391 |
|
392 |
|
393 | private _reset(): void {
|
394 | this._lastPoints = [];
|
395 | this._lastVelocity = 0;
|
396 | this._lastWidth = (this.minWidth + this.maxWidth) / 2;
|
397 | this._ctx.fillStyle = this.penColor;
|
398 | }
|
399 |
|
400 | private _createPoint(x: number, y: number, pressure: number): Point {
|
401 | const rect = this.canvas.getBoundingClientRect();
|
402 |
|
403 | return new Point(
|
404 | x - rect.left,
|
405 | y - rect.top,
|
406 | pressure,
|
407 | new Date().getTime(),
|
408 | );
|
409 | }
|
410 |
|
411 |
|
412 | private _addPoint(point: Point): Bezier | null {
|
413 | const { _lastPoints } = this;
|
414 |
|
415 | _lastPoints.push(point);
|
416 |
|
417 | if (_lastPoints.length > 2) {
|
418 |
|
419 |
|
420 | if (_lastPoints.length === 3) {
|
421 | _lastPoints.unshift(_lastPoints[0]);
|
422 | }
|
423 |
|
424 |
|
425 | const widths = this._calculateCurveWidths(_lastPoints[1], _lastPoints[2]);
|
426 | const curve = Bezier.fromPoints(_lastPoints, widths);
|
427 |
|
428 |
|
429 | _lastPoints.shift();
|
430 |
|
431 | return curve;
|
432 | }
|
433 |
|
434 | return null;
|
435 | }
|
436 |
|
437 | private _calculateCurveWidths(
|
438 | startPoint: Point,
|
439 | endPoint: Point,
|
440 | ): { start: number; end: number } {
|
441 | const velocity =
|
442 | this.velocityFilterWeight * endPoint.velocityFrom(startPoint) +
|
443 | (1 - this.velocityFilterWeight) * this._lastVelocity;
|
444 |
|
445 | const newWidth = this._strokeWidth(velocity);
|
446 |
|
447 | const widths = {
|
448 | end: newWidth,
|
449 | start: this._lastWidth,
|
450 | };
|
451 |
|
452 | this._lastVelocity = velocity;
|
453 | this._lastWidth = newWidth;
|
454 |
|
455 | return widths;
|
456 | }
|
457 |
|
458 | private _strokeWidth(velocity: number): number {
|
459 | return Math.max(this.maxWidth / (velocity + 1), this.minWidth);
|
460 | }
|
461 |
|
462 | private _drawCurveSegment(x: number, y: number, width: number): void {
|
463 | const ctx = this._ctx;
|
464 |
|
465 | ctx.moveTo(x, y);
|
466 | ctx.arc(x, y, width, 0, 2 * Math.PI, false);
|
467 | this._isEmpty = false;
|
468 | }
|
469 |
|
470 | private _drawCurve(curve: Bezier, options: PointGroupOptions): void {
|
471 | const ctx = this._ctx;
|
472 | const widthDelta = curve.endWidth - curve.startWidth;
|
473 |
|
474 |
|
475 | const drawSteps = Math.ceil(curve.length()) * 2;
|
476 |
|
477 | ctx.beginPath();
|
478 | ctx.fillStyle = options.penColor;
|
479 |
|
480 | for (let i = 0; i < drawSteps; i += 1) {
|
481 |
|
482 | const t = i / drawSteps;
|
483 | const tt = t * t;
|
484 | const ttt = tt * t;
|
485 | const u = 1 - t;
|
486 | const uu = u * u;
|
487 | const uuu = uu * u;
|
488 |
|
489 | let x = uuu * curve.startPoint.x;
|
490 | x += 3 * uu * t * curve.control1.x;
|
491 | x += 3 * u * tt * curve.control2.x;
|
492 | x += ttt * curve.endPoint.x;
|
493 |
|
494 | let y = uuu * curve.startPoint.y;
|
495 | y += 3 * uu * t * curve.control1.y;
|
496 | y += 3 * u * tt * curve.control2.y;
|
497 | y += ttt * curve.endPoint.y;
|
498 |
|
499 | const width = Math.min(
|
500 | curve.startWidth + ttt * widthDelta,
|
501 | options.maxWidth,
|
502 | );
|
503 | this._drawCurveSegment(x, y, width);
|
504 | }
|
505 |
|
506 | ctx.closePath();
|
507 | ctx.fill();
|
508 | }
|
509 |
|
510 | private _drawDot(point: BasicPoint, options: PointGroupOptions): void {
|
511 | const ctx = this._ctx;
|
512 | const width =
|
513 | options.dotSize > 0
|
514 | ? options.dotSize
|
515 | : (options.minWidth + options.maxWidth) / 2;
|
516 |
|
517 | ctx.beginPath();
|
518 | this._drawCurveSegment(point.x, point.y, width);
|
519 | ctx.closePath();
|
520 | ctx.fillStyle = options.penColor;
|
521 | ctx.fill();
|
522 | }
|
523 |
|
524 | private _fromData(
|
525 | pointGroups: PointGroup[],
|
526 | drawCurve: SignaturePad['_drawCurve'],
|
527 | drawDot: SignaturePad['_drawDot'],
|
528 | ): void {
|
529 | for (const group of pointGroups) {
|
530 | const { penColor, dotSize, minWidth, maxWidth, points } = group;
|
531 |
|
532 | if (points.length > 1) {
|
533 | for (let j = 0; j < points.length; j += 1) {
|
534 | const basicPoint = points[j];
|
535 | const point = new Point(
|
536 | basicPoint.x,
|
537 | basicPoint.y,
|
538 | basicPoint.pressure,
|
539 | basicPoint.time,
|
540 | );
|
541 |
|
542 |
|
543 |
|
544 | this.penColor = penColor;
|
545 |
|
546 | if (j === 0) {
|
547 | this._reset();
|
548 | }
|
549 |
|
550 | const curve = this._addPoint(point);
|
551 |
|
552 | if (curve) {
|
553 | drawCurve(curve, {
|
554 | penColor,
|
555 | dotSize,
|
556 | minWidth,
|
557 | maxWidth,
|
558 | });
|
559 | }
|
560 | }
|
561 | } else {
|
562 | this._reset();
|
563 |
|
564 | drawDot(points[0], {
|
565 | penColor,
|
566 | dotSize,
|
567 | minWidth,
|
568 | maxWidth,
|
569 | });
|
570 | }
|
571 | }
|
572 | }
|
573 |
|
574 | private _toSVG(): string {
|
575 | const pointGroups = this._data;
|
576 | const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
577 | const minX = 0;
|
578 | const minY = 0;
|
579 | const maxX = this.canvas.width / ratio;
|
580 | const maxY = this.canvas.height / ratio;
|
581 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
582 |
|
583 | svg.setAttribute('width', this.canvas.width.toString());
|
584 | svg.setAttribute('height', this.canvas.height.toString());
|
585 |
|
586 | this._fromData(
|
587 | pointGroups,
|
588 |
|
589 | (curve, { penColor }) => {
|
590 | const path = document.createElement('path');
|
591 |
|
592 |
|
593 |
|
594 |
|
595 |
|
596 | if (
|
597 | !isNaN(curve.control1.x) &&
|
598 | !isNaN(curve.control1.y) &&
|
599 | !isNaN(curve.control2.x) &&
|
600 | !isNaN(curve.control2.y)
|
601 | ) {
|
602 | const attr =
|
603 | `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
|
604 | 3,
|
605 | )} ` +
|
606 | `C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ` +
|
607 | `${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ` +
|
608 | `${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
|
609 | path.setAttribute('d', attr);
|
610 | path.setAttribute('stroke-width', (curve.endWidth * 2.25).toFixed(3));
|
611 | path.setAttribute('stroke', penColor);
|
612 | path.setAttribute('fill', 'none');
|
613 | path.setAttribute('stroke-linecap', 'round');
|
614 |
|
615 | svg.appendChild(path);
|
616 | }
|
617 |
|
618 | },
|
619 |
|
620 | (point, { penColor, dotSize, minWidth, maxWidth }) => {
|
621 | const circle = document.createElement('circle');
|
622 | const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
|
623 | circle.setAttribute('r', size.toString());
|
624 | circle.setAttribute('cx', point.x.toString());
|
625 | circle.setAttribute('cy', point.y.toString());
|
626 | circle.setAttribute('fill', penColor);
|
627 |
|
628 | svg.appendChild(circle);
|
629 | },
|
630 | );
|
631 |
|
632 | const prefix = 'data:image/svg+xml;base64,';
|
633 | const header =
|
634 | '<svg' +
|
635 | ' xmlns="http://www.w3.org/2000/svg"' +
|
636 | ' xmlns:xlink="http://www.w3.org/1999/xlink"' +
|
637 | ` viewBox="${minX} ${minY} ${this.canvas.width} ${this.canvas.height}"` +
|
638 | ` width="${maxX}"` +
|
639 | ` height="${maxY}"` +
|
640 | '>';
|
641 | let body = svg.innerHTML;
|
642 |
|
643 |
|
644 | if (body === undefined) {
|
645 | const dummy = document.createElement('dummy');
|
646 | const nodes = svg.childNodes;
|
647 | dummy.innerHTML = '';
|
648 |
|
649 |
|
650 | for (let i = 0; i < nodes.length; i += 1) {
|
651 | dummy.appendChild(nodes[i].cloneNode(true));
|
652 | }
|
653 |
|
654 | body = dummy.innerHTML;
|
655 | }
|
656 |
|
657 | const footer = '</svg>';
|
658 | const data = header + body + footer;
|
659 |
|
660 | return prefix + btoa(data);
|
661 | }
|
662 | }
|