mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
Add ability to enable or disable allowed signature types: - Drawn - Typed - Uploaded **Tabbed style signature dialog**  **Document settings**  **Team preferences**  - Add multiselect to select allowed signatures in document and templates settings tab - Add multiselect to select allowed signatures in teams preferences - Removed "Enable typed signatures" from document/template edit page - Refactored signature pad to use tabs instead of an all in one signature pad Added E2E tests to check settings are applied correctly for documents and templates
326 lines
8.7 KiB
TypeScript
326 lines
8.7 KiB
TypeScript
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
|
|
|
import { Point } from './point';
|
|
|
|
export class Canvas {
|
|
private readonly $canvas: HTMLCanvasElement;
|
|
private readonly $offscreenCanvas: HTMLCanvasElement;
|
|
|
|
private currentCanvasWidth = 0;
|
|
private currentCanvasHeight = 0;
|
|
|
|
private points: Point[] = [];
|
|
private onChangeHandlers: Array<(_canvas: Canvas, _cleared: boolean) => void> = [];
|
|
|
|
private isPressed = false;
|
|
private lastVelocity = 0;
|
|
|
|
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
|
|
private readonly DPI = SIGNATURE_CANVAS_DPI;
|
|
|
|
constructor(canvas: HTMLCanvasElement) {
|
|
this.$canvas = canvas;
|
|
this.$offscreenCanvas = document.createElement('canvas');
|
|
|
|
const { width, height } = this.$canvas.getBoundingClientRect();
|
|
|
|
this.currentCanvasWidth = width * this.DPI;
|
|
this.currentCanvasHeight = height * this.DPI;
|
|
|
|
this.$canvas.width = this.currentCanvasWidth;
|
|
this.$canvas.height = this.currentCanvasHeight;
|
|
|
|
Object.assign(this.$canvas.style, {
|
|
touchAction: 'none',
|
|
msTouchAction: 'none',
|
|
userSelect: 'none',
|
|
});
|
|
|
|
window.addEventListener('resize', this.onResize.bind(this));
|
|
|
|
this.$canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
|
|
this.$canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
|
|
this.$canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
|
|
this.$canvas.addEventListener('mouseenter', this.onMouseEnter.bind(this));
|
|
this.$canvas.addEventListener('mouseleave', this.onMouseLeave.bind(this));
|
|
this.$canvas.addEventListener('pointerdown', this.onMouseDown.bind(this));
|
|
this.$canvas.addEventListener('pointermove', this.onMouseMove.bind(this));
|
|
this.$canvas.addEventListener('pointerup', this.onMouseUp.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Calculates the minimum stroke width as a percentage of the current canvas suitable for a signature.
|
|
*/
|
|
private minStrokeWidth(): number {
|
|
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.005;
|
|
}
|
|
|
|
/**
|
|
* Calculates the maximum stroke width as a percentage of the current canvas suitable for a signature.
|
|
*/
|
|
private maxStrokeWidth(): number {
|
|
return Math.min(this.currentCanvasWidth, this.currentCanvasHeight) * 0.035;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the HTML canvas element.
|
|
*/
|
|
public getCanvas(): HTMLCanvasElement {
|
|
return this.$canvas;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the 2D rendering context of the canvas.
|
|
* Throws an error if the context is not available.
|
|
*/
|
|
public getContext(): CanvasRenderingContext2D {
|
|
const ctx = this.$canvas.getContext('2d');
|
|
|
|
if (!ctx) {
|
|
throw new Error('Canvas context is not available.');
|
|
}
|
|
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Handles the resize event of the canvas.
|
|
* Adjusts the canvas size and preserves the content using image data.
|
|
*/
|
|
private onResize(): void {
|
|
const { width, height } = this.$canvas.getBoundingClientRect();
|
|
|
|
const oldWidth = this.currentCanvasWidth;
|
|
const oldHeight = this.currentCanvasHeight;
|
|
|
|
const ctx = this.getContext();
|
|
|
|
const imageData = ctx.getImageData(0, 0, oldWidth, oldHeight);
|
|
|
|
this.$canvas.width = width * this.DPI;
|
|
this.$canvas.height = height * this.DPI;
|
|
|
|
this.currentCanvasWidth = width * this.DPI;
|
|
this.currentCanvasHeight = height * this.DPI;
|
|
|
|
ctx.putImageData(imageData, 0, 0, 0, 0, width * this.DPI, height * this.DPI);
|
|
}
|
|
|
|
/**
|
|
* Handles the mouse down event on the canvas.
|
|
* Adds the starting point for the signature.
|
|
*/
|
|
private onMouseDown(event: MouseEvent | PointerEvent | TouchEvent): void {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
this.isPressed = true;
|
|
|
|
const point = Point.fromEvent(event, this.DPI);
|
|
|
|
this.addPoint(point);
|
|
}
|
|
|
|
/**
|
|
* Handles the mouse move event on the canvas.
|
|
* Adds a point to the signature if the mouse is pressed, based on the sample rate.
|
|
*/
|
|
private onMouseMove(event: MouseEvent | PointerEvent | TouchEvent): void {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (!this.isPressed) {
|
|
return;
|
|
}
|
|
|
|
const point = Point.fromEvent(event, this.DPI);
|
|
|
|
if (point.distanceTo(this.points[this.points.length - 1]) > 10) {
|
|
this.addPoint(point);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the mouse up event on the canvas.
|
|
* Adds the final point for the signature and resets the points array.
|
|
*/
|
|
private onMouseUp(event: MouseEvent | PointerEvent | TouchEvent, addPoint = true): void {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
this.isPressed = false;
|
|
|
|
const point = Point.fromEvent(event, this.DPI);
|
|
|
|
if (addPoint) {
|
|
this.addPoint(point);
|
|
}
|
|
|
|
this.onChangeHandlers.forEach((handler) => handler(this, false));
|
|
|
|
this.points = [];
|
|
}
|
|
|
|
private onMouseEnter(event: MouseEvent): void {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
event.buttons === 1 && this.onMouseDown(event);
|
|
}
|
|
|
|
private onMouseLeave(event: MouseEvent): void {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
this.onMouseUp(event, false);
|
|
}
|
|
|
|
/**
|
|
* Adds a point to the signature and performs smoothing and drawing.
|
|
*/
|
|
private addPoint(point: Point): void {
|
|
const lastPoint = this.points[this.points.length - 1] ?? point;
|
|
|
|
this.points.push(point);
|
|
|
|
const smoothedPoints = this.smoothSignature(this.points);
|
|
|
|
let velocity = point.velocityFrom(lastPoint);
|
|
velocity =
|
|
this.VELOCITY_FILTER_WEIGHT * velocity +
|
|
(1 - this.VELOCITY_FILTER_WEIGHT) * this.lastVelocity;
|
|
|
|
const newWidth =
|
|
velocity > 0 && this.lastVelocity > 0 ? this.strokeWidth(velocity) : this.minStrokeWidth();
|
|
|
|
this.drawSmoothSignature(smoothedPoints, newWidth);
|
|
|
|
this.lastVelocity = velocity;
|
|
}
|
|
|
|
/**
|
|
* Applies a smoothing algorithm to the signature points.
|
|
*/
|
|
private smoothSignature(points: Point[]): Point[] {
|
|
const smoothedPoints: Point[] = [];
|
|
|
|
const startPoint = points[0];
|
|
const endPoint = points[points.length - 1];
|
|
|
|
smoothedPoints.push(startPoint);
|
|
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const p0 = i > 0 ? points[i - 1] : startPoint;
|
|
const p1 = points[i];
|
|
const p2 = points[i + 1];
|
|
const p3 = i < points.length - 2 ? points[i + 2] : endPoint;
|
|
|
|
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
|
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
|
|
|
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
|
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
|
|
|
smoothedPoints.push(new Point(cp1x, cp1y));
|
|
smoothedPoints.push(new Point(cp2x, cp2y));
|
|
smoothedPoints.push(p2);
|
|
}
|
|
|
|
return smoothedPoints;
|
|
}
|
|
|
|
/**
|
|
* Draws the smoothed signature on the canvas.
|
|
*/
|
|
private drawSmoothSignature(points: Point[], width: number): void {
|
|
const ctx = this.getContext();
|
|
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
|
|
ctx.beginPath();
|
|
|
|
const startPoint = points[0];
|
|
|
|
ctx.moveTo(startPoint.x, startPoint.y);
|
|
|
|
ctx.lineWidth = width;
|
|
|
|
for (let i = 1; i < points.length; i += 3) {
|
|
const cp1 = points[i];
|
|
const cp2 = points[i + 1];
|
|
const endPoint = points[i + 2];
|
|
|
|
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, endPoint.x, endPoint.y);
|
|
}
|
|
|
|
ctx.stroke();
|
|
ctx.closePath();
|
|
}
|
|
|
|
/**
|
|
* Calculates the stroke width based on the velocity.
|
|
*/
|
|
private strokeWidth(velocity: number): number {
|
|
return Math.max(this.maxStrokeWidth() / (velocity + 1), this.minStrokeWidth());
|
|
}
|
|
|
|
public registerOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
|
|
this.onChangeHandlers.push(handler);
|
|
}
|
|
|
|
public unregisterOnChangeHandler(handler: (_canvas: Canvas, _cleared: boolean) => void): void {
|
|
this.onChangeHandlers = this.onChangeHandlers.filter((l) => l !== handler);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the signature as a data URL.
|
|
*/
|
|
public toDataURL(type?: string, quality?: number): string {
|
|
return this.$canvas.toDataURL(type, quality);
|
|
}
|
|
|
|
/**
|
|
* Clears the signature from the canvas.
|
|
*/
|
|
public clear(): void {
|
|
const ctx = this.getContext();
|
|
|
|
ctx.clearRect(0, 0, this.currentCanvasWidth, this.currentCanvasHeight);
|
|
|
|
this.onChangeHandlers.forEach((handler) => handler(this, true));
|
|
|
|
this.points = [];
|
|
}
|
|
|
|
/**
|
|
* Retrieves the signature as an image blob.
|
|
*/
|
|
public async toBlob(type?: string, quality?: number): Promise<Blob> {
|
|
const promise = new Promise<Blob>((resolve, reject) => {
|
|
this.$canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('Could not convert canvas to blob.'));
|
|
return;
|
|
}
|
|
|
|
resolve(blob);
|
|
},
|
|
type,
|
|
quality,
|
|
);
|
|
});
|
|
|
|
return await promise;
|
|
}
|
|
}
|