mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: upload signature as img (#1496)
Allow users to upload their signature as an image. https://github.com/user-attachments/assets/375faad2-f0db-4f44-83d2-d969c5ab4442
This commit is contained in:
@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2, Upload } from 'lucide-react';
|
||||||
import type { StrokeOptions } from 'perfect-freehand';
|
import type { StrokeOptions } from 'perfect-freehand';
|
||||||
import { getStroke } from 'perfect-freehand';
|
import { getStroke } from 'perfect-freehand';
|
||||||
|
|
||||||
@ -33,6 +33,64 @@ const fontCaveat = Caveat({
|
|||||||
|
|
||||||
const DPI = 2;
|
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 SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||||
onChange?: (_signatureDataUrl: string | null) => void;
|
onChange?: (_signatureDataUrl: string | null) => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
@ -52,12 +110,15 @@ export const SignaturePad = ({
|
|||||||
}: SignaturePadProps) => {
|
}: SignaturePadProps) => {
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
const $el = useRef<HTMLCanvasElement>(null);
|
||||||
const $imageData = useRef<ImageData | null>(null);
|
const $imageData = useRef<ImageData | null>(null);
|
||||||
|
const $fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
const [lines, setLines] = useState<Point[][]>([]);
|
const [lines, setLines] = useState<Point[][]>([]);
|
||||||
const [currentLine, setCurrentLine] = useState<Point[]>([]);
|
const [currentLine, setCurrentLine] = useState<Point[]>([]);
|
||||||
const [selectedColor, setSelectedColor] = useState('black');
|
const [selectedColor, setSelectedColor] = useState('black');
|
||||||
const [typedSignature, setTypedSignature] = useState(defaultValue ?? '');
|
const [typedSignature, setTypedSignature] = useState(
|
||||||
|
defaultValue && !isBase64Image(defaultValue) ? defaultValue : '',
|
||||||
|
);
|
||||||
|
|
||||||
const perfectFreehandOptions = useMemo(() => {
|
const perfectFreehandOptions = useMemo(() => {
|
||||||
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
|
||||||
@ -80,6 +141,14 @@ export const SignaturePad = ({
|
|||||||
|
|
||||||
setIsPressed(true);
|
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);
|
const point = Point.fromEvent(event, DPI, $el.current);
|
||||||
|
|
||||||
setCurrentLine([point]);
|
setCurrentLine([point]);
|
||||||
@ -193,6 +262,10 @@ export const SignaturePad = ({
|
|||||||
$imageData.current = null;
|
$imageData.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($fileInput.current) {
|
||||||
|
$fileInput.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
onChange?.(null);
|
onChange?.(null);
|
||||||
|
|
||||||
setTypedSignature('');
|
setTypedSignature('');
|
||||||
@ -255,12 +328,30 @@ export const SignaturePad = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (typedSignature.trim() !== '') {
|
if (typedSignature.trim() !== '' && !isBase64Image(typedSignature)) {
|
||||||
renderTypedSignature();
|
renderTypedSignature();
|
||||||
onChange?.(typedSignature);
|
onChange?.(typedSignature);
|
||||||
} else {
|
|
||||||
onClearClick();
|
|
||||||
}
|
}
|
||||||
}, [typedSignature, selectedColor]);
|
}, [typedSignature, selectedColor]);
|
||||||
|
|
||||||
@ -370,6 +461,26 @@ export const SignaturePad = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="text-foreground absolute right-2 top-2 filter">
|
<div className="text-foreground absolute right-2 top-2 filter">
|
||||||
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
|
<Select defaultValue={selectedColor} onValueChange={(value) => setSelectedColor(value)}>
|
||||||
<SelectTrigger className="h-auto w-auto border-none p-0.5">
|
<SelectTrigger className="h-auto w-auto border-none p-0.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user