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