mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add signature configurations (#1710)
Add ability to enable or disable allowed signature types: - Drawn - Typed - Uploaded **Tabbed style signature dialog**  **Document settings**  **Team preferences**  - Add multiselect to select allowed signatures in document and templates settings tab - Add multiselect to select allowed signatures in teams preferences - Removed "Enable typed signatures" from document/template edit page - Refactored signature pad to use tabs instead of an all in one signature pad Added E2E tests to check settings are applied correctly for documents and templates
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||
|
||||
import { Point } from './point';
|
||||
|
||||
export class Canvas {
|
||||
@ -14,7 +16,7 @@ export class Canvas {
|
||||
private lastVelocity = 0;
|
||||
|
||||
private readonly VELOCITY_FILTER_WEIGHT = 0.5;
|
||||
private readonly DPI = 2;
|
||||
private readonly DPI = SIGNATURE_CANVAS_DPI;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.$canvas = canvas;
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type SignaturePadColorPickerProps = {
|
||||
selectedColor: string;
|
||||
setSelectedColor: (color: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SignaturePadColorPicker = ({
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
className,
|
||||
}: SignaturePadColorPickerProps) => {
|
||||
return (
|
||||
<div className={cn('text-foreground absolute right-2 top-2 filter', className)}>
|
||||
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
|
||||
<SelectTrigger className="h-auto w-auto border-none p-0.5">
|
||||
<SelectValue placeholder="" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent className="w-[100px]" align="end">
|
||||
<SelectItem value="black">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
|
||||
<Trans>Black</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="red">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
|
||||
<Trans>Red</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="blue">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
|
||||
<Trans>Blue</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="green">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
|
||||
<Trans>Green</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
Normal file
150
packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../button';
|
||||
import { SignaturePad } from './signature-pad';
|
||||
import { SignatureRender } from './signature-render';
|
||||
|
||||
export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
onChange: (_value: string) => void;
|
||||
dialogConfirmText?: MessageDescriptor | string;
|
||||
disableAnimation?: boolean;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const SignaturePadDialog = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
disableAnimation = false,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
dialogConfirmText,
|
||||
}: SignaturePadDialogProps) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||
const [signature, setSignature] = useState<string>(value ?? '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'aspect-signature-pad bg-background relative block w-full select-none rounded-lg border',
|
||||
className,
|
||||
{
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{value && (
|
||||
<div className="inset-0 h-full w-full">
|
||||
<SignatureRender value={value} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
data-testid="signature-pad-dialog-button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="absolute inset-0 flex items-center justify-center bg-transparent"
|
||||
onClick={() => setShowSignatureModal(true)}
|
||||
whileHover="onHover"
|
||||
>
|
||||
{!value && !disableAnimation && (
|
||||
<motion.svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/60"
|
||||
variants={{
|
||||
onHover: {
|
||||
scale: 1.1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 12,
|
||||
mass: 0.8,
|
||||
restDelta: 0.001,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<motion.path
|
||||
d="M1.5 11H14.5M1.5 14C1.5 14 8.72 2 4.86938 2H4.875C2.01 2 1.97437 14.0694 8 6.51188V6.5C8 6.5 9 11.3631 11.5 7.52375V7.5C11.5 7.5 11.5 9 14.5 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
pathLength: {
|
||||
duration: 2,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
opacity: { duration: 0.6 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<Dialog open={showSignatureModal} onOpenChange={disabled ? undefined : setShowSignatureModal}>
|
||||
<DialogContent hideClose={true} className="p-6 pt-4">
|
||||
<SignaturePad
|
||||
id="signature"
|
||||
value={value}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onChange={({ value }) => setSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!signature}
|
||||
onClick={() => {
|
||||
onChange(signature);
|
||||
setShowSignatureModal(false);
|
||||
}}
|
||||
>
|
||||
{dialogConfirmText ? (
|
||||
parseMessageDescriptor(i18n._, dialogConfirmText)
|
||||
) : (
|
||||
<Trans>Next</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
327
packages/ui/primitives/signature-pad/signature-pad-draw.tsx
Normal file
327
packages/ui/primitives/signature-pad/signature-pad-draw.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import type { StrokeOptions } from 'perfect-freehand';
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import {
|
||||
SIGNATURE_CANVAS_DPI,
|
||||
SIGNATURE_MIN_COVERAGE_THRESHOLD,
|
||||
} from '@documenso/lib/constants/signatures';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
import { SignaturePadColorPicker } from './signature-pad-color-picker';
|
||||
|
||||
const checkSignatureValidity = (element: RefObject<HTMLCanvasElement>) => {
|
||||
if (!element.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctx = element.current.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, element.current.width, element.current.height);
|
||||
const data = imageData.data;
|
||||
let filledPixels = 0;
|
||||
const totalPixels = data.length / 4;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) filledPixels++;
|
||||
}
|
||||
|
||||
const filledPercentage = filledPixels / totalPixels;
|
||||
const isValid = filledPercentage > SIGNATURE_MIN_COVERAGE_THRESHOLD;
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
export type SignaturePadDrawProps = {
|
||||
className?: string;
|
||||
value: string;
|
||||
onChange: (_signatureDataUrl: string) => void;
|
||||
};
|
||||
|
||||
export const SignaturePadDraw = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: SignaturePadDrawProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
const $fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [lines, setLines] = useState<Point[][]>([]);
|
||||
const [currentLine, setCurrentLine] = useState<Point[]>([]);
|
||||
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(null);
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('black');
|
||||
|
||||
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, SIGNATURE_CANVAS_DPI, $el.current);
|
||||
|
||||
setCurrentLine([point]);
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current);
|
||||
const lastPoint = currentLine[currentLine.length - 1];
|
||||
|
||||
if (lastPoint && point.distanceTo(lastPoint) > 5) {
|
||||
setCurrentLine([...currentLine, point]);
|
||||
|
||||
// Update the canvas here to draw the lines
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.fillStyle = selectedColor;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
});
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
|
||||
);
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, SIGNATURE_CANVAS_DPI, $el.current);
|
||||
|
||||
const newLines = [...lines];
|
||||
|
||||
if (addLine && currentLine.length > 0) {
|
||||
newLines.push([...currentLine, point]);
|
||||
setCurrentLine([]);
|
||||
}
|
||||
|
||||
setLines(newLines);
|
||||
|
||||
if ($el.current && newLines.length > 0) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.fillStyle = selectedColor;
|
||||
|
||||
newLines.forEach((line) => {
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
|
||||
);
|
||||
ctx.fill(pathData);
|
||||
});
|
||||
|
||||
const isValidSignature = checkSignatureValidity($el);
|
||||
|
||||
setIsSignatureValid(isValidSignature);
|
||||
|
||||
if (isValidSignature) {
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
ctx.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (isPressed) {
|
||||
onMouseUp(event, true);
|
||||
} else {
|
||||
onMouseUp(event, false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
$imageData.current = null;
|
||||
}
|
||||
|
||||
if ($fileInput.current) {
|
||||
$fileInput.current.value = '';
|
||||
}
|
||||
|
||||
onChange('');
|
||||
|
||||
setLines([]);
|
||||
setCurrentLine([]);
|
||||
setIsPressed(false);
|
||||
};
|
||||
|
||||
const onUndoClick = () => {
|
||||
if (lines.length === 0 || !$el.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLines = lines.slice(0, -1);
|
||||
setLines(newLines);
|
||||
|
||||
// Clear and redraw the canvas
|
||||
const ctx = $el.current.getContext('2d');
|
||||
const { width, height } = $el.current;
|
||||
ctx?.clearRect(0, 0, width, height);
|
||||
|
||||
if ($imageData.current) {
|
||||
ctx?.putImageData($imageData.current, 0, 0);
|
||||
}
|
||||
|
||||
newLines.forEach((line) => {
|
||||
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
|
||||
ctx?.fill(pathData);
|
||||
});
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
|
||||
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
|
||||
}
|
||||
|
||||
if ($el.current && value) {
|
||||
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));
|
||||
|
||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
||||
|
||||
$imageData.current = defaultImageData;
|
||||
};
|
||||
|
||||
img.src = value;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', className)}>
|
||||
<canvas
|
||||
data-testid="signature-pad-draw"
|
||||
ref={$el}
|
||||
className={cn('h-full w-full', {
|
||||
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
|
||||
})}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} />
|
||||
|
||||
<div className="absolute bottom-3 right-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => onClearClick()}
|
||||
>
|
||||
<Trans>Clear Signature</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSignatureValid === false && (
|
||||
<div className="absolute bottom-4 left-4 flex gap-2">
|
||||
<span className="text-destructive text-xs">
|
||||
<Trans>Signature is too small</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSignatureValid && lines.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title="undo"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={onUndoClick}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
<span className="sr-only">Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
packages/ui/primitives/signature-pad/signature-pad-type.tsx
Normal file
29
packages/ui/primitives/signature-pad/signature-pad-type.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type SignaturePadTypeProps = {
|
||||
className?: string;
|
||||
value?: string;
|
||||
onChange: (_value: string) => void;
|
||||
};
|
||||
|
||||
export const SignaturePadType = ({ className, value, onChange }: SignaturePadTypeProps) => {
|
||||
// Colors don't actually work for text.
|
||||
const [selectedColor, setSelectedColor] = useState('black');
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full w-full items-center justify-center', className)}>
|
||||
<input
|
||||
data-testid="signature-pad-type-input"
|
||||
placeholder="Type your signature"
|
||||
className="font-signature w-full bg-transparent px-4 text-center text-7xl text-black placeholder:text-4xl focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-white"
|
||||
// style={{ color: selectedColor }}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value.trimStart())}
|
||||
/>
|
||||
|
||||
{/* <SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} /> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
166
packages/ui/primitives/signature-pad/signature-pad-upload.tsx
Normal file
166
packages/ui/primitives/signature-pad/signature-pad-upload.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { motion } from 'framer-motion';
|
||||
import { UploadCloudIcon } from 'lucide-react';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
|
||||
if (!file) {
|
||||
throw new Error('No file selected');
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image size should be less than 5MB');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = objectUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const loadImageOntoCanvas = (
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): ImageData => {
|
||||
const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height);
|
||||
|
||||
const x = (canvas.width - image.width * scale) / 2;
|
||||
const y = (canvas.height - image.height * scale) / 2;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
ctx.drawImage(image, x, y, image.width * scale, image.height * scale);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
return imageData;
|
||||
};
|
||||
|
||||
export type SignaturePadUploadProps = {
|
||||
className?: string;
|
||||
value: string;
|
||||
onChange: (_signatureDataUrl: string) => void;
|
||||
};
|
||||
|
||||
export const SignaturePadUpload = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: SignaturePadUploadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
const $fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
const img = await loadImage(event.target.files?.[0]);
|
||||
|
||||
if (!$el.current) return;
|
||||
|
||||
const ctx = $el.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
|
||||
onChange?.($el.current.toDataURL());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
// Todo: Not really sure if this is required for uploaded images.
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
|
||||
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
|
||||
}
|
||||
|
||||
if ($el.current && value) {
|
||||
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));
|
||||
|
||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
||||
|
||||
$imageData.current = defaultImageData;
|
||||
};
|
||||
|
||||
img.src = value;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('relative h-full w-full', className)}>
|
||||
<canvas
|
||||
data-testid="signature-pad-upload"
|
||||
ref={$el}
|
||||
className="h-full w-full dark:hue-rotate-180 dark:invert"
|
||||
style={{ touchAction: 'none' }}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={$fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
|
||||
<motion.button
|
||||
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
whileHover="hover"
|
||||
onClick={() => $fileInput.current?.click()}
|
||||
>
|
||||
{!value && (
|
||||
<motion.div>
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<UploadCloudIcon className="h-8 w-8" />
|
||||
<span className="text-lg font-semibold">
|
||||
<Trans>Upload Signature</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,591 +1,199 @@
|
||||
import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Undo2, Upload } from 'lucide-react';
|
||||
import type { StrokeOptions } from 'perfect-freehand';
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { Point } from './point';
|
||||
import { SignaturePadDraw } from './signature-pad-draw';
|
||||
import { SignaturePadType } from './signature-pad-type';
|
||||
import { SignaturePadUpload } from './signature-pad-upload';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
||||
|
||||
const DPI = 2;
|
||||
|
||||
const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||
|
||||
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
|
||||
if (!file) {
|
||||
throw new Error('No file selected');
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image size should be less than 5MB');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = objectUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const loadImageOntoCanvas = (
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): ImageData => {
|
||||
const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height);
|
||||
|
||||
const x = (canvas.width - image.width * scale) / 2;
|
||||
const y = (canvas.height - image.height * scale) / 2;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
ctx.drawImage(image, x, y, image.width * scale, image.height * scale);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
return imageData;
|
||||
export type SignaturePadValue = {
|
||||
type: DocumentSignatureType;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
containerClassName?: string;
|
||||
value?: string;
|
||||
onChange?: (_value: SignaturePadValue) => void;
|
||||
|
||||
disabled?: boolean;
|
||||
allowTypedSignature?: boolean;
|
||||
defaultValue?: string;
|
||||
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
minCoverageThreshold?: number;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
value = '',
|
||||
onChange,
|
||||
disabled = false,
|
||||
allowTypedSignature,
|
||||
onValidityChange,
|
||||
minCoverageThreshold = 0.01,
|
||||
...props
|
||||
typedSignatureEnabled = true,
|
||||
uploadSignatureEnabled = true,
|
||||
drawSignatureEnabled = true,
|
||||
}: SignaturePadProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
const $fileInput = useRef<HTMLInputElement>(null);
|
||||
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
||||
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [lines, setLines] = useState<Point[][]>([]);
|
||||
const [currentLine, setCurrentLine] = useState<Point[]>([]);
|
||||
const [selectedColor, setSelectedColor] = useState('black');
|
||||
const [typedSignature, setTypedSignature] = useState(
|
||||
defaultValue && !isBase64Image(defaultValue) ? defaultValue : '',
|
||||
/**
|
||||
* This is cooked.
|
||||
*
|
||||
* Get the first enabled tab that has a signature if possible, otherwise just get
|
||||
* the first enabled tab.
|
||||
*/
|
||||
const [tab, setTab] = useState(
|
||||
((): 'draw' | 'text' | 'image' => {
|
||||
// First passthrough to check to see if there's a signature for a given tab.
|
||||
if (drawSignatureEnabled && drawSignature) {
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
if (typedSignatureEnabled && typedSignature) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
if (uploadSignatureEnabled && imageSignature) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// Second passthrough to just select the first avaliable tab.
|
||||
if (drawSignatureEnabled) {
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
if (typedSignatureEnabled) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
if (uploadSignatureEnabled) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
throw new Error('No signature enabled');
|
||||
})(),
|
||||
);
|
||||
|
||||
const perfectFreehandOptions = useMemo(() => {
|
||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||
const onImageSignatureChange = (value: string) => {
|
||||
setImageSignature(value);
|
||||
|
||||
return {
|
||||
size,
|
||||
thinning: 0.25,
|
||||
streamline: 0.5,
|
||||
smoothing: 0.5,
|
||||
end: {
|
||||
taper: size * 2,
|
||||
},
|
||||
} satisfies StrokeOptions;
|
||||
}, []);
|
||||
|
||||
const checkSignatureValidity = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
const imageData = ctx.getImageData(0, 0, $el.current.width, $el.current.height);
|
||||
const data = imageData.data;
|
||||
let filledPixels = 0;
|
||||
const totalPixels = data.length / 4;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) filledPixels++;
|
||||
}
|
||||
|
||||
const filledPercentage = filledPixels / totalPixels;
|
||||
const isValid = filledPercentage > minCoverageThreshold;
|
||||
onValidityChange?.(isValid);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
onChange?.({
|
||||
type: DocumentSignatureType.UPLOAD,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const onDrawSignatureChange = (value: string) => {
|
||||
setDrawSignature(value);
|
||||
|
||||
setIsPressed(true);
|
||||
|
||||
if (typedSignature) {
|
||||
setTypedSignature('');
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
setCurrentLine([point]);
|
||||
onChange?.({
|
||||
type: DocumentSignatureType.DRAW,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const onTypedSignatureChange = (value: string) => {
|
||||
setTypedSignature(value);
|
||||
|
||||
if (!isPressed) {
|
||||
onChange?.({
|
||||
type: DocumentSignatureType.TYPE,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
const onTabChange = (value: 'draw' | 'text' | 'image') => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
const lastPoint = currentLine[currentLine.length - 1];
|
||||
setTab(value);
|
||||
|
||||
if (lastPoint && point.distanceTo(lastPoint) > 5) {
|
||||
setCurrentLine([...currentLine, point]);
|
||||
|
||||
// Update the canvas here to draw the lines
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.fillStyle = selectedColor;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
|
||||
);
|
||||
|
||||
ctx.fill(pathData);
|
||||
});
|
||||
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
|
||||
);
|
||||
ctx.fill(pathData);
|
||||
}
|
||||
}
|
||||
}
|
||||
match(value)
|
||||
.with('draw', () => {
|
||||
onDrawSignatureChange(drawSignature);
|
||||
})
|
||||
.with('text', () => {
|
||||
onTypedSignatureChange(typedSignature);
|
||||
})
|
||||
.with('image', () => {
|
||||
onImageSignatureChange(imageSignature);
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setIsPressed(false);
|
||||
|
||||
const point = Point.fromEvent(event, DPI, $el.current);
|
||||
|
||||
const newLines = [...lines];
|
||||
|
||||
if (addLine && currentLine.length > 0) {
|
||||
newLines.push([...currentLine, point]);
|
||||
setCurrentLine([]);
|
||||
}
|
||||
|
||||
setLines(newLines);
|
||||
|
||||
if ($el.current && newLines.length > 0) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
ctx.restore();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.fillStyle = selectedColor;
|
||||
|
||||
newLines.forEach((line) => {
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
|
||||
);
|
||||
ctx.fill(pathData);
|
||||
});
|
||||
|
||||
const isValidSignature = checkSignatureValidity();
|
||||
|
||||
if (isValidSignature) {
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
ctx.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (isPressed) {
|
||||
onMouseUp(event, true);
|
||||
} else {
|
||||
onMouseUp(event, false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearClick = () => {
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
$imageData.current = null;
|
||||
}
|
||||
|
||||
if ($fileInput.current) {
|
||||
$fileInput.current.value = '';
|
||||
}
|
||||
|
||||
onChange?.(null);
|
||||
|
||||
setTypedSignature('');
|
||||
setLines([]);
|
||||
setCurrentLine([]);
|
||||
setIsPressed(false);
|
||||
};
|
||||
|
||||
const renderTypedSignature = () => {
|
||||
if ($el.current && typedSignature) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
const canvasWidth = $el.current.width;
|
||||
const canvasHeight = $el.current.height;
|
||||
const fontFamily = 'Caveat';
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = selectedColor;
|
||||
|
||||
// Calculate the desired width (25ch)
|
||||
const desiredWidth = canvasWidth * 0.85; // 85% of canvas width
|
||||
|
||||
// Start with a base font size
|
||||
let fontSize = 18;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
|
||||
// Measure 10 characters and calculate scale factor
|
||||
const characterWidth = ctx.measureText('m'.repeat(10)).width;
|
||||
const scaleFactor = desiredWidth / characterWidth;
|
||||
|
||||
// Apply scale factor to font size
|
||||
fontSize = fontSize * scaleFactor;
|
||||
|
||||
// Adjust font size if it exceeds canvas width
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
|
||||
const textWidth = ctx.measureText(typedSignature).width;
|
||||
|
||||
if (textWidth > desiredWidth) {
|
||||
fontSize = fontSize * (desiredWidth / textWidth);
|
||||
}
|
||||
|
||||
// Set final font and render text
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypedSignatureChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
|
||||
// Deny input while drawing.
|
||||
if (isPressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
setLines([]);
|
||||
setCurrentLine([]);
|
||||
}
|
||||
|
||||
setTypedSignature(newValue);
|
||||
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
}
|
||||
|
||||
if (newValue.trim() !== '') {
|
||||
onChange?.(newValue);
|
||||
onValidityChange?.(true);
|
||||
} else {
|
||||
onChange?.(null);
|
||||
onValidityChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
const img = await loadImage(event.target.files?.[0]);
|
||||
|
||||
if (!$el.current) return;
|
||||
|
||||
const ctx = $el.current.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
|
||||
onChange?.($el.current.toDataURL());
|
||||
|
||||
setLines([]);
|
||||
setCurrentLine([]);
|
||||
setTypedSignature('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typedSignature.trim() !== '' && !isBase64Image(typedSignature)) {
|
||||
renderTypedSignature();
|
||||
onChange?.(typedSignature);
|
||||
}
|
||||
}, [typedSignature, selectedColor]);
|
||||
|
||||
const onUndoClick = () => {
|
||||
if (lines.length === 0 && typedSignature.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typedSignature.length > 0) {
|
||||
const newTypedSignature = typedSignature.slice(0, -1);
|
||||
setTypedSignature(newTypedSignature);
|
||||
// You might want to call onChange here as well
|
||||
// onChange?.(newTypedSignature);
|
||||
} else {
|
||||
const newLines = lines.slice(0, -1);
|
||||
setLines(newLines);
|
||||
|
||||
// Clear and redraw the canvas
|
||||
if ($el.current) {
|
||||
const ctx = $el.current.getContext('2d');
|
||||
const { width, height } = $el.current;
|
||||
ctx?.clearRect(0, 0, width, height);
|
||||
|
||||
if (typeof defaultValue === 'string' && $imageData.current) {
|
||||
ctx?.putImageData($imageData.current, 0, 0);
|
||||
}
|
||||
|
||||
newLines.forEach((line) => {
|
||||
const pathData = new Path2D(
|
||||
getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
|
||||
);
|
||||
ctx?.fill(pathData);
|
||||
});
|
||||
|
||||
onChange?.($el.current.toDataURL());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * DPI;
|
||||
$el.current.height = $el.current.clientHeight * DPI;
|
||||
}
|
||||
|
||||
if (defaultValue && typedSignature) {
|
||||
renderTypedSignature();
|
||||
}
|
||||
}, []);
|
||||
|
||||
unsafe_useEffectOnce(() => {
|
||||
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));
|
||||
|
||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
||||
|
||||
$imageData.current = defaultImageData;
|
||||
};
|
||||
|
||||
img.src = defaultValue;
|
||||
}
|
||||
});
|
||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative block select-none', containerClassName, {
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
className={cn({
|
||||
'pointer-events-none': disabled,
|
||||
})}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
|
||||
>
|
||||
<canvas
|
||||
data-testid="signature-pad"
|
||||
ref={$el}
|
||||
className={cn(
|
||||
'relative block',
|
||||
{
|
||||
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
|
||||
},
|
||||
className,
|
||||
<TabsList>
|
||||
{drawSignatureEnabled && (
|
||||
<TabsTrigger value="draw">
|
||||
<SignatureIcon className="mr-2 size-4" />
|
||||
Draw
|
||||
</TabsTrigger>
|
||||
)}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
onPointerLeave={(event) => onMouseLeave(event)}
|
||||
onPointerEnter={(event) => onMouseEnter(event)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{allowTypedSignature && (
|
||||
<div
|
||||
className={cn('ml-4 pb-1', {
|
||||
'ml-10': lines.length > 0 || typedSignature.length > 0,
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
placeholder="Type your signature"
|
||||
className="w-1/2 border-none p-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
value={typedSignature}
|
||||
onChange={handleTypedSignatureChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{typedSignatureEnabled && (
|
||||
<TabsTrigger value="text">
|
||||
<KeyboardIcon className="mr-2 size-4" />
|
||||
Type
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
<div className="text-foreground absolute left-3 top-3 filter">
|
||||
<div
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground flex cursor-pointer flex-row gap-2 rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => $fileInput.current?.click()}
|
||||
>
|
||||
<Input
|
||||
ref={$fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Upload Signature</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{uploadSignatureEnabled && (
|
||||
<TabsTrigger value="image">
|
||||
<UploadCloudIcon className="mr-2 size-4" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="text-foreground absolute right-2 top-2 filter">
|
||||
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
|
||||
<SelectTrigger className="h-auto w-auto border-none p-0.5">
|
||||
<SelectValue placeholder="" />
|
||||
</SelectTrigger>
|
||||
<TabsContent
|
||||
value="draw"
|
||||
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
|
||||
>
|
||||
<SignaturePadDraw
|
||||
className="h-full w-full"
|
||||
onChange={onDrawSignatureChange}
|
||||
value={drawSignature}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<SelectContent className="w-[100px]" align="end">
|
||||
<SelectItem value="black">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-black shadow-sm" />
|
||||
<Trans>Black</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<TabsContent
|
||||
value="text"
|
||||
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
|
||||
>
|
||||
<SignaturePadType value={typedSignature} onChange={onTypedSignatureChange} />
|
||||
</TabsContent>
|
||||
|
||||
<SelectItem value="red">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[red] shadow-sm" />
|
||||
<Trans>Red</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="blue">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[blue] shadow-sm" />
|
||||
<Trans>Blue</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="green">
|
||||
<div className="text-muted-foreground flex items-center text-[0.688rem]">
|
||||
<div className="border-border mr-1 h-4 w-4 rounded-full border-2 bg-[green] shadow-sm" />
|
||||
<Trans>Green</Trans>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 right-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => onClearClick()}
|
||||
>
|
||||
<Trans>Clear Signature</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(lines.length > 0 || typedSignature.length > 0) && (
|
||||
<div className="absolute bottom-4 left-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title="undo"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-[0.688rem] focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={onUndoClick}
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
<span className="sr-only">Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent
|
||||
value="image"
|
||||
className={cn(
|
||||
'border-border aspect-signature-pad dark:bg-background relative rounded-md border bg-neutral-50',
|
||||
{
|
||||
'bg-white': imageSignature,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
128
packages/ui/primitives/signature-pad/signature-render.tsx
Normal file
128
packages/ui/primitives/signature-pad/signature-render.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { SIGNATURE_CANVAS_DPI, isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type SignatureRenderProps = {
|
||||
className?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a typed, uploaded or drawn signature.
|
||||
*/
|
||||
export const SignatureRender = ({ className, value }: SignatureRenderProps) => {
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
|
||||
const renderTypedSignature = () => {
|
||||
if (!$el.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
|
||||
const canvasWidth = $el.current.width;
|
||||
const canvasHeight = $el.current.height;
|
||||
const fontFamily = 'Caveat';
|
||||
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// ctx.fillStyle = selectedColor; // Todo: Color not implemented...
|
||||
|
||||
// Calculate the desired width (25ch)
|
||||
const desiredWidth = canvasWidth * 0.85; // 85% of canvas width
|
||||
|
||||
// Start with a base font size
|
||||
let fontSize = 18;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
|
||||
// Measure 10 characters and calculate scale factor
|
||||
const characterWidth = ctx.measureText('m'.repeat(10)).width;
|
||||
const scaleFactor = desiredWidth / characterWidth;
|
||||
|
||||
// Apply scale factor to font size
|
||||
fontSize = fontSize * scaleFactor;
|
||||
|
||||
// Adjust font size if it exceeds canvas width
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
|
||||
const textWidth = ctx.measureText(value).width;
|
||||
|
||||
if (textWidth > desiredWidth) {
|
||||
fontSize = fontSize * (desiredWidth / textWidth);
|
||||
}
|
||||
|
||||
// Set final font and render text
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.fillText(value, canvasWidth / 2, canvasHeight / 2);
|
||||
};
|
||||
|
||||
const renderImageSignature = () => {
|
||||
if (!$el.current || typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = $el.current.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, $el.current.width, $el.current.height);
|
||||
|
||||
const { width, height } = $el.current;
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate the scaled dimensions while maintaining aspect ratio
|
||||
const scale = Math.min(width / img.width, height / img.height);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
|
||||
// Calculate center position
|
||||
const x = (width - scaledWidth) / 2;
|
||||
const y = (height - scaledHeight) / 2;
|
||||
|
||||
ctx?.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
|
||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
||||
|
||||
$imageData.current = defaultImageData;
|
||||
};
|
||||
|
||||
img.src = value;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ($el.current) {
|
||||
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
|
||||
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBase64Image(value)) {
|
||||
renderImageSignature();
|
||||
} else {
|
||||
renderTypedSignature();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('h-full w-full dark:hue-rotate-180 dark:invert', className)}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
147
packages/ui/primitives/signature-pad/signature-tabs.tsx
Normal file
147
packages/ui/primitives/signature-pad/signature-tabs.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined);
|
||||
|
||||
function useTabs() {
|
||||
const context = React.useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('useTabs must be used within a Tabs provider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
defaultValue,
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TabsProps) {
|
||||
const [tabValue, setTabValue] = React.useState(defaultValue || '');
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
onValueChange?.(newValue);
|
||||
},
|
||||
[onValueChange],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
value: value !== undefined ? value : tabValue,
|
||||
onValueChange: handleValueChange,
|
||||
}),
|
||||
[value, tabValue, handleValueChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={contextValue}>
|
||||
<div className={cn('w-full', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TabsList({ children, className, ...props }: TabsListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('border-border flex flex-wrap border-b', className)}
|
||||
role="tabslist"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
value: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TabsTrigger({ value, icon, children, className, ...props }: TabsTriggerProps) {
|
||||
const { value: selectedValue, onValueChange } = useTabs();
|
||||
const isSelected = selectedValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
data-state={isSelected ? 'active' : 'inactive'}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
'relative flex items-center px-4 py-3 text-sm font-medium transition-all',
|
||||
'focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
isSelected ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="flex items-center">{icon}</span>}
|
||||
{children}
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="activeTabIndicator"
|
||||
className="bg-foreground/40 absolute bottom-0 left-0 h-0.5 w-full rounded-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 50,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TabsContent({ value, children, className, ...props }: TabsContentProps) {
|
||||
const { value: selectedValue } = useTabs();
|
||||
const isSelected = selectedValue === value;
|
||||
|
||||
if (!isSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
data-state={isSelected ? 'active' : 'inactive'}
|
||||
className={cn('mt-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user