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