avances en plantillas

This commit is contained in:
JACS 2026-05-01 18:15:40 -05:00
parent 0f84beacf1
commit da0530d79b
2062 changed files with 598814 additions and 22 deletions

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Szymon Nowak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,240 @@
# Signature Pad [![npm](https://badge.fury.io/js/signature_pad.svg)](https://www.npmjs.com/package/signature_pad) [![tests](https://github.com/szimek/signature_pad/actions/workflows/test.yml/badge.svg)](https://github.com/szimek/signature_pad/actions/workflows/test.yml) [![Code Climate](https://codeclimate.com/github/szimek/signature_pad.png)](https://codeclimate.com/github/szimek/signature_pad) [![](https://data.jsdelivr.com/v1/package/npm/signature_pad/badge?style=rounded)](https://www.jsdelivr.com/package/npm/signature_pad)
Signature Pad is a JavaScript library for drawing smooth signatures. It's HTML5 canvas based and uses variable width Bézier curve interpolation based on [Smoother Signatures](https://developer.squareup.com/blog/smoother-signatures/) post by [Square](https://squareup.com).
It works in all modern desktop and mobile browsers and doesn't depend on any external libraries.
![Example](https://f.cloud.github.com/assets/9873/268046/9ced3454-8efc-11e2-816e-a9b170a51004.png)
## Demo
[Demo](http://szimek.github.io/signature_pad) works in desktop and mobile browsers. You can check out its [source code](https://github.com/szimek/signature_pad/blob/master/docs/js/app.js) for some tips on how to handle window resize and high DPI screens. You can also find more about the latter in [HTML5 Rocks tutorial](http://www.html5rocks.com/en/tutorials/canvas/hidpi).
### Other demos
- Erase feature: <https://jsfiddle.net/UziTech/xa91e4Lp/>
- Undo feature: <https://jsfiddle.net/szimek/osenxvjc/>
## Installation
You can install the latest release using npm:
```bash
npm install --save signature_pad
```
or Yarn:
```bash
yarn add signature_pad
```
You can also add it directly to your page using `<script>` tag:
> [!NOTE]
> Replace `[version]` with the version you want to use.
```html
<script src="https://cdn.jsdelivr.net/npm/signature_pad@[version]/dist/signature_pad.umd.min.js"></script>
```
You can select a different version at [https://www.jsdelivr.com/package/npm/signature_pad](https://www.jsdelivr.com/package/npm/signature_pad).
This library is provided as UMD (Universal Module Definition) and ES6 module.
## Usage
### API
```javascript
const canvas = document.querySelector("canvas");
const signaturePad = new SignaturePad(canvas);
// Returns signature image as data URL (see https://mdn.io/todataurl for the list of possible parameters)
signaturePad.toDataURL(); // save image as PNG
signaturePad.toDataURL("image/jpeg"); // save image as JPEG
signaturePad.toDataURL("image/jpeg", 0.5); // save image as JPEG with 0.5 image quality
signaturePad.toDataURL("image/svg+xml"); // save image as SVG data url
// Return svg string without converting to base64
signaturePad.toSVG(); // "<svg...</svg>"
signaturePad.toSVG({includeBackgroundColor: true}); // add background color to svg output
signaturePad.toSVG({includeDataUrl: true}); // add data url added with fromDataUrl to svg output background
// Draws signature image from data URL (mostly uses https://mdn.io/drawImage under-the-hood)
// NOTE: This method does not populate internal data structure that represents drawn signature. Thus, after using #fromDataURL, #toData won't work properly.
signaturePad.fromDataURL("data:image/png;base64,iVBORw0K...");
// Draws signature image from data URL and alters it with the given options
signaturePad.fromDataURL("data:image/png;base64,iVBORw0K...", { ratio: 1, width: 400, height: 200, xOffset: 100, yOffset: 50 });
// Returns signature image as an array of point groups
const data = signaturePad.toData();
// Draws signature image from an array of point groups
signaturePad.fromData(data);
// Draws signature image from an array of point groups, without clearing your existing image (clear defaults to true if not provided)
signaturePad.fromData(data, { clear: false });
// Redraw the canvas
signaturePad.redraw();
// Clears the canvas
signaturePad.clear();
// Returns true if canvas is empty, otherwise returns false
signaturePad.isEmpty();
// Unbinds all event handlers
signaturePad.off();
// Rebinds all event handlers
signaturePad.on();
```
### Options
<dl>
<dt>dotSize</dt>
<dd>(float or function) Radius of a single dot. Also the width of the start of a mark.</dd>
<dt>minWidth</dt>
<dd>(float) Minimum width of a line. Defaults to <code>0.5</code>.</dd>
<dt>maxWidth</dt>
<dd>(float) Maximum width of a line. Defaults to <code>2.5</code>.</dd>
<dt>throttle</dt>
<dd>(integer) Draw the next point at most once per every <code>x</code> milliseconds. Set it to <code>0</code> to turn off throttling. Defaults to <code>16</code>.</dd>
<dt>minDistance</dt>
<dd>(integer) Add the next point only if the previous one is farther than <code>x</code> pixels. Defaults to <code>5</code>.
<dt>backgroundColor</dt>
<dd>(string) Color used to clear the background. Can be any color format accepted by <code>context.fillStyle</code>. Defaults to <code>"rgba(0,0,0,0)"</code> (transparent black). Use a non-transparent color e.g. <code>"rgb(255,255,255)"</code> (opaque white) if you'd like to save signatures as JPEG images.</dd>
<dt>penColor</dt>
<dd>(string) Color used to draw the lines. Can be any color format accepted by <code>context.fillStyle</code>. Defaults to <code>"black"</code>.</dd>
<dt>velocityFilterWeight</dt>
<dd>(float) Weight used to modify new velocity based on the previous velocity. Defaults to <code>0.7</code>.</dd>
<dt>canvasContextOptions</dt>
<dd>(CanvasRenderingContext2DSettings) part of the Canvas API, provides the 2D rendering context for the drawing surface of a <code>canvas</code> element. It is used for drawing shapes, text, images, and other objects (<a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getContextAttributes">MDN</a>).</dd>
</dl>
You can set options during initialization:
```javascript
const signaturePad = new SignaturePad(canvas, {
minWidth: 5,
maxWidth: 10,
penColor: "rgb(66, 133, 244)"
});
```
or during runtime:
```javascript
const signaturePad = new SignaturePad(canvas);
signaturePad.minWidth = 5;
signaturePad.maxWidth = 10;
signaturePad.penColor = "rgb(66, 133, 244)";
```
### Events
<dl>
<dt>beginStroke</dt>
<dd>Triggered before stroke begins.<br>Can be canceled with <code>event.preventDefault()</code></dd>
<dt>endStroke</dt>
<dd>Triggered after stroke ends.</dd>
<dt>beforeUpdateStroke</dt>
<dd>Triggered before stroke update.</dd>
<dt>afterUpdateStroke</dt>
<dd>Triggered after stroke update.</dd>
</dl>
You can add listeners to events with [`.addEventListener`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener):
```javascript
const signaturePad = new SignaturePad(canvas);
signaturePad.addEventListener("beginStroke", () => {
console.log("Signature started");
}, { once: true });
```
### Tips and tricks
#### Handling high DPI screens
To correctly handle canvas on low and high DPI screens one has to take `devicePixelRatio` into account and scale the canvas accordingly. This scaling is also necessary to properly display signatures loaded via `SignaturePad#fromDataURL`. Here's an example how it can be done:
```javascript
function resizeCanvas() {
const ratio = Math.max(window.devicePixelRatio || 1, 1);
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
signaturePad.clear(); // otherwise isEmpty() might return incorrect value
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
```
Instead of `resize` event you can listen to screen orientation change, if you're using this library only on mobile devices. You can also throttle the `resize` event - you can find some examples on [this MDN page](https://developer.mozilla.org/en-US/docs/Web/Events/resize).
#### Handling canvas resize
When you modify width or height of a canvas, it will be automatically cleared by the browser. SignaturePad doesn't know about it by itself, so you can call `signaturePad.redraw()` to reset the drawing, or `signaturePad.clear()` to make sure that `signaturePad.isEmpty()` returns correct value in this case.
This clearing of the canvas by the browser can be annoying, especially on mobile devices e.g. when screen orientation is changed. There are a few workarounds though, e.g. you can [lock screen orientation](https://developer.mozilla.org/en-US/docs/Web/API/Screen/lockOrientation), or read an image from the canvas before resizing it and write the image back after.
#### Handling data URI encoded images on the server side
If you are not familiar with data URI scheme, you can read more about it on [Wikipedia](http://en.wikipedia.org/wiki/Data_URI_scheme).
There are 2 ways you can handle data URI encoded images.
You could simply store it in your database as a string and display it in HTML like this:
```html
<img src="data:image/png;base64,iVBORw0K..." />
```
but this way has many disadvantages - it's not easy to get image dimensions, you can't manipulate it e.g. to create a thumbnail and it also [has some performance issues on mobile devices](https://web.archive.org/web/20160414182912/http://www.mobify.com/blog/data-uris-are-slow-on-mobile).
Thus, more common way is to decode it and store as a file. Here's an example in Ruby:
```ruby
require "base64"
data_uri = "data:image/png;base64,iVBORw0K..."
encoded_image = data_uri.split(",")[1]
decoded_image = Base64.decode64(encoded_image)
File.open("signature.png", "wb") { |f| f.write(decoded_image) }
```
Here's an example in PHP:
```php
$data_uri = "data:image/png;base64,iVBORw0K...";
$encoded_image = explode(",", $data_uri)[1];
$decoded_image = base64_decode($encoded_image);
file_put_contents("signature.png", $decoded_image);
```
Here's an example in C# for ASP.NET:
```csharp
var dataUri = "data:image/png;base64,iVBORw0K...";
var encodedImage = dataUri.Split(',')[1];
var decodedImage = Convert.FromBase64String(encodedImage);
System.IO.File.WriteAllBytes("signature.png", decodedImage);
```
#### Removing empty space around a signature
If you'd like to remove (trim) empty space around a signature, you can do it on the server side or the client side. On the server side you can use e.g. ImageMagic and its `trim` option: `convert -trim input.jpg output.jpg`. If you don't have access to the server, or just want to trim the image before submitting it to the server, you can do it on the client side as well. There are a few examples how to do it, e.g. [here](https://github.com/szimek/signature_pad/issues/49#issue-29108215) or [here](https://github.com/szimek/signature_pad/issues/49#issuecomment-260976909) and there's also a tiny library [trim-canvas](https://github.com/agilgur5/trim-canvas) that provides this functionality.
#### Drawing over an image
Demo: <https://jsfiddle.net/szimek/d6a78gwq/>
## License
Released under the [MIT License](http://www.opensource.org/licenses/MIT).

View file

@ -0,0 +1,787 @@
/*!
* Signature Pad v5.1.3 | https://github.com/szimek/signature_pad
* (c) 2025 Szymon Nowak | Released under the MIT license
*/
// src/point.ts
var Point = class {
x;
y;
pressure;
time;
constructor(x, y, pressure, time) {
if (isNaN(x) || isNaN(y)) {
throw new Error(`Point is invalid: (${x}, ${y})`);
}
this.x = +x;
this.y = +y;
this.pressure = pressure || 0;
this.time = time || Date.now();
}
distanceTo(start) {
return Math.sqrt(
Math.pow(this.x - start.x, 2) + Math.pow(this.y - start.y, 2)
);
}
equals(other) {
return this.x === other.x && this.y === other.y && this.pressure === other.pressure && this.time === other.time;
}
velocityFrom(start) {
return this.time !== start.time ? this.distanceTo(start) / (this.time - start.time) : 0;
}
};
// src/bezier.ts
var Bezier = class _Bezier {
constructor(startPoint, control2, control1, endPoint, startWidth, endWidth) {
this.startPoint = startPoint;
this.control2 = control2;
this.control1 = control1;
this.endPoint = endPoint;
this.startWidth = startWidth;
this.endWidth = endWidth;
}
static fromPoints(points, widths) {
const c2 = this.calculateControlPoints(points[0], points[1], points[2]).c2;
const c3 = this.calculateControlPoints(points[1], points[2], points[3]).c1;
return new _Bezier(points[1], c2, c3, points[2], widths.start, widths.end);
}
static calculateControlPoints(s1, s2, s3) {
const dx1 = s1.x - s2.x;
const dy1 = s1.y - s2.y;
const dx2 = s2.x - s3.x;
const dy2 = s2.y - s3.y;
const m1 = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 };
const m2 = { x: (s2.x + s3.x) / 2, y: (s2.y + s3.y) / 2 };
const l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const dxm = m1.x - m2.x;
const dym = m1.y - m2.y;
const k = l1 + l2 == 0 ? 0 : l2 / (l1 + l2);
const cm = { x: m2.x + dxm * k, y: m2.y + dym * k };
const tx = s2.x - cm.x;
const ty = s2.y - cm.y;
return {
c1: new Point(m1.x + tx, m1.y + ty),
c2: new Point(m2.x + tx, m2.y + ty)
};
}
// Returns approximated length. Code taken from https://www.lemoda.net/maths/bezier-length/index.html.
length() {
const steps = 10;
let length = 0;
let px;
let py;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const cx = this.point(
t,
this.startPoint.x,
this.control1.x,
this.control2.x,
this.endPoint.x
);
const cy = this.point(
t,
this.startPoint.y,
this.control1.y,
this.control2.y,
this.endPoint.y
);
if (i > 0) {
const xdiff = cx - px;
const ydiff = cy - py;
length += Math.sqrt(xdiff * xdiff + ydiff * ydiff);
}
px = cx;
py = cy;
}
return length;
}
// Calculate parametric value of x or y given t and the four point coordinates of a cubic bezier curve.
point(t, start, c1, c2, end) {
return start * (1 - t) * (1 - t) * (1 - t) + 3 * c1 * (1 - t) * (1 - t) * t + 3 * c2 * (1 - t) * t * t + end * t * t * t;
}
};
// src/signature_event_target.ts
var SignatureEventTarget = class {
/* tslint:disable: variable-name */
_et;
/* tslint:enable: variable-name */
constructor() {
try {
this._et = new EventTarget();
} catch {
this._et = document;
}
}
addEventListener(type, listener, options) {
this._et.addEventListener(type, listener, options);
}
dispatchEvent(event) {
return this._et.dispatchEvent(event);
}
removeEventListener(type, callback, options) {
this._et.removeEventListener(type, callback, options);
}
};
// src/throttle.ts
function throttle(fn, wait = 250) {
let previous = 0;
let timeout = null;
let result;
let storedContext;
let storedArgs;
const later = () => {
previous = Date.now();
timeout = null;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
};
return function wrapper(...args) {
const now = Date.now();
const remaining = wait - (now - previous);
storedContext = this;
storedArgs = args;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
} else if (!timeout) {
timeout = window.setTimeout(later, remaining);
}
return result;
};
}
// src/signature_pad.ts
var SignaturePad = class _SignaturePad extends SignatureEventTarget {
/* tslint:enable: variable-name */
constructor(canvas, options = {}) {
super();
this.canvas = canvas;
this.velocityFilterWeight = options.velocityFilterWeight || 0.7;
this.minWidth = options.minWidth || 0.5;
this.maxWidth = options.maxWidth || 2.5;
this.throttle = options.throttle ?? 16;
this.minDistance = options.minDistance ?? 5;
this.dotSize = options.dotSize || 0;
this.penColor = options.penColor || "black";
this.backgroundColor = options.backgroundColor || "rgba(0,0,0,0)";
this.compositeOperation = options.compositeOperation || "source-over";
this.canvasContextOptions = options.canvasContextOptions ?? {};
this._strokeMoveUpdate = this.throttle ? throttle(_SignaturePad.prototype._strokeUpdate, this.throttle) : _SignaturePad.prototype._strokeUpdate;
this._handleMouseDown = this._handleMouseDown.bind(this);
this._handleMouseMove = this._handleMouseMove.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this);
this._handleTouchStart = this._handleTouchStart.bind(this);
this._handleTouchMove = this._handleTouchMove.bind(this);
this._handleTouchEnd = this._handleTouchEnd.bind(this);
this._handlePointerDown = this._handlePointerDown.bind(this);
this._handlePointerMove = this._handlePointerMove.bind(this);
this._handlePointerUp = this._handlePointerUp.bind(this);
this._handlePointerCancel = this._handlePointerCancel.bind(this);
this._handleTouchCancel = this._handleTouchCancel.bind(this);
this._ctx = canvas.getContext(
"2d",
this.canvasContextOptions
);
this.clear();
this.on();
}
// Public stuff
dotSize;
minWidth;
maxWidth;
penColor;
minDistance;
velocityFilterWeight;
compositeOperation;
backgroundColor;
throttle;
canvasContextOptions;
// Private stuff
/* tslint:disable: variable-name */
_ctx;
_drawingStroke = false;
_isEmpty = true;
_dataUrl;
_dataUrlOptions;
_lastPoints = [];
// Stores up to 4 most recent points; used to generate a new curve
_data = [];
// Stores all points in groups (one group per line or dot)
_lastVelocity = 0;
_lastWidth = 0;
_strokeMoveUpdate;
_strokePointerId;
clear() {
const { _ctx: ctx, canvas } = this;
ctx.fillStyle = this.backgroundColor;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, canvas.width, canvas.height);
this._data = [];
this._reset(this._getPointGroupOptions());
this._isEmpty = true;
this._dataUrl = void 0;
this._dataUrlOptions = void 0;
this._strokePointerId = void 0;
}
redraw() {
const data = this._data;
const dataUrl = this._dataUrl;
const dataUrlOptions = this._dataUrlOptions;
this.clear();
if (dataUrl) {
this.fromDataURL(dataUrl, dataUrlOptions);
}
this.fromData(data, { clear: false });
}
fromDataURL(dataUrl, options = {}) {
return new Promise((resolve, reject) => {
const image = new Image();
const ratio = options.ratio || window.devicePixelRatio || 1;
const width = options.width || this.canvas.width / ratio;
const height = options.height || this.canvas.height / ratio;
const xOffset = options.xOffset || 0;
const yOffset = options.yOffset || 0;
this._reset(this._getPointGroupOptions());
image.onload = () => {
this._ctx.drawImage(image, xOffset, yOffset, width, height);
resolve();
};
image.onerror = (error) => {
reject(error);
};
image.crossOrigin = "anonymous";
image.src = dataUrl;
this._isEmpty = false;
this._dataUrl = dataUrl;
this._dataUrlOptions = { ...options };
});
}
toDataURL(type = "image/png", encoderOptions) {
switch (type) {
case "image/svg+xml":
if (typeof encoderOptions !== "object") {
encoderOptions = void 0;
}
return `data:image/svg+xml;base64,${btoa(
this.toSVG(encoderOptions)
)}`;
default:
if (typeof encoderOptions !== "number") {
encoderOptions = void 0;
}
return this.canvas.toDataURL(type, encoderOptions);
}
}
on() {
this.canvas.style.touchAction = "none";
this.canvas.style.msTouchAction = "none";
this.canvas.style.userSelect = "none";
this.canvas.style.webkitUserSelect = "none";
const isIOS = /Macintosh/.test(navigator.userAgent) && "ontouchstart" in document;
if (window.PointerEvent && !isIOS) {
this._handlePointerEvents();
} else {
this._handleMouseEvents();
if ("ontouchstart" in window) {
this._handleTouchEvents();
}
}
}
off() {
this.canvas.style.touchAction = "auto";
this.canvas.style.msTouchAction = "auto";
this.canvas.style.userSelect = "auto";
this.canvas.style.webkitUserSelect = "auto";
this.canvas.removeEventListener("pointerdown", this._handlePointerDown);
this.canvas.removeEventListener("mousedown", this._handleMouseDown);
this.canvas.removeEventListener("touchstart", this._handleTouchStart);
this._removeMoveUpEventListeners();
}
_getListenerFunctions() {
const canvasWindow = window.document === this.canvas.ownerDocument ? window : this.canvas.ownerDocument.defaultView ?? this.canvas.ownerDocument;
return {
addEventListener: canvasWindow.addEventListener.bind(
canvasWindow
),
removeEventListener: canvasWindow.removeEventListener.bind(
canvasWindow
)
};
}
_removeMoveUpEventListeners() {
const { removeEventListener } = this._getListenerFunctions();
removeEventListener("pointermove", this._handlePointerMove);
removeEventListener("pointerup", this._handlePointerUp);
removeEventListener("pointercancel", this._handlePointerCancel);
removeEventListener("mousemove", this._handleMouseMove);
removeEventListener("mouseup", this._handleMouseUp);
removeEventListener("touchmove", this._handleTouchMove);
removeEventListener("touchend", this._handleTouchEnd);
removeEventListener("touchcancel", this._handleTouchCancel);
}
isEmpty() {
return this._isEmpty;
}
fromData(pointGroups, { clear = true } = {}) {
if (clear) {
this.clear();
}
this._fromData(
pointGroups,
this._drawCurve.bind(this),
this._drawDot.bind(this)
);
this._data = this._data.concat(pointGroups);
}
toData() {
return this._data;
}
_isLeftButtonPressed(event, only) {
if (only) {
return event.buttons === 1;
}
return (event.buttons & 1) === 1;
}
_pointerEventToSignatureEvent(event) {
return {
event,
type: event.type,
x: event.clientX,
y: event.clientY,
pressure: "pressure" in event ? event.pressure : 0
};
}
_touchEventToSignatureEvent(event) {
const touch = event.changedTouches[0];
return {
event,
type: event.type,
x: touch.clientX,
y: touch.clientY,
pressure: touch.force
};
}
// Event handlers
_handleMouseDown(event) {
if (!this._isLeftButtonPressed(event, true) || this._drawingStroke) {
return;
}
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
_handleMouseMove(event) {
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
_handleMouseUp(event) {
if (this._isLeftButtonPressed(event)) {
return;
}
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
_handleTouchStart(event) {
if (event.targetTouches.length !== 1 || this._drawingStroke) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
this._strokeBegin(this._touchEventToSignatureEvent(event));
}
_handleTouchMove(event) {
if (event.targetTouches.length !== 1) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
if (!this._drawingStroke) {
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._touchEventToSignatureEvent(event));
}
_handleTouchEnd(event) {
if (event.targetTouches.length !== 0) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event));
}
_handlePointerCancel(event) {
if (!this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
}
_handleTouchCancel(event) {
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
}
_getPointerId(event) {
return event.persistentDeviceId || event.pointerId;
}
_allowPointerId(event, allowUndefined = false) {
if (typeof this._strokePointerId === "undefined") {
return allowUndefined;
}
return this._getPointerId(event) === this._strokePointerId;
}
_handlePointerDown(event) {
if (this._drawingStroke || !this._isLeftButtonPressed(event) || !this._allowPointerId(event, true)) {
return;
}
this._strokePointerId = this._getPointerId(event);
event.preventDefault();
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
_handlePointerMove(event) {
if (!this._allowPointerId(event)) {
return;
}
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
event.preventDefault();
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
_handlePointerUp(event) {
if (this._isLeftButtonPressed(event) || !this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
_getPointGroupOptions(group) {
return {
penColor: group && "penColor" in group ? group.penColor : this.penColor,
dotSize: group && "dotSize" in group ? group.dotSize : this.dotSize,
minWidth: group && "minWidth" in group ? group.minWidth : this.minWidth,
maxWidth: group && "maxWidth" in group ? group.maxWidth : this.maxWidth,
velocityFilterWeight: group && "velocityFilterWeight" in group ? group.velocityFilterWeight : this.velocityFilterWeight,
compositeOperation: group && "compositeOperation" in group ? group.compositeOperation : this.compositeOperation
};
}
// Private methods
_strokeBegin(event) {
const cancelled = !this.dispatchEvent(
new CustomEvent("beginStroke", { detail: event, cancelable: true })
);
if (cancelled) {
return;
}
const { addEventListener } = this._getListenerFunctions();
switch (event.event.type) {
case "mousedown":
addEventListener("mousemove", this._handleMouseMove, {
passive: false
});
addEventListener("mouseup", this._handleMouseUp, { passive: false });
break;
case "touchstart":
addEventListener("touchmove", this._handleTouchMove, {
passive: false
});
addEventListener("touchend", this._handleTouchEnd, { passive: false });
addEventListener("touchcancel", this._handleTouchCancel, { passive: false });
break;
case "pointerdown":
addEventListener("pointermove", this._handlePointerMove, {
passive: false
});
addEventListener("pointerup", this._handlePointerUp, {
passive: false
});
addEventListener("pointercancel", this._handlePointerCancel, {
passive: false
});
break;
default:
}
this._drawingStroke = true;
const pointGroupOptions = this._getPointGroupOptions();
const newPointGroup = {
...pointGroupOptions,
points: []
};
this._data.push(newPointGroup);
this._reset(pointGroupOptions);
this._strokeUpdate(event);
}
_strokeUpdate(event) {
if (!this._drawingStroke) {
return;
}
if (this._data.length === 0) {
this._strokeBegin(event);
return;
}
this.dispatchEvent(
new CustomEvent("beforeUpdateStroke", { detail: event })
);
const point = this._createPoint(event.x, event.y, event.pressure);
const lastPointGroup = this._data[this._data.length - 1];
const lastPoints = lastPointGroup.points;
const lastPoint = lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
const isLastPointTooClose = lastPoint ? point.distanceTo(lastPoint) <= this.minDistance : false;
const pointGroupOptions = this._getPointGroupOptions(lastPointGroup);
if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
const curve = this._addPoint(point, pointGroupOptions);
if (!lastPoint) {
this._drawDot(point, pointGroupOptions);
} else if (curve) {
this._drawCurve(curve, pointGroupOptions);
}
lastPoints.push({
time: point.time,
x: point.x,
y: point.y,
pressure: point.pressure
});
}
this.dispatchEvent(new CustomEvent("afterUpdateStroke", { detail: event }));
}
_strokeEnd(event, shouldUpdate = true) {
this._removeMoveUpEventListeners();
if (!this._drawingStroke) {
return;
}
if (shouldUpdate) {
this._strokeUpdate(event);
}
this._drawingStroke = false;
this._strokePointerId = void 0;
this.dispatchEvent(new CustomEvent("endStroke", { detail: event }));
}
_handlePointerEvents() {
this._drawingStroke = false;
this.canvas.addEventListener("pointerdown", this._handlePointerDown, {
passive: false
});
}
_handleMouseEvents() {
this._drawingStroke = false;
this.canvas.addEventListener("mousedown", this._handleMouseDown, {
passive: false
});
}
_handleTouchEvents() {
this.canvas.addEventListener("touchstart", this._handleTouchStart, {
passive: false
});
}
// Called when a new line is started
_reset(options) {
this._lastPoints = [];
this._lastVelocity = 0;
this._lastWidth = (options.minWidth + options.maxWidth) / 2;
this._ctx.fillStyle = options.penColor;
this._ctx.globalCompositeOperation = options.compositeOperation;
}
_createPoint(x, y, pressure) {
const rect = this.canvas.getBoundingClientRect();
return new Point(
x - rect.left,
y - rect.top,
pressure,
(/* @__PURE__ */ new Date()).getTime()
);
}
// Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
_addPoint(point, options) {
const { _lastPoints } = this;
_lastPoints.push(point);
if (_lastPoints.length > 2) {
if (_lastPoints.length === 3) {
_lastPoints.unshift(_lastPoints[0]);
}
const widths = this._calculateCurveWidths(
_lastPoints[1],
_lastPoints[2],
options
);
const curve = Bezier.fromPoints(_lastPoints, widths);
_lastPoints.shift();
return curve;
}
return null;
}
_calculateCurveWidths(startPoint, endPoint, options) {
const velocity = options.velocityFilterWeight * endPoint.velocityFrom(startPoint) + (1 - options.velocityFilterWeight) * this._lastVelocity;
const newWidth = this._strokeWidth(velocity, options);
const widths = {
end: newWidth,
start: this._lastWidth
};
this._lastVelocity = velocity;
this._lastWidth = newWidth;
return widths;
}
_strokeWidth(velocity, options) {
return Math.max(options.maxWidth / (velocity + 1), options.minWidth);
}
_drawCurveSegment(x, y, width) {
const ctx = this._ctx;
ctx.moveTo(x, y);
ctx.arc(x, y, width, 0, 2 * Math.PI, false);
this._isEmpty = false;
}
_drawCurve(curve, options) {
const ctx = this._ctx;
const widthDelta = curve.endWidth - curve.startWidth;
const drawSteps = Math.ceil(curve.length()) * 2;
ctx.beginPath();
ctx.fillStyle = options.penColor;
for (let i = 0; i < drawSteps; i += 1) {
const t = i / drawSteps;
const tt = t * t;
const ttt = tt * t;
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
let x = uuu * curve.startPoint.x;
x += 3 * uu * t * curve.control1.x;
x += 3 * u * tt * curve.control2.x;
x += ttt * curve.endPoint.x;
let y = uuu * curve.startPoint.y;
y += 3 * uu * t * curve.control1.y;
y += 3 * u * tt * curve.control2.y;
y += ttt * curve.endPoint.y;
const width = Math.min(
curve.startWidth + ttt * widthDelta,
options.maxWidth
);
this._drawCurveSegment(x, y, width);
}
ctx.closePath();
ctx.fill();
}
_drawDot(point, options) {
const ctx = this._ctx;
const width = options.dotSize > 0 ? options.dotSize : (options.minWidth + options.maxWidth) / 2;
ctx.beginPath();
this._drawCurveSegment(point.x, point.y, width);
ctx.closePath();
ctx.fillStyle = options.penColor;
ctx.fill();
}
_fromData(pointGroups, drawCurve, drawDot) {
for (const group of pointGroups) {
const { points } = group;
const pointGroupOptions = this._getPointGroupOptions(group);
if (points.length > 1) {
for (let j = 0; j < points.length; j += 1) {
const basicPoint = points[j];
const point = new Point(
basicPoint.x,
basicPoint.y,
basicPoint.pressure,
basicPoint.time
);
if (j === 0) {
this._reset(pointGroupOptions);
}
const curve = this._addPoint(point, pointGroupOptions);
if (curve) {
drawCurve(curve, pointGroupOptions);
}
}
} else {
this._reset(pointGroupOptions);
drawDot(points[0], pointGroupOptions);
}
}
}
toSVG({ includeBackgroundColor = false, includeDataUrl = false } = {}) {
const pointGroups = this._data;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const minX = 0;
const minY = 0;
const maxX = this.canvas.width / ratio;
const maxY = this.canvas.height / ratio;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
svg.setAttribute("viewBox", `${minX} ${minY} ${maxX} ${maxY}`);
svg.setAttribute("width", maxX.toString());
svg.setAttribute("height", maxY.toString());
if (includeBackgroundColor && this.backgroundColor) {
const rect = document.createElement("rect");
rect.setAttribute("width", "100%");
rect.setAttribute("height", "100%");
rect.setAttribute("fill", this.backgroundColor);
svg.appendChild(rect);
}
if (includeDataUrl && this._dataUrl) {
const ratio2 = this._dataUrlOptions?.ratio || window.devicePixelRatio || 1;
const width = this._dataUrlOptions?.width || this.canvas.width / ratio2;
const height = this._dataUrlOptions?.height || this.canvas.height / ratio2;
const xOffset = this._dataUrlOptions?.xOffset || 0;
const yOffset = this._dataUrlOptions?.yOffset || 0;
const image = document.createElement("image");
image.setAttribute("x", xOffset.toString());
image.setAttribute("y", yOffset.toString());
image.setAttribute("width", width.toString());
image.setAttribute("height", height.toString());
image.setAttribute("preserveAspectRatio", "none");
image.setAttribute("href", this._dataUrl);
svg.appendChild(image);
}
this._fromData(
pointGroups,
(curve, { penColor }) => {
const path = document.createElement("path");
if (!isNaN(curve.control1.x) && !isNaN(curve.control1.y) && !isNaN(curve.control2.x) && !isNaN(curve.control2.y)) {
const attr = `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
3
)} C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
path.setAttribute("d", attr);
path.setAttribute("stroke-width", (curve.endWidth * 2.25).toFixed(3));
path.setAttribute("stroke", penColor);
path.setAttribute("fill", "none");
path.setAttribute("stroke-linecap", "round");
svg.appendChild(path);
}
},
(point, { penColor, dotSize, minWidth, maxWidth }) => {
const circle = document.createElement("circle");
const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
circle.setAttribute("r", size.toString());
circle.setAttribute("cx", point.x.toString());
circle.setAttribute("cy", point.y.toString());
circle.setAttribute("fill", penColor);
svg.appendChild(circle);
}
);
return svg.outerHTML;
}
};
export {
SignaturePad as default
};
//# sourceMappingURL=signature_pad.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,848 @@
/*!
* Signature Pad v5.1.3 | https://github.com/szimek/signature_pad
* (c) 2025 Szymon Nowak | Released under the MIT license
*/
(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("SignaturePad",f)}else {g["SignaturePad"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/point.ts
var Point;
var init_point = __esm({
"src/point.ts"() {
"use strict";
Point = class {
x;
y;
pressure;
time;
constructor(x, y, pressure, time) {
if (isNaN(x) || isNaN(y)) {
throw new Error(`Point is invalid: (${x}, ${y})`);
}
this.x = +x;
this.y = +y;
this.pressure = pressure || 0;
this.time = time || Date.now();
}
distanceTo(start) {
return Math.sqrt(
Math.pow(this.x - start.x, 2) + Math.pow(this.y - start.y, 2)
);
}
equals(other) {
return this.x === other.x && this.y === other.y && this.pressure === other.pressure && this.time === other.time;
}
velocityFrom(start) {
return this.time !== start.time ? this.distanceTo(start) / (this.time - start.time) : 0;
}
};
}
});
// src/bezier.ts
var Bezier;
var init_bezier = __esm({
"src/bezier.ts"() {
"use strict";
init_point();
Bezier = class _Bezier {
constructor(startPoint, control2, control1, endPoint, startWidth, endWidth) {
this.startPoint = startPoint;
this.control2 = control2;
this.control1 = control1;
this.endPoint = endPoint;
this.startWidth = startWidth;
this.endWidth = endWidth;
}
static fromPoints(points, widths) {
const c2 = this.calculateControlPoints(points[0], points[1], points[2]).c2;
const c3 = this.calculateControlPoints(points[1], points[2], points[3]).c1;
return new _Bezier(points[1], c2, c3, points[2], widths.start, widths.end);
}
static calculateControlPoints(s1, s2, s3) {
const dx1 = s1.x - s2.x;
const dy1 = s1.y - s2.y;
const dx2 = s2.x - s3.x;
const dy2 = s2.y - s3.y;
const m1 = { x: (s1.x + s2.x) / 2, y: (s1.y + s2.y) / 2 };
const m2 = { x: (s2.x + s3.x) / 2, y: (s2.y + s3.y) / 2 };
const l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const dxm = m1.x - m2.x;
const dym = m1.y - m2.y;
const k = l1 + l2 == 0 ? 0 : l2 / (l1 + l2);
const cm = { x: m2.x + dxm * k, y: m2.y + dym * k };
const tx = s2.x - cm.x;
const ty = s2.y - cm.y;
return {
c1: new Point(m1.x + tx, m1.y + ty),
c2: new Point(m2.x + tx, m2.y + ty)
};
}
// Returns approximated length. Code taken from https://www.lemoda.net/maths/bezier-length/index.html.
length() {
const steps = 10;
let length = 0;
let px;
let py;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const cx = this.point(
t,
this.startPoint.x,
this.control1.x,
this.control2.x,
this.endPoint.x
);
const cy = this.point(
t,
this.startPoint.y,
this.control1.y,
this.control2.y,
this.endPoint.y
);
if (i > 0) {
const xdiff = cx - px;
const ydiff = cy - py;
length += Math.sqrt(xdiff * xdiff + ydiff * ydiff);
}
px = cx;
py = cy;
}
return length;
}
// Calculate parametric value of x or y given t and the four point coordinates of a cubic bezier curve.
point(t, start, c1, c2, end) {
return start * (1 - t) * (1 - t) * (1 - t) + 3 * c1 * (1 - t) * (1 - t) * t + 3 * c2 * (1 - t) * t * t + end * t * t * t;
}
};
}
});
// src/signature_event_target.ts
var SignatureEventTarget;
var init_signature_event_target = __esm({
"src/signature_event_target.ts"() {
"use strict";
SignatureEventTarget = class {
/* tslint:disable: variable-name */
_et;
/* tslint:enable: variable-name */
constructor() {
try {
this._et = new EventTarget();
} catch {
this._et = document;
}
}
addEventListener(type, listener, options) {
this._et.addEventListener(type, listener, options);
}
dispatchEvent(event) {
return this._et.dispatchEvent(event);
}
removeEventListener(type, callback, options) {
this._et.removeEventListener(type, callback, options);
}
};
}
});
// src/throttle.ts
function throttle(fn, wait = 250) {
let previous = 0;
let timeout = null;
let result;
let storedContext;
let storedArgs;
const later = () => {
previous = Date.now();
timeout = null;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
};
return function wrapper(...args) {
const now = Date.now();
const remaining = wait - (now - previous);
storedContext = this;
storedArgs = args;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
} else if (!timeout) {
timeout = window.setTimeout(later, remaining);
}
return result;
};
}
var init_throttle = __esm({
"src/throttle.ts"() {
"use strict";
}
});
// src/signature_pad.ts
var signature_pad_exports = {};
__export(signature_pad_exports, {
default: () => SignaturePad
});
var SignaturePad;
var init_signature_pad = __esm({
"src/signature_pad.ts"() {
"use strict";
init_bezier();
init_point();
init_signature_event_target();
init_throttle();
init_point();
SignaturePad = class _SignaturePad extends SignatureEventTarget {
/* tslint:enable: variable-name */
constructor(canvas, options = {}) {
super();
this.canvas = canvas;
this.velocityFilterWeight = options.velocityFilterWeight || 0.7;
this.minWidth = options.minWidth || 0.5;
this.maxWidth = options.maxWidth || 2.5;
this.throttle = options.throttle ?? 16;
this.minDistance = options.minDistance ?? 5;
this.dotSize = options.dotSize || 0;
this.penColor = options.penColor || "black";
this.backgroundColor = options.backgroundColor || "rgba(0,0,0,0)";
this.compositeOperation = options.compositeOperation || "source-over";
this.canvasContextOptions = options.canvasContextOptions ?? {};
this._strokeMoveUpdate = this.throttle ? throttle(_SignaturePad.prototype._strokeUpdate, this.throttle) : _SignaturePad.prototype._strokeUpdate;
this._handleMouseDown = this._handleMouseDown.bind(this);
this._handleMouseMove = this._handleMouseMove.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this);
this._handleTouchStart = this._handleTouchStart.bind(this);
this._handleTouchMove = this._handleTouchMove.bind(this);
this._handleTouchEnd = this._handleTouchEnd.bind(this);
this._handlePointerDown = this._handlePointerDown.bind(this);
this._handlePointerMove = this._handlePointerMove.bind(this);
this._handlePointerUp = this._handlePointerUp.bind(this);
this._handlePointerCancel = this._handlePointerCancel.bind(this);
this._handleTouchCancel = this._handleTouchCancel.bind(this);
this._ctx = canvas.getContext(
"2d",
this.canvasContextOptions
);
this.clear();
this.on();
}
// Public stuff
dotSize;
minWidth;
maxWidth;
penColor;
minDistance;
velocityFilterWeight;
compositeOperation;
backgroundColor;
throttle;
canvasContextOptions;
// Private stuff
/* tslint:disable: variable-name */
_ctx;
_drawingStroke = false;
_isEmpty = true;
_dataUrl;
_dataUrlOptions;
_lastPoints = [];
// Stores up to 4 most recent points; used to generate a new curve
_data = [];
// Stores all points in groups (one group per line or dot)
_lastVelocity = 0;
_lastWidth = 0;
_strokeMoveUpdate;
_strokePointerId;
clear() {
const { _ctx: ctx, canvas } = this;
ctx.fillStyle = this.backgroundColor;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, canvas.width, canvas.height);
this._data = [];
this._reset(this._getPointGroupOptions());
this._isEmpty = true;
this._dataUrl = void 0;
this._dataUrlOptions = void 0;
this._strokePointerId = void 0;
}
redraw() {
const data = this._data;
const dataUrl = this._dataUrl;
const dataUrlOptions = this._dataUrlOptions;
this.clear();
if (dataUrl) {
this.fromDataURL(dataUrl, dataUrlOptions);
}
this.fromData(data, { clear: false });
}
fromDataURL(dataUrl, options = {}) {
return new Promise((resolve, reject) => {
const image = new Image();
const ratio = options.ratio || window.devicePixelRatio || 1;
const width = options.width || this.canvas.width / ratio;
const height = options.height || this.canvas.height / ratio;
const xOffset = options.xOffset || 0;
const yOffset = options.yOffset || 0;
this._reset(this._getPointGroupOptions());
image.onload = () => {
this._ctx.drawImage(image, xOffset, yOffset, width, height);
resolve();
};
image.onerror = (error) => {
reject(error);
};
image.crossOrigin = "anonymous";
image.src = dataUrl;
this._isEmpty = false;
this._dataUrl = dataUrl;
this._dataUrlOptions = { ...options };
});
}
toDataURL(type = "image/png", encoderOptions) {
switch (type) {
case "image/svg+xml":
if (typeof encoderOptions !== "object") {
encoderOptions = void 0;
}
return `data:image/svg+xml;base64,${btoa(
this.toSVG(encoderOptions)
)}`;
default:
if (typeof encoderOptions !== "number") {
encoderOptions = void 0;
}
return this.canvas.toDataURL(type, encoderOptions);
}
}
on() {
this.canvas.style.touchAction = "none";
this.canvas.style.msTouchAction = "none";
this.canvas.style.userSelect = "none";
this.canvas.style.webkitUserSelect = "none";
const isIOS = /Macintosh/.test(navigator.userAgent) && "ontouchstart" in document;
if (window.PointerEvent && !isIOS) {
this._handlePointerEvents();
} else {
this._handleMouseEvents();
if ("ontouchstart" in window) {
this._handleTouchEvents();
}
}
}
off() {
this.canvas.style.touchAction = "auto";
this.canvas.style.msTouchAction = "auto";
this.canvas.style.userSelect = "auto";
this.canvas.style.webkitUserSelect = "auto";
this.canvas.removeEventListener("pointerdown", this._handlePointerDown);
this.canvas.removeEventListener("mousedown", this._handleMouseDown);
this.canvas.removeEventListener("touchstart", this._handleTouchStart);
this._removeMoveUpEventListeners();
}
_getListenerFunctions() {
const canvasWindow = window.document === this.canvas.ownerDocument ? window : this.canvas.ownerDocument.defaultView ?? this.canvas.ownerDocument;
return {
addEventListener: canvasWindow.addEventListener.bind(
canvasWindow
),
removeEventListener: canvasWindow.removeEventListener.bind(
canvasWindow
)
};
}
_removeMoveUpEventListeners() {
const { removeEventListener } = this._getListenerFunctions();
removeEventListener("pointermove", this._handlePointerMove);
removeEventListener("pointerup", this._handlePointerUp);
removeEventListener("pointercancel", this._handlePointerCancel);
removeEventListener("mousemove", this._handleMouseMove);
removeEventListener("mouseup", this._handleMouseUp);
removeEventListener("touchmove", this._handleTouchMove);
removeEventListener("touchend", this._handleTouchEnd);
removeEventListener("touchcancel", this._handleTouchCancel);
}
isEmpty() {
return this._isEmpty;
}
fromData(pointGroups, { clear = true } = {}) {
if (clear) {
this.clear();
}
this._fromData(
pointGroups,
this._drawCurve.bind(this),
this._drawDot.bind(this)
);
this._data = this._data.concat(pointGroups);
}
toData() {
return this._data;
}
_isLeftButtonPressed(event, only) {
if (only) {
return event.buttons === 1;
}
return (event.buttons & 1) === 1;
}
_pointerEventToSignatureEvent(event) {
return {
event,
type: event.type,
x: event.clientX,
y: event.clientY,
pressure: "pressure" in event ? event.pressure : 0
};
}
_touchEventToSignatureEvent(event) {
const touch = event.changedTouches[0];
return {
event,
type: event.type,
x: touch.clientX,
y: touch.clientY,
pressure: touch.force
};
}
// Event handlers
_handleMouseDown(event) {
if (!this._isLeftButtonPressed(event, true) || this._drawingStroke) {
return;
}
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
_handleMouseMove(event) {
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
_handleMouseUp(event) {
if (this._isLeftButtonPressed(event)) {
return;
}
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
_handleTouchStart(event) {
if (event.targetTouches.length !== 1 || this._drawingStroke) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
this._strokeBegin(this._touchEventToSignatureEvent(event));
}
_handleTouchMove(event) {
if (event.targetTouches.length !== 1) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
if (!this._drawingStroke) {
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._touchEventToSignatureEvent(event));
}
_handleTouchEnd(event) {
if (event.targetTouches.length !== 0) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event));
}
_handlePointerCancel(event) {
if (!this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
}
_handleTouchCancel(event) {
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
}
_getPointerId(event) {
return event.persistentDeviceId || event.pointerId;
}
_allowPointerId(event, allowUndefined = false) {
if (typeof this._strokePointerId === "undefined") {
return allowUndefined;
}
return this._getPointerId(event) === this._strokePointerId;
}
_handlePointerDown(event) {
if (this._drawingStroke || !this._isLeftButtonPressed(event) || !this._allowPointerId(event, true)) {
return;
}
this._strokePointerId = this._getPointerId(event);
event.preventDefault();
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
_handlePointerMove(event) {
if (!this._allowPointerId(event)) {
return;
}
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
event.preventDefault();
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
_handlePointerUp(event) {
if (this._isLeftButtonPressed(event) || !this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
_getPointGroupOptions(group) {
return {
penColor: group && "penColor" in group ? group.penColor : this.penColor,
dotSize: group && "dotSize" in group ? group.dotSize : this.dotSize,
minWidth: group && "minWidth" in group ? group.minWidth : this.minWidth,
maxWidth: group && "maxWidth" in group ? group.maxWidth : this.maxWidth,
velocityFilterWeight: group && "velocityFilterWeight" in group ? group.velocityFilterWeight : this.velocityFilterWeight,
compositeOperation: group && "compositeOperation" in group ? group.compositeOperation : this.compositeOperation
};
}
// Private methods
_strokeBegin(event) {
const cancelled = !this.dispatchEvent(
new CustomEvent("beginStroke", { detail: event, cancelable: true })
);
if (cancelled) {
return;
}
const { addEventListener } = this._getListenerFunctions();
switch (event.event.type) {
case "mousedown":
addEventListener("mousemove", this._handleMouseMove, {
passive: false
});
addEventListener("mouseup", this._handleMouseUp, { passive: false });
break;
case "touchstart":
addEventListener("touchmove", this._handleTouchMove, {
passive: false
});
addEventListener("touchend", this._handleTouchEnd, { passive: false });
addEventListener("touchcancel", this._handleTouchCancel, { passive: false });
break;
case "pointerdown":
addEventListener("pointermove", this._handlePointerMove, {
passive: false
});
addEventListener("pointerup", this._handlePointerUp, {
passive: false
});
addEventListener("pointercancel", this._handlePointerCancel, {
passive: false
});
break;
default:
}
this._drawingStroke = true;
const pointGroupOptions = this._getPointGroupOptions();
const newPointGroup = {
...pointGroupOptions,
points: []
};
this._data.push(newPointGroup);
this._reset(pointGroupOptions);
this._strokeUpdate(event);
}
_strokeUpdate(event) {
if (!this._drawingStroke) {
return;
}
if (this._data.length === 0) {
this._strokeBegin(event);
return;
}
this.dispatchEvent(
new CustomEvent("beforeUpdateStroke", { detail: event })
);
const point = this._createPoint(event.x, event.y, event.pressure);
const lastPointGroup = this._data[this._data.length - 1];
const lastPoints = lastPointGroup.points;
const lastPoint = lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
const isLastPointTooClose = lastPoint ? point.distanceTo(lastPoint) <= this.minDistance : false;
const pointGroupOptions = this._getPointGroupOptions(lastPointGroup);
if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
const curve = this._addPoint(point, pointGroupOptions);
if (!lastPoint) {
this._drawDot(point, pointGroupOptions);
} else if (curve) {
this._drawCurve(curve, pointGroupOptions);
}
lastPoints.push({
time: point.time,
x: point.x,
y: point.y,
pressure: point.pressure
});
}
this.dispatchEvent(new CustomEvent("afterUpdateStroke", { detail: event }));
}
_strokeEnd(event, shouldUpdate = true) {
this._removeMoveUpEventListeners();
if (!this._drawingStroke) {
return;
}
if (shouldUpdate) {
this._strokeUpdate(event);
}
this._drawingStroke = false;
this._strokePointerId = void 0;
this.dispatchEvent(new CustomEvent("endStroke", { detail: event }));
}
_handlePointerEvents() {
this._drawingStroke = false;
this.canvas.addEventListener("pointerdown", this._handlePointerDown, {
passive: false
});
}
_handleMouseEvents() {
this._drawingStroke = false;
this.canvas.addEventListener("mousedown", this._handleMouseDown, {
passive: false
});
}
_handleTouchEvents() {
this.canvas.addEventListener("touchstart", this._handleTouchStart, {
passive: false
});
}
// Called when a new line is started
_reset(options) {
this._lastPoints = [];
this._lastVelocity = 0;
this._lastWidth = (options.minWidth + options.maxWidth) / 2;
this._ctx.fillStyle = options.penColor;
this._ctx.globalCompositeOperation = options.compositeOperation;
}
_createPoint(x, y, pressure) {
const rect = this.canvas.getBoundingClientRect();
return new Point(
x - rect.left,
y - rect.top,
pressure,
(/* @__PURE__ */ new Date()).getTime()
);
}
// Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
_addPoint(point, options) {
const { _lastPoints } = this;
_lastPoints.push(point);
if (_lastPoints.length > 2) {
if (_lastPoints.length === 3) {
_lastPoints.unshift(_lastPoints[0]);
}
const widths = this._calculateCurveWidths(
_lastPoints[1],
_lastPoints[2],
options
);
const curve = Bezier.fromPoints(_lastPoints, widths);
_lastPoints.shift();
return curve;
}
return null;
}
_calculateCurveWidths(startPoint, endPoint, options) {
const velocity = options.velocityFilterWeight * endPoint.velocityFrom(startPoint) + (1 - options.velocityFilterWeight) * this._lastVelocity;
const newWidth = this._strokeWidth(velocity, options);
const widths = {
end: newWidth,
start: this._lastWidth
};
this._lastVelocity = velocity;
this._lastWidth = newWidth;
return widths;
}
_strokeWidth(velocity, options) {
return Math.max(options.maxWidth / (velocity + 1), options.minWidth);
}
_drawCurveSegment(x, y, width) {
const ctx = this._ctx;
ctx.moveTo(x, y);
ctx.arc(x, y, width, 0, 2 * Math.PI, false);
this._isEmpty = false;
}
_drawCurve(curve, options) {
const ctx = this._ctx;
const widthDelta = curve.endWidth - curve.startWidth;
const drawSteps = Math.ceil(curve.length()) * 2;
ctx.beginPath();
ctx.fillStyle = options.penColor;
for (let i = 0; i < drawSteps; i += 1) {
const t = i / drawSteps;
const tt = t * t;
const ttt = tt * t;
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
let x = uuu * curve.startPoint.x;
x += 3 * uu * t * curve.control1.x;
x += 3 * u * tt * curve.control2.x;
x += ttt * curve.endPoint.x;
let y = uuu * curve.startPoint.y;
y += 3 * uu * t * curve.control1.y;
y += 3 * u * tt * curve.control2.y;
y += ttt * curve.endPoint.y;
const width = Math.min(
curve.startWidth + ttt * widthDelta,
options.maxWidth
);
this._drawCurveSegment(x, y, width);
}
ctx.closePath();
ctx.fill();
}
_drawDot(point, options) {
const ctx = this._ctx;
const width = options.dotSize > 0 ? options.dotSize : (options.minWidth + options.maxWidth) / 2;
ctx.beginPath();
this._drawCurveSegment(point.x, point.y, width);
ctx.closePath();
ctx.fillStyle = options.penColor;
ctx.fill();
}
_fromData(pointGroups, drawCurve, drawDot) {
for (const group of pointGroups) {
const { points } = group;
const pointGroupOptions = this._getPointGroupOptions(group);
if (points.length > 1) {
for (let j = 0; j < points.length; j += 1) {
const basicPoint = points[j];
const point = new Point(
basicPoint.x,
basicPoint.y,
basicPoint.pressure,
basicPoint.time
);
if (j === 0) {
this._reset(pointGroupOptions);
}
const curve = this._addPoint(point, pointGroupOptions);
if (curve) {
drawCurve(curve, pointGroupOptions);
}
}
} else {
this._reset(pointGroupOptions);
drawDot(points[0], pointGroupOptions);
}
}
}
toSVG({ includeBackgroundColor = false, includeDataUrl = false } = {}) {
const pointGroups = this._data;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const minX = 0;
const minY = 0;
const maxX = this.canvas.width / ratio;
const maxY = this.canvas.height / ratio;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
svg.setAttribute("viewBox", `${minX} ${minY} ${maxX} ${maxY}`);
svg.setAttribute("width", maxX.toString());
svg.setAttribute("height", maxY.toString());
if (includeBackgroundColor && this.backgroundColor) {
const rect = document.createElement("rect");
rect.setAttribute("width", "100%");
rect.setAttribute("height", "100%");
rect.setAttribute("fill", this.backgroundColor);
svg.appendChild(rect);
}
if (includeDataUrl && this._dataUrl) {
const ratio2 = this._dataUrlOptions?.ratio || window.devicePixelRatio || 1;
const width = this._dataUrlOptions?.width || this.canvas.width / ratio2;
const height = this._dataUrlOptions?.height || this.canvas.height / ratio2;
const xOffset = this._dataUrlOptions?.xOffset || 0;
const yOffset = this._dataUrlOptions?.yOffset || 0;
const image = document.createElement("image");
image.setAttribute("x", xOffset.toString());
image.setAttribute("y", yOffset.toString());
image.setAttribute("width", width.toString());
image.setAttribute("height", height.toString());
image.setAttribute("preserveAspectRatio", "none");
image.setAttribute("href", this._dataUrl);
svg.appendChild(image);
}
this._fromData(
pointGroups,
(curve, { penColor }) => {
const path = document.createElement("path");
if (!isNaN(curve.control1.x) && !isNaN(curve.control1.y) && !isNaN(curve.control2.x) && !isNaN(curve.control2.y)) {
const attr = `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
3
)} C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
path.setAttribute("d", attr);
path.setAttribute("stroke-width", (curve.endWidth * 2.25).toFixed(3));
path.setAttribute("stroke", penColor);
path.setAttribute("fill", "none");
path.setAttribute("stroke-linecap", "round");
svg.appendChild(path);
}
},
(point, { penColor, dotSize, minWidth, maxWidth }) => {
const circle = document.createElement("circle");
const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
circle.setAttribute("r", size.toString());
circle.setAttribute("cx", point.x.toString());
circle.setAttribute("cy", point.y.toString());
circle.setAttribute("fill", penColor);
svg.appendChild(circle);
}
);
return svg.outerHTML;
}
};
}
});
// <stdin>
module.exports = (init_signature_pad(), __toCommonJS(signature_pad_exports)).default;
if(__exports != exports)module.exports = exports;return module.exports}));
//# sourceMappingURL=signature_pad.umd.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,17 @@
import { BasicPoint, Point } from './point.js';
export declare class Bezier {
startPoint: Point;
control2: BasicPoint;
control1: BasicPoint;
endPoint: Point;
startWidth: number;
endWidth: number;
static fromPoints(points: Point[], widths: {
start: number;
end: number;
}): Bezier;
private static calculateControlPoints;
constructor(startPoint: Point, control2: BasicPoint, control1: BasicPoint, endPoint: Point, startWidth: number, endWidth: number);
length(): number;
private point;
}

View file

@ -0,0 +1,16 @@
export interface BasicPoint {
x: number;
y: number;
pressure: number;
time: number;
}
export declare class Point implements BasicPoint {
x: number;
y: number;
pressure: number;
time: number;
constructor(x: number, y: number, pressure?: number, time?: number);
distanceTo(start: BasicPoint): number;
equals(other: BasicPoint): boolean;
velocityFrom(start: BasicPoint): number;
}

View file

@ -0,0 +1,7 @@
export declare class SignatureEventTarget {
private _et;
constructor();
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
dispatchEvent(event: Event): boolean;
removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void;
}

View file

@ -0,0 +1,126 @@
/**
* The main idea and some parts of the code (e.g. drawing variable width Bézier curve) are taken from:
* http://corner.squareup.com/2012/07/smoother-signatures.html
*
* Implementation of interpolation using cubic Bézier curves is taken from:
* https://web.archive.org/web/20160323213433/http://www.benknowscode.com/2012/09/path-interpolation-using-cubic-bezier_9742.html
*
* Algorithm for approximated length of a Bézier curve is taken from:
* http://www.lemoda.net/maths/bezier-length/index.html
*/
import { BasicPoint } from './point.js';
import { SignatureEventTarget } from './signature_event_target.js';
export { BasicPoint } from './point.js';
export interface SignatureEvent {
event: MouseEvent | TouchEvent | PointerEvent;
type: string;
x: number;
y: number;
pressure: number;
}
export interface FromDataOptions {
clear?: boolean;
}
export interface FromDataUrlOptions {
ratio?: number;
width?: number;
height?: number;
xOffset?: number;
yOffset?: number;
}
export interface ToSVGOptions {
includeBackgroundColor?: boolean;
includeDataUrl?: boolean;
}
export interface PointGroupOptions {
dotSize: number;
minWidth: number;
maxWidth: number;
penColor: string;
velocityFilterWeight: number;
/**
* This is the globalCompositeOperation for the line.
* *default: 'source-over'*
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
*/
compositeOperation: GlobalCompositeOperation;
}
export interface Options extends Partial<PointGroupOptions> {
minDistance?: number;
backgroundColor?: string;
throttle?: number;
canvasContextOptions?: CanvasRenderingContext2DSettings;
}
export interface PointGroup extends PointGroupOptions {
points: BasicPoint[];
}
export default class SignaturePad extends SignatureEventTarget {
private canvas;
dotSize: number;
minWidth: number;
maxWidth: number;
penColor: string;
minDistance: number;
velocityFilterWeight: number;
compositeOperation: GlobalCompositeOperation;
backgroundColor: string;
throttle: number;
canvasContextOptions: CanvasRenderingContext2DSettings;
private _ctx;
private _drawingStroke;
private _isEmpty;
private _dataUrl;
private _dataUrlOptions;
private _lastPoints;
private _data;
private _lastVelocity;
private _lastWidth;
private _strokeMoveUpdate;
private _strokePointerId;
constructor(canvas: HTMLCanvasElement, options?: Options);
clear(): void;
redraw(): void;
fromDataURL(dataUrl: string, options?: FromDataUrlOptions): Promise<void>;
toDataURL(type: 'image/svg+xml', encoderOptions?: ToSVGOptions): string;
toDataURL(type?: string, encoderOptions?: number): string;
on(): void;
off(): void;
private _getListenerFunctions;
private _removeMoveUpEventListeners;
isEmpty(): boolean;
fromData(pointGroups: PointGroup[], { clear }?: FromDataOptions): void;
toData(): PointGroup[];
private _isLeftButtonPressed;
private _pointerEventToSignatureEvent;
private _touchEventToSignatureEvent;
private _handleMouseDown;
private _handleMouseMove;
private _handleMouseUp;
private _handleTouchStart;
private _handleTouchMove;
private _handleTouchEnd;
private _handlePointerCancel;
private _handleTouchCancel;
private _getPointerId;
private _allowPointerId;
private _handlePointerDown;
private _handlePointerMove;
private _handlePointerUp;
private _getPointGroupOptions;
private _strokeBegin;
private _strokeUpdate;
private _strokeEnd;
private _handlePointerEvents;
private _handleMouseEvents;
private _handleTouchEvents;
private _reset;
private _createPoint;
private _addPoint;
private _calculateCurveWidths;
private _strokeWidth;
private _drawCurveSegment;
private _drawCurve;
private _drawDot;
private _fromData;
toSVG({ includeBackgroundColor, includeDataUrl }?: ToSVGOptions): string;
}

View file

@ -0,0 +1 @@
export declare function throttle(fn: (...args: any[]) => any, wait?: number): (this: any, ...args: any[]) => any;

View file

@ -0,0 +1,5 @@
module.exports = {
globals: {
SignaturePad: false
}
};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Signature Pad demo</title>
<meta name="description"
content="Signature Pad - HTML5 canvas based smooth signature drawing using variable width spline interpolation.">
<meta name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="stylesheet" href="css/signature-pad.css">
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-39365077-1']);
_gaq.push(['_trackPageview']);
(function () {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</head>
<body onselectstart="return false">
<span id="forkongithub">
<a href="https://github.com/szimek/signature_pad">Fork me on GitHub</a>
</span>
<div id="signature-pad" class="signature-pad">
<div id="canvas-wrapper" class="signature-pad--body">
<canvas></canvas>
</div>
<div class="signature-pad--footer">
<div class="description">Sign above</div>
<div class="signature-pad--actions">
<div class="column">
<button type="button" class="button clear" data-action="clear">Clear</button>
<button type="button" class="button" data-action="undo" title="Ctrl-Z">Undo</button>
<button type="button" class="button" data-action="redo" title="Ctrl-Y">Redo</button>
<br/>
<button type="button" class="button" data-action="change-color">Change color</button>
<button type="button" class="button" data-action="change-width">Change width</button>
<button type="button" class="button" data-action="change-background-color">Change background color</button>
</div>
<div class="column">
<button type="button" class="button save" data-action="save-png">Save as PNG</button>
<button type="button" class="button save" data-action="save-jpg">Save as JPG</button>
<button type="button" class="button save" data-action="save-svg">Save as SVG</button>
<button type="button" class="button save" data-action="save-svg-with-background">Save as SVG with
background</button>
</div>
</div>
<div>
<button type="button" class="button" data-action="open-in-window">Open in Window</button>
</div>
</div>
</div>
<script src="js/signature_pad.umd.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,198 @@
const wrapper = document.getElementById("signature-pad");
const canvasWrapper = document.getElementById("canvas-wrapper");
const clearButton = wrapper.querySelector("[data-action=clear]");
const changeBackgroundColorButton = wrapper.querySelector("[data-action=change-background-color]");
const changeColorButton = wrapper.querySelector("[data-action=change-color]");
const changeWidthButton = wrapper.querySelector("[data-action=change-width]");
const undoButton = wrapper.querySelector("[data-action=undo]");
const redoButton = wrapper.querySelector("[data-action=redo]");
const savePNGButton = wrapper.querySelector("[data-action=save-png]");
const saveJPGButton = wrapper.querySelector("[data-action=save-jpg]");
const saveSVGButton = wrapper.querySelector("[data-action=save-svg]");
const saveSVGWithBackgroundButton = wrapper.querySelector("[data-action=save-svg-with-background]");
const openInWindowButton = wrapper.querySelector("[data-action=open-in-window]");
let undoData = [];
const canvas = wrapper.querySelector("canvas");
const signaturePad = new SignaturePad(canvas, {
// It's Necessary to use an opaque color when saving image as JPEG;
// this option can be omitted if only saving as PNG or SVG
backgroundColor: 'rgb(255, 255, 255)'
});
function randomColor() {
const r = Math.round(Math.random() * 255);
const g = Math.round(Math.random() * 255);
const b = Math.round(Math.random() * 255);
return `rgb(${r},${g},${b})`;
}
// Adjust canvas coordinate space taking into account pixel ratio,
// to make it look crisp on mobile devices.
// This also causes canvas to be cleared.
function resizeCanvas() {
// When zoomed out to less than 100%, for some very strange reason,
// some browsers report devicePixelRatio as less than 1
// and only part of the canvas is cleared then.
const ratio = Math.max(window.devicePixelRatio || 1, 1);
// This part causes the canvas to be cleared
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
canvas.getContext("2d").scale(ratio, ratio);
// This library does not listen for canvas changes, so after the canvas is automatically
// cleared by the browser, SignaturePad#isEmpty might still return false, even though the
// canvas looks empty, because the internal data of this library wasn't cleared. To make sure
// that the state of this library is consistent with visual state of the canvas, you
// have to clear it manually.
//signaturePad.clear();
// If you want to keep the drawing on resize instead of clearing it you can reset the data.
signaturePad.redraw();
}
// On mobile devices it might make more sense to listen to orientation change,
// rather than window resize events.
window.onresize = resizeCanvas;
resizeCanvas();
window.addEventListener("keydown", (event) => {
switch (true) {
case event.key === "z" && event.ctrlKey:
undoButton.click();
break;
case event.key === "y" && event.ctrlKey:
redoButton.click();
break;
}
});
function download(dataURL, filename) {
const blob = dataURLToBlob(dataURL);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style = "display: none";
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}
// One could simply use Canvas#toBlob method instead, but it's just to show
// that it can be done using result of SignaturePad#toDataURL.
function dataURLToBlob(dataURL) {
// Code taken from https://github.com/ebidel/filer.js
const parts = dataURL.split(';base64,');
const contentType = parts[0].split(":")[1];
const raw = window.atob(parts[1]);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
}
signaturePad.addEventListener("endStroke", () => {
// clear undoData when new data is added
undoData = [];
});
clearButton.addEventListener("click", () => {
signaturePad.clear();
});
undoButton.addEventListener("click", () => {
const data = signaturePad.toData();
if (data && data.length > 0) {
// remove the last dot or line
const removed = data.pop();
undoData.push(removed);
signaturePad.redraw();
}
});
redoButton.addEventListener("click", () => {
if (undoData.length > 0) {
const data = signaturePad.toData();
data.push(undoData.pop());
signaturePad.redraw();
}
});
changeBackgroundColorButton.addEventListener("click", () => {
signaturePad.backgroundColor = randomColor();
signaturePad.redraw();
});
changeColorButton.addEventListener("click", () => {
signaturePad.penColor = randomColor();
});
changeWidthButton.addEventListener("click", () => {
const min = Math.round(Math.random() * 100) / 10;
const max = Math.round(Math.random() * 100) / 10;
signaturePad.minWidth = Math.min(min, max);
signaturePad.maxWidth = Math.max(min, max);
});
savePNGButton.addEventListener("click", () => {
if (signaturePad.isEmpty()) {
alert("Please provide a signature first.");
} else {
const dataURL = signaturePad.toDataURL();
download(dataURL, "signature.png");
}
});
saveJPGButton.addEventListener("click", () => {
if (signaturePad.isEmpty()) {
alert("Please provide a signature first.");
} else {
const dataURL = signaturePad.toDataURL("image/jpeg");
download(dataURL, "signature.jpg");
}
});
saveSVGButton.addEventListener("click", () => {
if (signaturePad.isEmpty()) {
alert("Please provide a signature first.");
} else {
const dataURL = signaturePad.toDataURL('image/svg+xml');
download(dataURL, "signature.svg");
}
});
saveSVGWithBackgroundButton.addEventListener("click", () => {
if (signaturePad.isEmpty()) {
alert("Please provide a signature first.");
} else {
const dataURL = signaturePad.toDataURL('image/svg+xml', { includeBackgroundColor: true, includeDataUrl: true });
download(dataURL, "signature.svg");
}
});
openInWindowButton.addEventListener("click", () => {
var externalWin = window.open('', '', `width=${canvas.width / window.devicePixelRatio},height=${canvas.height / window.devicePixelRatio}`);
canvas.style.width = "100%";
canvas.style.height = "100%";
externalWin.onresize = resizeCanvas;
externalWin.document.body.style.margin = '0';
externalWin.document.body.appendChild(canvas);
canvasWrapper.classList.add("empty");
externalWin.onbeforeunload = () => {
canvas.style.width = "";
canvas.style.height = "";
canvasWrapper.classList.remove("empty");
canvasWrapper.appendChild(canvas);
resizeCanvas();
};
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,110 @@
{
"name": "signature_pad",
"description": "Library for drawing smooth signatures.",
"version": "5.1.3",
"homepage": "https://github.com/szimek/signature_pad",
"author": {
"name": "Szymon Nowak",
"email": "szimek@gmail.com",
"url": "https://github.com/szimek"
},
"license": "MIT",
"source": "src/signature_pad.ts",
"main": "dist/signature_pad.umd.js",
"module": "dist/signature_pad.js",
"type": "module",
"types": "dist/types/signature_pad.d.ts",
"exports": {
"types": "./dist/types/signature_pad.d.ts",
"import": "./dist/signature_pad.js",
"require": "./dist/signature_pad.umd.js",
"default": "./dist/signature_pad.umd.js"
},
"scripts": {
"build": "yarn run lint && yarn run clean && node esbuild.config.js && yarn run emit-types && yarn run update-docs",
"clean": "yarn run del dist",
"emit-types": "yarn run del dist/types && yarn run tsc src/signature_pad.ts --lib DOM,ES2015 --declaration --declarationDir dist/types --emitDeclarationOnly",
"format": "prettier --write {src,tests}/**/*.{js,ts}",
"lint": "eslint {src,tests}/**/*.ts",
"prepublishOnly": "yarn run build",
"serve": "serve -l 9000 docs",
"start": "yarn run build && yarn run serve",
"test": "jest --coverage",
"update-docs": "yarn run cpy 'dist/signature_pad.umd.min.*' docs/js"
},
"repository": {
"type": "git",
"url": "https://github.com/szimek/signature_pad.git"
},
"files": [
"src",
"dist",
"docs"
],
"devDependencies": {
"@eslint/js": "^9.34.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.5",
"@semantic-release/npm": "^12.0.2",
"@semantic-release/release-notes-generator": "^14.0.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.0",
"cpy-cli": "^6.0.0",
"del": "^8.0.0",
"del-cli": "^6.0.0",
"esbuild": "^0.25.9",
"esbuild-plugin-umd-wrapper": "^3.0.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^16.3.0",
"jest": "^30.1.3",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^30.1.2",
"prettier": "^3.6.2",
"semantic-release": "^24.2.7",
"serve": "^14.2.4",
"ts-jest": "^29.4.1",
"tslib": "^2.8.1",
"typescript": "~5.9.2",
"typescript-eslint": "^8.42.0"
},
"jest": {
"moduleFileExtensions": [
"ts",
"js"
],
"moduleNameMapper": {
"^\\./bezier\\.js$": "<rootDir>/src/bezier.ts",
"^\\./point\\.js$": "<rootDir>/src/point.ts",
"^\\./signature_event_target\\.js$": "<rootDir>/src/signature_event_target.ts",
"^\\./throttle\\.js$": "<rootDir>/src/throttle.ts"
},
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"resources": "usable",
"url": "http://localhost:3000/"
},
"testMatch": [
"<rootDir>/tests/**/*.test.ts"
],
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"setupFiles": [
"jest-canvas-mock"
]
},
"release": {
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github",
"@semantic-release/git"
]
},
"packageManager": "yarn@4.9.4"
}

View file

@ -0,0 +1,109 @@
import { BasicPoint, Point } from './point.js';
export class Bezier {
public static fromPoints(
points: Point[],
widths: { start: number; end: number },
): Bezier {
const c2 = this.calculateControlPoints(points[0], points[1], points[2]).c2;
const c3 = this.calculateControlPoints(points[1], points[2], points[3]).c1;
return new Bezier(points[1], c2, c3, points[2], widths.start, widths.end);
}
private static calculateControlPoints(
s1: BasicPoint,
s2: BasicPoint,
s3: BasicPoint,
): {
c1: BasicPoint;
c2: BasicPoint;
} {
const dx1 = s1.x - s2.x;
const dy1 = s1.y - s2.y;
const dx2 = s2.x - s3.x;
const dy2 = s2.y - s3.y;
const m1 = { x: (s1.x + s2.x) / 2.0, y: (s1.y + s2.y) / 2.0 };
const m2 = { x: (s2.x + s3.x) / 2.0, y: (s2.y + s3.y) / 2.0 };
const l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const dxm = m1.x - m2.x;
const dym = m1.y - m2.y;
const k = l1 + l2 == 0 ? 0 : l2 / (l1 + l2);
const cm = { x: m2.x + dxm * k, y: m2.y + dym * k };
const tx = s2.x - cm.x;
const ty = s2.y - cm.y;
return {
c1: new Point(m1.x + tx, m1.y + ty),
c2: new Point(m2.x + tx, m2.y + ty),
};
}
constructor(
public startPoint: Point,
public control2: BasicPoint,
public control1: BasicPoint,
public endPoint: Point,
public startWidth: number,
public endWidth: number,
) {}
// Returns approximated length. Code taken from https://www.lemoda.net/maths/bezier-length/index.html.
public length(): number {
const steps = 10;
let length = 0;
let px;
let py;
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const cx = this.point(
t,
this.startPoint.x,
this.control1.x,
this.control2.x,
this.endPoint.x,
);
const cy = this.point(
t,
this.startPoint.y,
this.control1.y,
this.control2.y,
this.endPoint.y,
);
if (i > 0) {
const xdiff = cx - (px as number);
const ydiff = cy - (py as number);
length += Math.sqrt(xdiff * xdiff + ydiff * ydiff);
}
px = cx;
py = cy;
}
return length;
}
// Calculate parametric value of x or y given t and the four point coordinates of a cubic bezier curve.
private point(
t: number,
start: number,
c1: number,
c2: number,
end: number,
): number {
// prettier-ignore
return ( start * (1.0 - t) * (1.0 - t) * (1.0 - t))
+ (3.0 * c1 * (1.0 - t) * (1.0 - t) * t)
+ (3.0 * c2 * (1.0 - t) * t * t)
+ ( end * t * t * t);
}
}

View file

@ -0,0 +1,45 @@
// Interface for point data structure used e.g. in SignaturePad#fromData method
export interface BasicPoint {
x: number;
y: number;
pressure: number;
time: number;
}
export class Point implements BasicPoint {
public x: number;
public y: number;
public pressure: number;
public time: number;
constructor(x: number, y: number, pressure?: number, time?: number) {
if (isNaN(x) || isNaN(y)) {
throw new Error(`Point is invalid: (${x}, ${y})`);
}
this.x = +x;
this.y = +y;
this.pressure = pressure || 0;
this.time = time || Date.now();
}
public distanceTo(start: BasicPoint): number {
return Math.sqrt(
Math.pow(this.x - start.x, 2) + Math.pow(this.y - start.y, 2),
);
}
public equals(other: BasicPoint): boolean {
return (
this.x === other.x &&
this.y === other.y &&
this.pressure === other.pressure &&
this.time === other.time
);
}
public velocityFrom(start: BasicPoint): number {
return this.time !== start.time
? this.distanceTo(start) / (this.time - start.time)
: 0;
}
}

View file

@ -0,0 +1,35 @@
export class SignatureEventTarget {
/* tslint:disable: variable-name */
private _et: EventTarget;
/* tslint:enable: variable-name */
constructor() {
try {
this._et = new EventTarget();
} catch {
// Using document as EventTarget to support iOS 13 and older.
// Because EventTarget constructor just exists at iOS 14 and later.
this._et = document;
}
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void {
this._et.addEventListener(type, listener, options);
}
dispatchEvent(event: Event): boolean {
return this._et.dispatchEvent(event);
}
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions,
): void {
this._et.removeEventListener(type, callback, options);
}
}

View file

@ -0,0 +1,918 @@
/**
* The main idea and some parts of the code (e.g. drawing variable width Bézier curve) are taken from:
* http://corner.squareup.com/2012/07/smoother-signatures.html
*
* Implementation of interpolation using cubic Bézier curves is taken from:
* https://web.archive.org/web/20160323213433/http://www.benknowscode.com/2012/09/path-interpolation-using-cubic-bezier_9742.html
*
* Algorithm for approximated length of a Bézier curve is taken from:
* http://www.lemoda.net/maths/bezier-length/index.html
*/
import { Bezier } from './bezier.js';
import { BasicPoint, Point } from './point.js';
import { SignatureEventTarget } from './signature_event_target.js';
import { throttle } from './throttle.js';
export { BasicPoint } from './point.js';
export interface SignatureEvent {
event: MouseEvent | TouchEvent | PointerEvent;
type: string;
x: number;
y: number;
pressure: number;
}
export interface FromDataOptions {
clear?: boolean;
}
export interface FromDataUrlOptions {
ratio?: number;
width?: number;
height?: number;
xOffset?: number;
yOffset?: number;
}
export interface ToSVGOptions {
includeBackgroundColor?: boolean;
includeDataUrl?: boolean;
}
export interface PointGroupOptions {
dotSize: number;
minWidth: number;
maxWidth: number;
penColor: string;
velocityFilterWeight: number;
/**
* This is the globalCompositeOperation for the line.
* *default: 'source-over'*
* @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
*/
compositeOperation: GlobalCompositeOperation;
}
export interface Options extends Partial<PointGroupOptions> {
minDistance?: number;
backgroundColor?: string;
throttle?: number;
canvasContextOptions?: CanvasRenderingContext2DSettings;
}
export interface PointGroup extends PointGroupOptions {
points: BasicPoint[];
}
export default class SignaturePad extends SignatureEventTarget {
// Public stuff
public dotSize: number;
public minWidth: number;
public maxWidth: number;
public penColor: string;
public minDistance: number;
public velocityFilterWeight: number;
public compositeOperation: GlobalCompositeOperation;
public backgroundColor: string;
public throttle: number;
public canvasContextOptions: CanvasRenderingContext2DSettings;
// Private stuff
/* tslint:disable: variable-name */
private _ctx: CanvasRenderingContext2D;
private _drawingStroke = false;
private _isEmpty = true;
private _dataUrl: string | undefined;
private _dataUrlOptions: FromDataUrlOptions | undefined;
private _lastPoints: Point[] = []; // Stores up to 4 most recent points; used to generate a new curve
private _data: PointGroup[] = []; // Stores all points in groups (one group per line or dot)
private _lastVelocity = 0;
private _lastWidth = 0;
private _strokeMoveUpdate: (event: SignatureEvent) => void;
private _strokePointerId: number | undefined;
/* tslint:enable: variable-name */
constructor(
private canvas: HTMLCanvasElement,
options: Options = {},
) {
super();
this.velocityFilterWeight = options.velocityFilterWeight || 0.7;
this.minWidth = options.minWidth || 0.5;
this.maxWidth = options.maxWidth || 2.5;
// We need to handle 0 value, so use `??` instead of `||`
this.throttle = options.throttle ?? 16; // in milliseconds
this.minDistance = options.minDistance ?? 5; // in pixels
this.dotSize = options.dotSize || 0;
this.penColor = options.penColor || 'black';
this.backgroundColor = options.backgroundColor || 'rgba(0,0,0,0)';
this.compositeOperation = options.compositeOperation || 'source-over';
this.canvasContextOptions = options.canvasContextOptions ?? {};
this._strokeMoveUpdate = this.throttle
? throttle(SignaturePad.prototype._strokeUpdate, this.throttle)
: SignaturePad.prototype._strokeUpdate;
this._handleMouseDown = this._handleMouseDown.bind(this);
this._handleMouseMove = this._handleMouseMove.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this);
this._handleTouchStart = this._handleTouchStart.bind(this);
this._handleTouchMove = this._handleTouchMove.bind(this);
this._handleTouchEnd = this._handleTouchEnd.bind(this);
this._handlePointerDown = this._handlePointerDown.bind(this);
this._handlePointerMove = this._handlePointerMove.bind(this);
this._handlePointerUp = this._handlePointerUp.bind(this);
this._handlePointerCancel = this._handlePointerCancel.bind(this);
this._handleTouchCancel = this._handleTouchCancel.bind(this);
this._ctx = canvas.getContext(
'2d',
this.canvasContextOptions,
) as CanvasRenderingContext2D;
this.clear();
// Enable mouse and touch event handlers
this.on();
}
public clear(): void {
const { _ctx: ctx, canvas } = this;
// Clear canvas using background color
ctx.fillStyle = this.backgroundColor;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, canvas.width, canvas.height);
this._data = [];
this._reset(this._getPointGroupOptions());
this._isEmpty = true;
this._dataUrl = undefined;
this._dataUrlOptions = undefined;
this._strokePointerId = undefined;
}
public redraw(): void {
const data = this._data;
const dataUrl = this._dataUrl;
const dataUrlOptions = this._dataUrlOptions;
this.clear();
if (dataUrl) {
this.fromDataURL(dataUrl, dataUrlOptions);
}
this.fromData(data, { clear: false });
}
public fromDataURL(
dataUrl: string,
options: FromDataUrlOptions = {},
): Promise<void> {
return new Promise((resolve, reject) => {
const image = new Image();
const ratio = options.ratio || window.devicePixelRatio || 1;
const width = options.width || this.canvas.width / ratio;
const height = options.height || this.canvas.height / ratio;
const xOffset = options.xOffset || 0;
const yOffset = options.yOffset || 0;
this._reset(this._getPointGroupOptions());
image.onload = (): void => {
this._ctx.drawImage(image, xOffset, yOffset, width, height);
resolve();
};
image.onerror = (error): void => {
reject(error);
};
image.crossOrigin = 'anonymous';
image.src = dataUrl;
this._isEmpty = false;
this._dataUrl = dataUrl;
this._dataUrlOptions = {...options};
});
}
public toDataURL(
type: 'image/svg+xml',
encoderOptions?: ToSVGOptions,
): string;
public toDataURL(type?: string, encoderOptions?: number): string;
public toDataURL(
type = 'image/png',
encoderOptions?: number | ToSVGOptions | undefined,
): string {
switch (type) {
case 'image/svg+xml':
if (typeof encoderOptions !== 'object') {
encoderOptions = undefined;
}
return `data:image/svg+xml;base64,${btoa(
this.toSVG(encoderOptions as ToSVGOptions),
)}`;
default:
if (typeof encoderOptions !== 'number') {
encoderOptions = undefined;
}
return this.canvas.toDataURL(type, encoderOptions as number);
}
}
public on(): void {
// Disable panning/zooming when touching canvas element
this.canvas.style.touchAction = 'none';
(
this.canvas.style as CSSStyleDeclaration & {
msTouchAction: string | null;
}
).msTouchAction = 'none';
this.canvas.style.userSelect = 'none';
// Safari does not support userSelect property without a prefix even as of iOS 26
// https://caniuse.com/?search=user-select
this.canvas.style.webkitUserSelect = 'none';
const isIOS =
/Macintosh/.test(navigator.userAgent) && 'ontouchstart' in document;
// The "Scribble" feature of iOS intercepts point events. So that we can
// lose some of them when tapping rapidly. Use touch events for iOS
// platforms to prevent it. See
// https://developer.apple.com/forums/thread/664108 for more information.
if (window.PointerEvent && !isIOS) {
this._handlePointerEvents();
} else {
this._handleMouseEvents();
if ('ontouchstart' in window) {
this._handleTouchEvents();
}
}
}
public off(): void {
// Enable panning/zooming when touching canvas element
this.canvas.style.touchAction = 'auto';
(
this.canvas.style as CSSStyleDeclaration & {
msTouchAction: string | null;
}
).msTouchAction = 'auto';
this.canvas.style.userSelect = 'auto';
this.canvas.style.webkitUserSelect = 'auto';
this.canvas.removeEventListener('pointerdown', this._handlePointerDown);
this.canvas.removeEventListener('mousedown', this._handleMouseDown);
this.canvas.removeEventListener('touchstart', this._handleTouchStart);
this._removeMoveUpEventListeners();
}
private _getListenerFunctions() {
const canvasWindow =
window.document === this.canvas.ownerDocument
? window
: (this.canvas.ownerDocument.defaultView ?? this.canvas.ownerDocument);
return {
addEventListener: canvasWindow.addEventListener.bind(
canvasWindow,
) as typeof window.addEventListener,
removeEventListener: canvasWindow.removeEventListener.bind(
canvasWindow,
) as typeof window.removeEventListener,
};
}
private _removeMoveUpEventListeners(): void {
const { removeEventListener } = this._getListenerFunctions();
removeEventListener('pointermove', this._handlePointerMove);
removeEventListener('pointerup', this._handlePointerUp);
removeEventListener('pointercancel', this._handlePointerCancel);
removeEventListener('mousemove', this._handleMouseMove);
removeEventListener('mouseup', this._handleMouseUp);
removeEventListener('touchmove', this._handleTouchMove);
removeEventListener('touchend', this._handleTouchEnd);
removeEventListener('touchcancel', this._handleTouchCancel);
}
public isEmpty(): boolean {
return this._isEmpty;
}
public fromData(
pointGroups: PointGroup[],
{ clear = true }: FromDataOptions = {},
): void {
if (clear) {
this.clear();
}
this._fromData(
pointGroups,
this._drawCurve.bind(this),
this._drawDot.bind(this),
);
this._data = this._data.concat(pointGroups);
}
public toData(): PointGroup[] {
return this._data;
}
private _isLeftButtonPressed(event: MouseEvent, only?: boolean): boolean {
if (only) {
return event.buttons === 1;
}
return (event.buttons & 1) === 1;
}
private _pointerEventToSignatureEvent(
event: MouseEvent | PointerEvent,
): SignatureEvent {
return {
event: event,
type: event.type,
x: event.clientX,
y: event.clientY,
pressure: 'pressure' in event ? event.pressure : 0,
};
}
private _touchEventToSignatureEvent(event: TouchEvent): SignatureEvent {
const touch = event.changedTouches[0];
return {
event: event,
type: event.type,
x: touch.clientX,
y: touch.clientY,
pressure: touch.force,
};
}
// Event handlers
private _handleMouseDown(event: MouseEvent): void {
if (!this._isLeftButtonPressed(event, true) || this._drawingStroke) {
return;
}
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
private _handleMouseMove(event: MouseEvent): void {
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
// Stop when not pressing primary button or pressing multiple buttons
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
private _handleMouseUp(event: MouseEvent): void {
if (this._isLeftButtonPressed(event)) {
return;
}
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
private _handleTouchStart(event: TouchEvent): void {
if (event.targetTouches.length !== 1 || this._drawingStroke) {
return;
}
// Prevent scrolling.
if (event.cancelable) {
event.preventDefault();
}
this._strokeBegin(this._touchEventToSignatureEvent(event));
}
private _handleTouchMove(event: TouchEvent): void {
if (event.targetTouches.length !== 1) {
return;
}
// Prevent scrolling.
if (event.cancelable) {
event.preventDefault();
}
if (!this._drawingStroke) {
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
return;
}
this._strokeMoveUpdate(this._touchEventToSignatureEvent(event));
}
private _handleTouchEnd(event: TouchEvent): void {
if (event.targetTouches.length !== 0) {
return;
}
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event));
}
private _handlePointerCancel(event: PointerEvent): void {
if (!this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
}
private _handleTouchCancel(event: TouchEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this._strokeEnd(this._touchEventToSignatureEvent(event), false);
}
private _getPointerId(event: PointerEvent) {
// @ts-expect-error persistentDeviceId is not available yet but we want to use it when it is available
return event.persistentDeviceId || event.pointerId;
}
private _allowPointerId(
event: PointerEvent,
allowUndefined = false,
): boolean {
if (typeof this._strokePointerId === 'undefined') {
return allowUndefined;
}
return this._getPointerId(event) === this._strokePointerId;
}
private _handlePointerDown(event: PointerEvent): void {
if (
this._drawingStroke ||
!this._isLeftButtonPressed(event) ||
!this._allowPointerId(event, true)
) {
return;
}
this._strokePointerId = this._getPointerId(event);
event.preventDefault();
this._strokeBegin(this._pointerEventToSignatureEvent(event));
}
private _handlePointerMove(event: PointerEvent): void {
if (!this._allowPointerId(event)) {
return;
}
if (!this._isLeftButtonPressed(event, true) || !this._drawingStroke) {
// Stop when primary button not pressed or multiple buttons pressed
this._strokeEnd(this._pointerEventToSignatureEvent(event), false);
return;
}
event.preventDefault();
this._strokeMoveUpdate(this._pointerEventToSignatureEvent(event));
}
private _handlePointerUp(event: PointerEvent): void {
if (this._isLeftButtonPressed(event) || !this._allowPointerId(event)) {
return;
}
event.preventDefault();
this._strokeEnd(this._pointerEventToSignatureEvent(event));
}
private _getPointGroupOptions(group?: PointGroup): PointGroupOptions {
return {
penColor: group && 'penColor' in group ? group.penColor : this.penColor,
dotSize: group && 'dotSize' in group ? group.dotSize : this.dotSize,
minWidth: group && 'minWidth' in group ? group.minWidth : this.minWidth,
maxWidth: group && 'maxWidth' in group ? group.maxWidth : this.maxWidth,
velocityFilterWeight:
group && 'velocityFilterWeight' in group
? group.velocityFilterWeight
: this.velocityFilterWeight,
compositeOperation:
group && 'compositeOperation' in group
? group.compositeOperation
: this.compositeOperation,
};
}
// Private methods
private _strokeBegin(event: SignatureEvent): void {
const cancelled = !this.dispatchEvent(
new CustomEvent('beginStroke', { detail: event, cancelable: true }),
);
if (cancelled) {
return;
}
const { addEventListener } = this._getListenerFunctions();
switch (event.event.type) {
case 'mousedown':
addEventListener('mousemove', this._handleMouseMove, {
passive: false,
});
addEventListener('mouseup', this._handleMouseUp, { passive: false });
break;
case 'touchstart':
addEventListener('touchmove', this._handleTouchMove, {
passive: false,
});
addEventListener('touchend', this._handleTouchEnd, { passive: false });
addEventListener('touchcancel', this._handleTouchCancel, { passive: false });
break;
case 'pointerdown':
addEventListener('pointermove', this._handlePointerMove, {
passive: false,
});
addEventListener('pointerup', this._handlePointerUp, {
passive: false,
});
addEventListener('pointercancel', this._handlePointerCancel, {
passive: false,
});
break;
default:
// do nothing
}
this._drawingStroke = true;
const pointGroupOptions = this._getPointGroupOptions();
const newPointGroup: PointGroup = {
...pointGroupOptions,
points: [],
};
this._data.push(newPointGroup);
this._reset(pointGroupOptions);
this._strokeUpdate(event);
}
private _strokeUpdate(event: SignatureEvent): void {
if (!this._drawingStroke) {
return;
}
if (this._data.length === 0) {
// This can happen if clear() was called while a signature is still in progress,
// or if there is a race condition between start/update events.
this._strokeBegin(event);
return;
}
this.dispatchEvent(
new CustomEvent('beforeUpdateStroke', { detail: event }),
);
const point = this._createPoint(event.x, event.y, event.pressure);
const lastPointGroup = this._data[this._data.length - 1];
const lastPoints = lastPointGroup.points;
const lastPoint =
lastPoints.length > 0 && lastPoints[lastPoints.length - 1];
const isLastPointTooClose = lastPoint
? point.distanceTo(lastPoint) <= this.minDistance
: false;
const pointGroupOptions = this._getPointGroupOptions(lastPointGroup);
// Skip this point if it's too close to the previous one
if (!lastPoint || !(lastPoint && isLastPointTooClose)) {
const curve = this._addPoint(point, pointGroupOptions);
if (!lastPoint) {
this._drawDot(point, pointGroupOptions);
} else if (curve) {
this._drawCurve(curve, pointGroupOptions);
}
lastPoints.push({
time: point.time,
x: point.x,
y: point.y,
pressure: point.pressure,
});
}
this.dispatchEvent(new CustomEvent('afterUpdateStroke', { detail: event }));
}
private _strokeEnd(event: SignatureEvent, shouldUpdate = true): void {
this._removeMoveUpEventListeners();
if (!this._drawingStroke) {
return;
}
if (shouldUpdate) {
this._strokeUpdate(event);
}
this._drawingStroke = false;
this._strokePointerId = undefined;
this.dispatchEvent(new CustomEvent('endStroke', { detail: event }));
}
private _handlePointerEvents(): void {
this._drawingStroke = false;
this.canvas.addEventListener('pointerdown', this._handlePointerDown, {
passive: false,
});
}
private _handleMouseEvents(): void {
this._drawingStroke = false;
this.canvas.addEventListener('mousedown', this._handleMouseDown, {
passive: false,
});
}
private _handleTouchEvents(): void {
this.canvas.addEventListener('touchstart', this._handleTouchStart, {
passive: false,
});
}
// Called when a new line is started
private _reset(options: PointGroupOptions): void {
this._lastPoints = [];
this._lastVelocity = 0;
this._lastWidth = (options.minWidth + options.maxWidth) / 2;
this._ctx.fillStyle = options.penColor;
this._ctx.globalCompositeOperation = options.compositeOperation;
}
private _createPoint(x: number, y: number, pressure: number): Point {
const rect = this.canvas.getBoundingClientRect();
return new Point(
x - rect.left,
y - rect.top,
pressure,
new Date().getTime(),
);
}
// Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3)
private _addPoint(point: Point, options: PointGroupOptions): Bezier | null {
const { _lastPoints } = this;
_lastPoints.push(point);
if (_lastPoints.length > 2) {
// To reduce the initial lag make it work with 3 points
// by copying the first point to the beginning.
if (_lastPoints.length === 3) {
_lastPoints.unshift(_lastPoints[0]);
}
// _points array will always have 4 points here.
const widths = this._calculateCurveWidths(
_lastPoints[1],
_lastPoints[2],
options,
);
const curve = Bezier.fromPoints(_lastPoints, widths);
// Remove the first element from the list, so that there are no more than 4 points at any time.
_lastPoints.shift();
return curve;
}
return null;
}
private _calculateCurveWidths(
startPoint: Point,
endPoint: Point,
options: PointGroupOptions,
): { start: number; end: number } {
const velocity =
options.velocityFilterWeight * endPoint.velocityFrom(startPoint) +
(1 - options.velocityFilterWeight) * this._lastVelocity;
const newWidth = this._strokeWidth(velocity, options);
const widths = {
end: newWidth,
start: this._lastWidth,
};
this._lastVelocity = velocity;
this._lastWidth = newWidth;
return widths;
}
private _strokeWidth(velocity: number, options: PointGroupOptions): number {
return Math.max(options.maxWidth / (velocity + 1), options.minWidth);
}
private _drawCurveSegment(x: number, y: number, width: number): void {
const ctx = this._ctx;
ctx.moveTo(x, y);
ctx.arc(x, y, width, 0, 2 * Math.PI, false);
this._isEmpty = false;
}
private _drawCurve(curve: Bezier, options: PointGroupOptions): void {
const ctx = this._ctx;
const widthDelta = curve.endWidth - curve.startWidth;
// '2' is just an arbitrary number here. If only length is used, then
// there are gaps between curve segments :/
const drawSteps = Math.ceil(curve.length()) * 2;
ctx.beginPath();
ctx.fillStyle = options.penColor;
for (let i = 0; i < drawSteps; i += 1) {
// Calculate the Bezier (x, y) coordinate for this step.
const t = i / drawSteps;
const tt = t * t;
const ttt = tt * t;
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
let x = uuu * curve.startPoint.x;
x += 3 * uu * t * curve.control1.x;
x += 3 * u * tt * curve.control2.x;
x += ttt * curve.endPoint.x;
let y = uuu * curve.startPoint.y;
y += 3 * uu * t * curve.control1.y;
y += 3 * u * tt * curve.control2.y;
y += ttt * curve.endPoint.y;
const width = Math.min(
curve.startWidth + ttt * widthDelta,
options.maxWidth,
);
this._drawCurveSegment(x, y, width);
}
ctx.closePath();
ctx.fill();
}
private _drawDot(point: BasicPoint, options: PointGroupOptions): void {
const ctx = this._ctx;
const width =
options.dotSize > 0
? options.dotSize
: (options.minWidth + options.maxWidth) / 2;
ctx.beginPath();
this._drawCurveSegment(point.x, point.y, width);
ctx.closePath();
ctx.fillStyle = options.penColor;
ctx.fill();
}
private _fromData(
pointGroups: PointGroup[],
drawCurve: SignaturePad['_drawCurve'],
drawDot: SignaturePad['_drawDot'],
): void {
for (const group of pointGroups) {
const { points } = group;
const pointGroupOptions = this._getPointGroupOptions(group);
if (points.length > 1) {
for (let j = 0; j < points.length; j += 1) {
const basicPoint = points[j];
const point = new Point(
basicPoint.x,
basicPoint.y,
basicPoint.pressure,
basicPoint.time,
);
if (j === 0) {
this._reset(pointGroupOptions);
}
const curve = this._addPoint(point, pointGroupOptions);
if (curve) {
drawCurve(curve, pointGroupOptions);
}
}
} else {
this._reset(pointGroupOptions);
drawDot(points[0], pointGroupOptions);
}
}
}
public toSVG({ includeBackgroundColor = false, includeDataUrl = false }: ToSVGOptions = {}): string {
const pointGroups = this._data;
const ratio = Math.max(window.devicePixelRatio || 1, 1);
const minX = 0;
const minY = 0;
const maxX = this.canvas.width / ratio;
const maxY = this.canvas.height / ratio;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
svg.setAttribute('viewBox', `${minX} ${minY} ${maxX} ${maxY}`);
svg.setAttribute('width', maxX.toString());
svg.setAttribute('height', maxY.toString());
if (includeBackgroundColor && this.backgroundColor) {
const rect = document.createElement('rect');
rect.setAttribute('width', '100%');
rect.setAttribute('height', '100%');
rect.setAttribute('fill', this.backgroundColor);
svg.appendChild(rect);
}
if (includeDataUrl && this._dataUrl) {
const ratio = this._dataUrlOptions?.ratio || window.devicePixelRatio || 1;
const width = this._dataUrlOptions?.width || this.canvas.width / ratio;
const height = this._dataUrlOptions?.height || this.canvas.height / ratio;
const xOffset = this._dataUrlOptions?.xOffset || 0;
const yOffset = this._dataUrlOptions?.yOffset || 0;
const image = document.createElement('image');
image.setAttribute('x', xOffset.toString());
image.setAttribute('y', yOffset.toString());
image.setAttribute('width', width.toString());
image.setAttribute('height', height.toString());
image.setAttribute('preserveAspectRatio', 'none');
image.setAttribute('href', this._dataUrl);
svg.appendChild(image);
}
this._fromData(
pointGroups,
(curve, { penColor }) => {
const path = document.createElement('path');
// Need to check curve for NaN values, these pop up when drawing
// lines on the canvas that are not continuous. E.g. Sharp corners
// or stopping mid-stroke and than continuing without lifting mouse.
if (
!isNaN(curve.control1.x) &&
!isNaN(curve.control1.y) &&
!isNaN(curve.control2.x) &&
!isNaN(curve.control2.y)
) {
const attr =
`M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(
3,
)} ` +
`C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ` +
`${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ` +
`${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`;
path.setAttribute('d', attr);
path.setAttribute('stroke-width', (curve.endWidth * 2.25).toFixed(3));
path.setAttribute('stroke', penColor);
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
svg.appendChild(path);
}
},
(point, { penColor, dotSize, minWidth, maxWidth }) => {
const circle = document.createElement('circle');
const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2;
circle.setAttribute('r', size.toString());
circle.setAttribute('cx', point.x.toString());
circle.setAttribute('cy', point.y.toString());
circle.setAttribute('fill', penColor);
svg.appendChild(circle);
},
);
return svg.outerHTML;
}
}

View file

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-this-alias */
// Slightly simplified version of http://stackoverflow.com/a/27078401/815507
export function throttle(
fn: (...args: any[]) => any,
wait = 250,
): (this: any, ...args: any[]) => any {
let previous = 0;
let timeout: number | null = null;
let result: any;
let storedContext: any;
let storedArgs: any[];
const later = (): void => {
previous = Date.now();
timeout = null;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
};
return function wrapper(this: any, ...args: any[]): any {
const now = Date.now();
const remaining = wait - (now - previous);
storedContext = this;
storedArgs = args;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = fn.apply(storedContext, storedArgs);
if (!timeout) {
storedContext = null;
storedArgs = [];
}
} else if (!timeout) {
timeout = window.setTimeout(later, remaining);
}
return result;
};
}