mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
refactor: extract common components into UI package
This commit is contained in:
@ -7,7 +7,8 @@
|
||||
"dev": "PORT=3001 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@ -37,4 +38,4 @@
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
56611
apps/marketing/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -21,12 +21,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
|
||||
@ -1,321 +0,0 @@
|
||||
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 = 2;
|
||||
|
||||
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 toBlob(type?: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.$canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Could not convert canvas to blob.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
},
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
export const average = (a: number, b: number) => (a + b) / 2;
|
||||
|
||||
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(
|
||||
2,
|
||||
)} ${average(b[0], c[0]).toFixed(2)},${average(b[1], c[1]).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += 'Z';
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from './signature-pad';
|
||||
@ -1,98 +0,0 @@
|
||||
import {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
|
||||
export type PointLike = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const isTouchEvent = (
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
): event is TouchEvent | ReactTouchEvent => {
|
||||
return 'touches' in event;
|
||||
};
|
||||
|
||||
export class Point implements PointLike {
|
||||
public x: number;
|
||||
public y: number;
|
||||
public timestamp: number;
|
||||
|
||||
constructor(x: number, y: number, timestamp?: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.timestamp = timestamp ?? Date.now();
|
||||
}
|
||||
|
||||
public distanceTo(point: PointLike): number {
|
||||
return Math.sqrt(Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2));
|
||||
}
|
||||
|
||||
public equals(point: PointLike): boolean {
|
||||
return this.x === point.x && this.y === point.y && this.timestamp === point.timestamp;
|
||||
}
|
||||
|
||||
public velocityFrom(start: PointLike): number {
|
||||
const timeDifference = this.timestamp - start.timestamp;
|
||||
|
||||
if (timeDifference !== 0) {
|
||||
return this.distanceTo(start) / timeDifference;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static fromPointLike({ x, y, timestamp }: PointLike): Point {
|
||||
return new Point(x, y, timestamp);
|
||||
}
|
||||
|
||||
public static fromEvent(
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
dpi = 1,
|
||||
el?: HTMLElement | null,
|
||||
): Point {
|
||||
const target = el ?? event.target;
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error('Event target is not an HTMLElement.');
|
||||
}
|
||||
|
||||
const { top, bottom, left, right } = target.getBoundingClientRect();
|
||||
|
||||
let clientX, clientY;
|
||||
|
||||
if (isTouchEvent(event)) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
}
|
||||
|
||||
// create a new point snapping to the edge of the current target element if it exceeds
|
||||
// the bounding box of the target element
|
||||
let x = Math.min(Math.max(left, clientX), right) - left;
|
||||
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
||||
|
||||
// adjust for DPI
|
||||
x *= dpi;
|
||||
y *= dpi;
|
||||
|
||||
return new Point(x, y);
|
||||
}
|
||||
}
|
||||
@ -1,212 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
TouchEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
|
||||
const DPI = 2;
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
const perfectFreehandOptions = useMemo(() => {
|
||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||
|
||||
return {
|
||||
size,
|
||||
thinning: 0.25,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
end: {
|
||||
taper: size * 2,
|
||||
},
|
||||
} satisfies StrokeOptions;
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(true);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.save();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points];
|
||||
|
||||
if (addPoint) {
|
||||
newPoints.push(point);
|
||||
|
||||
setPoints(newPoints);
|
||||
}
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if ('buttons' in event && event.buttons === 1) {
|
||||
onMouseDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onMouseUp(event, false);
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * DPI;
|
||||
$el.current.height = $el.current.clientHeight * DPI;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('relative block', className)}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<button className="rounded-full p-2 text-xs text-slate-500" onClick={() => onClearClick()}>
|
||||
Clear Signature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -9,16 +9,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"contentlayer/generated": [
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"types": [
|
||||
"@documenso/lib/types/next-auth.d.ts"
|
||||
],
|
||||
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||
"strictNullChecks": true,
|
||||
"incremental": false
|
||||
},
|
||||
@ -29,7 +23,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"dev": "PORT=3000 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@ -34,7 +35,6 @@
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-pdf": "^7.1.1",
|
||||
"react-rnd": "^10.4.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.1.6",
|
||||
|
||||
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
56611
apps/web/public/pdf.worker.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
|
||||
@ -2,14 +2,23 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
|
||||
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
|
||||
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
|
||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
@ -26,6 +35,9 @@ export const EditDocumentForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
@ -50,6 +62,76 @@ export const EditDocumentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onNextStep();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while sending the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -69,6 +151,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -79,6 +162,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -89,6 +173,7 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
onContinue={onNextStep}
|
||||
onGoBack={onPreviousStep}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,34 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
export type LoadablePDFCard = PDFViewerProps & {
|
||||
className?: string;
|
||||
pdfClassName?: string;
|
||||
};
|
||||
|
||||
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
|
||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||
return (
|
||||
<Card className={className} gradient {...props}>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer className={pdfClassName} {...props} />
|
||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -12,8 +12,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@ -9,9 +10,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
|
||||
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { SigningForm } from './form';
|
||||
|
||||
@ -12,10 +12,9 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SignaturePad } from '~/components/signature-pad';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
const DocumentDropzoneContainerVariants: Variants = {
|
||||
initial: {
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
scale: 1,
|
||||
},
|
||||
hover: {
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DocumentDropzoneCardLeftVariants: Variants = {
|
||||
initial: {
|
||||
x: 40,
|
||||
y: -10,
|
||||
rotate: -14,
|
||||
},
|
||||
animate: {
|
||||
x: 40,
|
||||
y: -10,
|
||||
rotate: -14,
|
||||
},
|
||||
hover: {
|
||||
x: -25,
|
||||
y: -25,
|
||||
rotate: -22,
|
||||
},
|
||||
};
|
||||
|
||||
const DocumentDropzoneCardRightVariants: Variants = {
|
||||
initial: {
|
||||
x: -40,
|
||||
y: -10,
|
||||
rotate: 14,
|
||||
},
|
||||
animate: {
|
||||
x: -40,
|
||||
y: -10,
|
||||
rotate: 14,
|
||||
},
|
||||
hover: {
|
||||
x: 25,
|
||||
y: -25,
|
||||
rotate: 22,
|
||||
},
|
||||
};
|
||||
|
||||
const DocumentDropzoneCardCenterVariants: Variants = {
|
||||
initial: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
animate: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
hover: {
|
||||
x: 0,
|
||||
y: -25,
|
||||
},
|
||||
};
|
||||
|
||||
export type DocumentDropzoneProps = {
|
||||
className: string;
|
||||
onDrop?: (_file: File) => void | Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => {
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
multiple: false,
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile && onDrop) {
|
||||
onDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn('flex', className)}
|
||||
variants={DocumentDropzoneContainerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
whileHover="hover"
|
||||
>
|
||||
<Card
|
||||
role="button"
|
||||
className={cn(
|
||||
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
gradient={true}
|
||||
degrees={120}
|
||||
{...getRootProps()}
|
||||
{...props}
|
||||
>
|
||||
<CardContent className="text-muted-foreground/40 flex flex-col items-center justify-center p-6">
|
||||
{/* <FilePlus strokeWidth="1px" className="h-16 w-16"/> */}
|
||||
<div className="flex">
|
||||
<motion.div
|
||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||
variants={DocumentDropzoneCardLeftVariants}
|
||||
>
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-20 flex aspect-[3/4] w-24 flex-col items-center justify-center gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||
variants={DocumentDropzoneCardCenterVariants}
|
||||
>
|
||||
<Plus
|
||||
strokeWidth="2px"
|
||||
className="text-muted-foreground/20 group-hover:text-documenso h-12 w-12"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left rotate-[22deg] flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"
|
||||
variants={DocumentDropzoneCardRightVariants}
|
||||
>
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<p className="group-hover:text-foreground text-muted-foreground mt-8 font-medium">
|
||||
Add a document
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground/80 mt-1 text-sm ">Drag & drop your document here.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
export const LazyPDFViewer = dynamic(
|
||||
async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
type LoadedPDFDocument = pdfjs.PDFDocumentProxy;
|
||||
|
||||
/**
|
||||
* This imports the worker from the `pdfjs-dist` package.
|
||||
*/
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.js',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
export type OnPDFViewerPageClick = (_event: {
|
||||
pageNumber: number;
|
||||
numPages: number;
|
||||
originalEvent: React.MouseEvent<HTMLDivElement, MouseEvent>;
|
||||
pageHeight: number;
|
||||
pageWidth: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
document: string;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const PDFViewer = ({ className, document, onPageClick, ...props }: PDFViewerProps) => {
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
|
||||
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
|
||||
setNumPages(doc.numPages);
|
||||
};
|
||||
|
||||
const onDocumentPageClick = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
pageNumber: number,
|
||||
) => {
|
||||
const $el = event.target instanceof HTMLElement ? event.target : null;
|
||||
|
||||
if (!$el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = $el.closest('.react-pdf__Page');
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width, top, left } = $page.getBoundingClientRect();
|
||||
|
||||
const pageX = event.clientX - left;
|
||||
const pageY = event.clientY - top;
|
||||
|
||||
if (onPageClick) {
|
||||
onPageClick({
|
||||
pageNumber,
|
||||
numPages,
|
||||
originalEvent: event,
|
||||
pageHeight: height,
|
||||
pageWidth: width,
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
const $current = $el.current;
|
||||
|
||||
const { width } = $current.getBoundingClientRect();
|
||||
|
||||
setWidth(width);
|
||||
|
||||
const onResize = () => {
|
||||
const { width } = $current.getBoundingClientRect();
|
||||
|
||||
setWidth(width);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
|
||||
<PDFDocument
|
||||
file={document}
|
||||
className="w-full overflow-hidden rounded"
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PDFViewer;
|
||||
@ -1,2 +0,0 @@
|
||||
export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document';
|
||||
export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page';
|
||||
@ -21,12 +21,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { claimPlan } from '~/api/claim-plan/fetcher';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
const ZWidgetFormSchema = z
|
||||
.object({
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
|
||||
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||
|
||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -1,577 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { getBoundingClientRect } from '~/helpers/get-bounding-client-rect';
|
||||
|
||||
import { addFields } from './add-fields.action';
|
||||
import { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import {
|
||||
EditDocumentFormContainer,
|
||||
EditDocumentFormContainerActions,
|
||||
EditDocumentFormContainerContent,
|
||||
EditDocumentFormContainerFooter,
|
||||
EditDocumentFormContainerStep,
|
||||
} from './container';
|
||||
import { FieldItem } from './field-item';
|
||||
import { FRIENDLY_FIELD_TYPE } from './types';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||
const DEFAULT_WIDTH_PERCENT = 15;
|
||||
|
||||
const MIN_HEIGHT_PX = 60;
|
||||
const MIN_WIDTH_PX = 200;
|
||||
|
||||
export type AddFieldsFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
document: Document;
|
||||
onContinue?: () => void;
|
||||
onGoBack?: () => void;
|
||||
};
|
||||
|
||||
export const AddFieldsFormPartial = ({
|
||||
recipients,
|
||||
fields,
|
||||
document,
|
||||
onContinue,
|
||||
onGoBack,
|
||||
}: AddFieldsFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<TAddFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: fields.map((field) => ({
|
||||
nativeId: field.id,
|
||||
formId: `${field.id}-${field.documentId}`,
|
||||
pageNumber: field.page,
|
||||
type: field.type,
|
||||
pageX: Number(field.positionX),
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
update,
|
||||
fields: localFields,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'fields',
|
||||
});
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
|
||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const fieldBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a mouse event, find the nearest pdf page element.
|
||||
*/
|
||||
const getPage = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
|
||||
const $page =
|
||||
target.closest<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR) ??
|
||||
target.querySelector<HTMLElement>(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $page;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provided a page and a field, calculate the position of the field
|
||||
* as a percentage of the page width and height.
|
||||
*/
|
||||
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
|
||||
const {
|
||||
top: pageTop,
|
||||
left: pageLeft,
|
||||
height: pageHeight,
|
||||
width: pageWidth,
|
||||
} = getBoundingClientRect(page);
|
||||
|
||||
const {
|
||||
top: fieldTop,
|
||||
left: fieldLeft,
|
||||
height: fieldHeight,
|
||||
width: fieldWidth,
|
||||
} = getBoundingClientRect(field);
|
||||
|
||||
return {
|
||||
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
|
||||
y: ((fieldTop - pageTop) / pageHeight) * 100,
|
||||
width: (fieldWidth / pageWidth) * 100,
|
||||
height: (fieldHeight / pageHeight) * 100,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a mouse event, determine if the mouse is within the bounds of the
|
||||
* nearest pdf page element.
|
||||
*/
|
||||
const isWithinPageBounds = useCallback((event: MouseEvent) => {
|
||||
const $page = getPage(event);
|
||||
|
||||
if (!$page) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = $page.getBoundingClientRect();
|
||||
|
||||
if (event.clientY > top + height || event.clientY < top) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.clientX > left + width || event.clientX < left) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!isWithinPageBounds(event)) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVisible(true);
|
||||
setCoords({
|
||||
x: event.clientX - fieldBounds.current.width / 2,
|
||||
y: event.clientY - fieldBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedField || !selectedSigner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = getPage(event);
|
||||
|
||||
if (!$page || !isWithinPageBounds(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
append({
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
});
|
||||
|
||||
setVisible(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner],
|
||||
);
|
||||
|
||||
const onFieldResize = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = localFields[index];
|
||||
|
||||
const $page = window.document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
x: pageX,
|
||||
y: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
} = getFieldPosition($page, node);
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
},
|
||||
[localFields, update],
|
||||
);
|
||||
|
||||
const onFieldMove = useCallback(
|
||||
(node: HTMLElement, index: number) => {
|
||||
const field = localFields[index];
|
||||
|
||||
const $page = window.document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x: pageX, y: pageY } = getFieldPosition($page, node);
|
||||
|
||||
update(index, {
|
||||
...field,
|
||||
pageX,
|
||||
pageY,
|
||||
});
|
||||
},
|
||||
[localFields, update],
|
||||
);
|
||||
|
||||
const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onContinue?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('click', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('click', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
useEffect(() => {
|
||||
const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||
}, [recipients]);
|
||||
|
||||
return (
|
||||
<EditDocumentFormContainer>
|
||||
<EditDocumentFormContainerContent
|
||||
title="Add Fields"
|
||||
description="Add all relevant fields for each recipient."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && visible && (
|
||||
<Card
|
||||
className="border-primary pointer-events-none fixed z-50 cursor-pointer bg-white"
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<FieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={visible && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="bg-background text-muted-foreground justify-between font-normal"
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.email} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput />
|
||||
<CommandEmpty />
|
||||
|
||||
<CommandGroup>
|
||||
{recipients.map((recipient, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
className={cn({
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
})}
|
||||
onSelect={() => setSelectedSigner(recipient)}
|
||||
>
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
This document has already been sent to this recipient. You can no longer
|
||||
edit this recipient.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{recipient.name && (
|
||||
<span className="truncate" title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span className="truncate" title={recipient.email}>
|
||||
{recipient.email}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="-mx-2 mt-8 flex-1 overflow-y-scroll px-2">
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.name || 'Signature'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Email'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Name'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Date'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditDocumentFormContainerContent>
|
||||
|
||||
<EditDocumentFormContainerFooter>
|
||||
<EditDocumentFormContainerStep title="Add Fields" step={2} maxStep={3} />
|
||||
|
||||
<EditDocumentFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoNextClick={() => onFormSubmit()}
|
||||
onGoBackClick={onGoBack}
|
||||
/>
|
||||
</EditDocumentFormContainerFooter>
|
||||
</EditDocumentFormContainer>
|
||||
);
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddFieldsFormSchema = z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TAddFieldsFormSchema = z.infer<typeof ZAddFieldsFormSchema>;
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
import { TAddSignersFormSchema } from './add-signers.types';
|
||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||
|
||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -1,246 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useId } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '~/components/form/form-error-message';
|
||||
|
||||
import { addSigners } from './add-signers.action';
|
||||
import { TAddSignersFormSchema } from './add-signers.types';
|
||||
import {
|
||||
EditDocumentFormContainer,
|
||||
EditDocumentFormContainerActions,
|
||||
EditDocumentFormContainerContent,
|
||||
EditDocumentFormContainerFooter,
|
||||
EditDocumentFormContainerStep,
|
||||
} from './container';
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
document: Document;
|
||||
onContinue?: () => void;
|
||||
onGoBack?: () => void;
|
||||
};
|
||||
|
||||
export const AddSignersFormPartial = ({
|
||||
recipients,
|
||||
fields: _fields,
|
||||
document: document,
|
||||
onContinue,
|
||||
onGoBack,
|
||||
}: AddSignersFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const initialId = useId();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddSignersFormSchema>({
|
||||
defaultValues: {
|
||||
signers:
|
||||
recipients.length > 0
|
||||
? recipients.map((recipient) => ({
|
||||
nativeId: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
append: appendSigner,
|
||||
fields: signers,
|
||||
remove: removeSigner,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const hasBeenSentToRecipientId = (id?: number) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return recipients.some(
|
||||
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
|
||||
);
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
if (hasBeenSentToRecipientId(signer.nativeId)) {
|
||||
toast({
|
||||
title: 'Cannot remove signer',
|
||||
description: 'This signer has already received the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
removeSigner(index);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddSigner();
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = handleSubmit(async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
signers: data.signers,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onContinue?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<EditDocumentFormContainer onSubmit={onFormSubmit}>
|
||||
<EditDocumentFormContainerContent
|
||||
title="Add Signers"
|
||||
description="Add the people who will sign the document."
|
||||
>
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
<AnimatePresence>
|
||||
{signers.map((signer, index) => (
|
||||
<motion.div
|
||||
key={signer.formId}
|
||||
data-native-id={signer.nativeId}
|
||||
className="flex flex-wrap items-end gap-x-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={`signer-${signer.formId}-email`}>
|
||||
Email
|
||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||
</Label>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`signers.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`signer-${signer.formId}-email`}
|
||||
type="email"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={`signer-${signer.formId}-name`}>Name</Label>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name={`signers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id={`signer-${signer.formId}-name`}
|
||||
type="text"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers.length === 1
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
|
||||
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.signers} />
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
</div>
|
||||
</EditDocumentFormContainerContent>
|
||||
|
||||
<EditDocumentFormContainerFooter>
|
||||
<EditDocumentFormContainerStep title="Add Signers" step={1} maxStep={3} />
|
||||
|
||||
<EditDocumentFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoNextClick={() => onFormSubmit()}
|
||||
onGoBackClick={onGoBack}
|
||||
/>
|
||||
</EditDocumentFormContainerFooter>
|
||||
</EditDocumentFormContainer>
|
||||
);
|
||||
};
|
||||
@ -1,19 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddSignersFormSchema = z.object({
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
});
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
|
||||
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||
|
||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||
documentId: number;
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '~/components/form/form-error-message';
|
||||
|
||||
import { completeDocument } from './add-subject.action';
|
||||
import { TAddSubjectFormSchema } from './add-subject.types';
|
||||
import {
|
||||
EditDocumentFormContainer,
|
||||
EditDocumentFormContainerActions,
|
||||
EditDocumentFormContainerContent,
|
||||
EditDocumentFormContainerFooter,
|
||||
EditDocumentFormContainerStep,
|
||||
} from './container';
|
||||
|
||||
export type AddSubjectFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
document: Document;
|
||||
onContinue?: () => void;
|
||||
onGoBack?: () => void;
|
||||
};
|
||||
|
||||
export const AddSubjectFormPartial = ({
|
||||
recipients: _recipients,
|
||||
fields: _fields,
|
||||
document,
|
||||
onContinue,
|
||||
onGoBack,
|
||||
}: AddSubjectFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddSubjectFormSchema>({
|
||||
defaultValues: {
|
||||
email: {
|
||||
subject: '',
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await completeDocument({
|
||||
documentId: document.id,
|
||||
email: {
|
||||
subject,
|
||||
message,
|
||||
},
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
|
||||
onContinue?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while sending the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<EditDocumentFormContainer>
|
||||
<EditDocumentFormContainerContent
|
||||
title="Add Subject"
|
||||
description="Add the subject and message you wish to send to signers."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">
|
||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="subject"
|
||||
// placeholder="Subject"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...register('email.subject')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.email?.subject} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message">
|
||||
Message <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
|
||||
<Textarea
|
||||
id="message"
|
||||
className="bg-background mt-2 h-32 resize-none"
|
||||
disabled={isSubmitting}
|
||||
{...register('email.message')}
|
||||
/>
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
error={
|
||||
typeof errors.email?.message !== 'string' ? errors.email?.message : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You can use the following variables in your message:
|
||||
</p>
|
||||
|
||||
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
|
||||
<li className="text-muted-foreground">
|
||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||
{'{signer.name}'}
|
||||
</code>{' '}
|
||||
- The signer's name
|
||||
</li>
|
||||
<li className="text-muted-foreground">
|
||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||
{'{signer.email}'}
|
||||
</code>{' '}
|
||||
- The signer's email
|
||||
</li>
|
||||
<li className="text-muted-foreground">
|
||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||
{'{document.name}'}
|
||||
</code>{' '}
|
||||
- The document's name
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditDocumentFormContainerContent>
|
||||
|
||||
<EditDocumentFormContainerFooter>
|
||||
<EditDocumentFormContainerStep title="Add Subject" step={3} maxStep={3} />
|
||||
|
||||
<EditDocumentFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
goNextLabel={document.status === DocumentStatus.DRAFT ? 'Send' : 'Update'}
|
||||
onGoNextClick={() => onFormSubmit()}
|
||||
onGoBackClick={onGoBack}
|
||||
/>
|
||||
</EditDocumentFormContainerFooter>
|
||||
</EditDocumentFormContainer>
|
||||
);
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZAddSubjectFormSchema = z.object({
|
||||
email: z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TAddSubjectFormSchema = z.infer<typeof ZAddSubjectFormSchema>;
|
||||
@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type EditDocumentFormContainerProps = HTMLAttributes<HTMLFormElement> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EditDocumentFormContainer = ({
|
||||
children,
|
||||
id = 'edit-document-form',
|
||||
className,
|
||||
...props
|
||||
}: EditDocumentFormContainerProps) => {
|
||||
return (
|
||||
<form
|
||||
id={id}
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>{children}</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export type EditDocumentFormContainerContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
title: string;
|
||||
description: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EditDocumentFormContainerContent = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
...props
|
||||
}: EditDocumentFormContainerContentProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-1 flex-col', className)} {...props}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">{title}</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EditDocumentFormContainerFooterProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EditDocumentFormContainerFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: EditDocumentFormContainerFooterProps) => {
|
||||
return (
|
||||
<div className={cn('mt-4 flex-shrink-0', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EditDocumentFormContainerStepProps = {
|
||||
title: string;
|
||||
step: number;
|
||||
maxStep: number;
|
||||
};
|
||||
|
||||
export const EditDocumentFormContainerStep = ({
|
||||
title,
|
||||
step,
|
||||
maxStep,
|
||||
}: EditDocumentFormContainerStepProps) => {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{title}{' '}
|
||||
<span>
|
||||
({step}/{maxStep})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
|
||||
<div
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${(100 / maxStep) * step}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EditDocumentFormContainerActionsProps = {
|
||||
canGoBack?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goNextLabel?: string;
|
||||
goBackLabel?: string;
|
||||
onGoBackClick?: () => void;
|
||||
onGoNextClick?: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const EditDocumentFormContainerActions = ({
|
||||
canGoBack = true,
|
||||
canGoNext = true,
|
||||
goNextLabel = 'Continue',
|
||||
goBackLabel = 'Go Back',
|
||||
onGoBackClick,
|
||||
onGoNextClick,
|
||||
loading,
|
||||
disabled,
|
||||
}: EditDocumentFormContainerActionsProps) => {
|
||||
return (
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={disabled || loading || !canGoBack}
|
||||
onClick={onGoBackClick}
|
||||
>
|
||||
{goBackLabel}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-documenso flex-1"
|
||||
size="lg"
|
||||
disabled={disabled || loading || !canGoNext}
|
||||
onClick={onGoNextClick}
|
||||
>
|
||||
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
{goNextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,152 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Trash } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
|
||||
import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types';
|
||||
|
||||
type Field = TEditDocumentFormSchema['fields'][0];
|
||||
|
||||
export type FieldItemProps = {
|
||||
field: Field;
|
||||
passive?: boolean;
|
||||
disabled?: boolean;
|
||||
minHeight?: number;
|
||||
minWidth?: number;
|
||||
onResize?: (_node: HTMLElement) => void;
|
||||
onMove?: (_node: HTMLElement) => void;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export const FieldItem = ({
|
||||
field,
|
||||
passive,
|
||||
disabled,
|
||||
minHeight: _minHeight,
|
||||
minWidth: _minWidth,
|
||||
onResize,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: FieldItemProps) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
pageHeight: 0,
|
||||
pageWidth: 0,
|
||||
});
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
|
||||
);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
const top = $page.getBoundingClientRect().top + window.scrollY;
|
||||
const left = $page.getBoundingClientRect().left + window.scrollX;
|
||||
|
||||
// X and Y are percentages of the page's height and width
|
||||
const pageX = (field.pageX / 100) * width + left;
|
||||
const pageY = (field.pageY / 100) * height + top;
|
||||
|
||||
const pageHeight = (field.pageHeight / 100) * height;
|
||||
const pageWidth = (field.pageWidth / 100) * width;
|
||||
|
||||
setCoords({
|
||||
pageX: pageX,
|
||||
pageY: pageY,
|
||||
pageHeight: pageHeight,
|
||||
pageWidth: pageWidth,
|
||||
});
|
||||
}, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateCoords();
|
||||
}, [calculateCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
calculateCoords();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
return createPortal(
|
||||
<Rnd
|
||||
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
||||
className={cn('absolute z-20', {
|
||||
'pointer-events-none': passive,
|
||||
'pointer-events-none z-10 opacity-75': disabled,
|
||||
})}
|
||||
// minHeight={minHeight}
|
||||
// minWidth={minWidth}
|
||||
default={{
|
||||
x: coords.pageX,
|
||||
y: coords.pageY,
|
||||
height: coords.pageHeight,
|
||||
width: coords.pageWidth,
|
||||
}}
|
||||
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
|
||||
onDragStart={() => setActive(true)}
|
||||
onResizeStart={() => setActive(true)}
|
||||
onResizeStop={(_e, _d, ref) => {
|
||||
setActive(false);
|
||||
onResize?.(ref);
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
setActive(false);
|
||||
onMove?.(d.node);
|
||||
}}
|
||||
>
|
||||
{!disabled && (
|
||||
<button
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground/80 absolute -right-2 -top-2 z-[9999] flex h-8 w-8 items-center justify-center rounded-full border bg-white shadow-[0_0_0_2px_theme(colors.gray.100/70%)]"
|
||||
onClick={() => onRemove?.()}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={cn('h-full w-full bg-white', {
|
||||
'border-primary': !disabled,
|
||||
'border-primary/80': active,
|
||||
})}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
'text-foreground flex h-full w-full flex-col items-center justify-center p-2',
|
||||
{
|
||||
'text-muted-foreground/50': disabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{FRIENDLY_FIELD_TYPE[field.type]}
|
||||
|
||||
<p className="text-muted-foreground/50 w-full truncate text-center text-xs">
|
||||
{field.signerEmail}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Rnd>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@ -1,64 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Button, ButtonProps } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
export type SendDocumentActionDialogProps = ButtonProps & {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const SendDocumentActionDialog = ({
|
||||
loading,
|
||||
className,
|
||||
...props
|
||||
}: SendDocumentActionDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" className={className}>
|
||||
{loading && <Loader className="text-documenso mr-2 h-5 w-5 animate-spin" />}
|
||||
Send
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-lg font-semibold">Send Document</DialogTitle>
|
||||
<DialogDescription className="text-center text-base">
|
||||
You are about to send this document to the recipients. Are you sure you want to
|
||||
continue?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-4 flex items-center gap-x-4">
|
||||
<Button
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 dark:focus-visible:ring-muted/80 flex-1 border-none bg-black/5 hover:bg-black/10 focus-visible:ring-black/10"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{/* We would use DialogAction here but it interrupts the submit action */}
|
||||
<Button className={className} {...props}>
|
||||
{loading && <Loader className="mr-2 h-5 w-5 animate-spin" />}
|
||||
Send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZEditDocumentFormSchema = z.object({
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
|
||||
fields: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
|
||||
email: z.object({
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type TEditDocumentFormSchema = z.infer<typeof ZEditDocumentFormSchema>;
|
||||
|
||||
export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
||||
[FieldType.SIGNATURE]: 'Signature',
|
||||
[FieldType.FREE_SIGNATURE]: 'Free Signature',
|
||||
[FieldType.TEXT]: 'Text',
|
||||
[FieldType.DATE]: 'Date',
|
||||
[FieldType.EMAIL]: 'Email',
|
||||
[FieldType.NAME]: 'Name',
|
||||
};
|
||||
@ -14,10 +14,10 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
|
||||
@ -12,10 +12,9 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SignaturePad } from '../signature-pad';
|
||||
|
||||
export const ZSignUpFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
|
||||
@ -1,321 +0,0 @@
|
||||
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 = 2;
|
||||
|
||||
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 toBlob(type?: string, quality?: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.$canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Could not convert canvas to blob.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
},
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
export const average = (a: number, b: number) => (a + b) / 2;
|
||||
|
||||
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(
|
||||
2,
|
||||
)} ${average(b[0], c[0]).toFixed(2)},${average(b[1], c[1]).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += 'Z';
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from './signature-pad';
|
||||
@ -1,98 +0,0 @@
|
||||
import {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
|
||||
export type PointLike = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const isTouchEvent = (
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
): event is TouchEvent | ReactTouchEvent => {
|
||||
return 'touches' in event;
|
||||
};
|
||||
|
||||
export class Point implements PointLike {
|
||||
public x: number;
|
||||
public y: number;
|
||||
public timestamp: number;
|
||||
|
||||
constructor(x: number, y: number, timestamp?: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.timestamp = timestamp ?? Date.now();
|
||||
}
|
||||
|
||||
public distanceTo(point: PointLike): number {
|
||||
return Math.sqrt(Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2));
|
||||
}
|
||||
|
||||
public equals(point: PointLike): boolean {
|
||||
return this.x === point.x && this.y === point.y && this.timestamp === point.timestamp;
|
||||
}
|
||||
|
||||
public velocityFrom(start: PointLike): number {
|
||||
const timeDifference = this.timestamp - start.timestamp;
|
||||
|
||||
if (timeDifference !== 0) {
|
||||
return this.distanceTo(start) / timeDifference;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static fromPointLike({ x, y, timestamp }: PointLike): Point {
|
||||
return new Point(x, y, timestamp);
|
||||
}
|
||||
|
||||
public static fromEvent(
|
||||
event:
|
||||
| ReactMouseEvent
|
||||
| ReactPointerEvent
|
||||
| ReactTouchEvent
|
||||
| MouseEvent
|
||||
| PointerEvent
|
||||
| TouchEvent,
|
||||
dpi = 1,
|
||||
el?: HTMLElement | null,
|
||||
): Point {
|
||||
const target = el ?? event.target;
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
throw new Error('Event target is not an HTMLElement.');
|
||||
}
|
||||
|
||||
const { top, bottom, left, right } = target.getBoundingClientRect();
|
||||
|
||||
let clientX, clientY;
|
||||
|
||||
if (isTouchEvent(event)) {
|
||||
clientX = event.touches[0].clientX;
|
||||
clientY = event.touches[0].clientY;
|
||||
} else {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
}
|
||||
|
||||
// create a new point snapping to the edge of the current target element if it exceeds
|
||||
// the bounding box of the target element
|
||||
let x = Math.min(Math.max(left, clientX), right) - left;
|
||||
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
||||
|
||||
// adjust for DPI
|
||||
x *= dpi;
|
||||
y *= dpi;
|
||||
|
||||
return new Point(x, y);
|
||||
}
|
||||
}
|
||||
@ -1,238 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
HTMLAttributes,
|
||||
MouseEvent,
|
||||
PointerEvent,
|
||||
TouchEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { StrokeOptions, getStroke } from 'perfect-freehand';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
|
||||
const DPI = 2;
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({
|
||||
className,
|
||||
defaultValue,
|
||||
onChange,
|
||||
...props
|
||||
}: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
const perfectFreehandOptions = useMemo(() => {
|
||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||
|
||||
return {
|
||||
size,
|
||||
thinning: 0.25,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
end: {
|
||||
taper: size * 2,
|
||||
},
|
||||
} satisfies StrokeOptions;
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(true);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.save();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
if (point.distanceTo(points[points.length - 1]) > 5) {
|
||||
const newPoints = [...points, point];
|
||||
|
||||
setPoints(newPoints);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newPoints = [...points];
|
||||
|
||||
if (addPoint) {
|
||||
newPoints.push(point);
|
||||
|
||||
setPoints(newPoints);
|
||||
}
|
||||
|
||||
if ($el.current && newPoints.length > 0) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if ('buttons' in event && event.buttons === 1) {
|
||||
onMouseDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
onMouseUp(event, false);
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
|
||||
setPoints([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * DPI;
|
||||
$el.current.height = $el.current.clientHeight * DPI;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ defaultValue });
|
||||
if ($el.current && typeof defaultValue === 'string') {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
const { width, height } = $el.current;
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
|
||||
};
|
||||
|
||||
img.src = defaultValue;
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('relative block dark:invert', className)}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-full p-0 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => onClearClick()}
|
||||
>
|
||||
Clear Signature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
export const getBoundingClientRect = (element: HTMLElement) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
const { width, height } = rect;
|
||||
|
||||
const top = rect.top + window.scrollY;
|
||||
const left = rect.left + window.scrollX;
|
||||
|
||||
return { top, left, width, height };
|
||||
};
|
||||
@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
|
||||
import { getBoundingClientRect } from '~/helpers/get-bounding-client-rect';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
|
||||
@ -9,16 +9,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"contentlayer/generated": [
|
||||
"./.contentlayer/generated"
|
||||
]
|
||||
"~/*": ["./src/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
},
|
||||
"types": [
|
||||
"@documenso/lib/types/next-auth.d.ts"
|
||||
],
|
||||
"types": ["@documenso/lib/types/next-auth.d.ts"],
|
||||
"strictNullChecks": true,
|
||||
"incremental": false
|
||||
},
|
||||
@ -30,7 +24,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".contentlayer/generated"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user