mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
fix: allow zooming and dragging uploaded signatures
This commit is contained in:
@@ -1,3 +1,39 @@
|
|||||||
|
import { SIGNATURE_MIN_COVERAGE_THRESHOLD } from '@documenso/lib/constants/signatures';
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the signature covers enough of the canvas to be considered
|
||||||
|
* valid, by measuring the percentage of non-transparent pixels against
|
||||||
|
* SIGNATURE_MIN_COVERAGE_THRESHOLD.
|
||||||
|
*/
|
||||||
|
export 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 const average = (a: number, b: number) => (a + b) / 2;
|
export const average = (a: number, b: number) => (a + b) / 2;
|
||||||
|
|
||||||
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
|
export const getSvgPathFromStroke = (points: number[][], closed = true) => {
|
||||||
|
|||||||
@@ -1,46 +1,18 @@
|
|||||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
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 { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } 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';
|
||||||
import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react';
|
import type { MouseEvent, PointerEvent, TouchEvent } from 'react';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { getSvgPathFromStroke } from './helper';
|
import { checkSignatureValidity, getSvgPathFromStroke } from './helper';
|
||||||
import { Point } from './point';
|
import { Point } from './point';
|
||||||
import { SignaturePadColorPicker } from './signature-pad-color-picker';
|
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 = {
|
export type SignaturePadDrawProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -48,6 +20,8 @@ export type SignaturePadDrawProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SignaturePadDraw = ({ className, value, onChange, ...props }: SignaturePadDrawProps) => {
|
export const SignaturePadDraw = ({ className, value, onChange, ...props }: SignaturePadDrawProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
const $el = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const $imageData = useRef<ImageData | null>(null);
|
const $imageData = useRef<ImageData | null>(null);
|
||||||
@@ -276,9 +250,19 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SignaturePadColorPicker selectedColor={selectedColor} setSelectedColor={setSelectedColor} />
|
<SignaturePadColorPicker
|
||||||
|
className={cn('transition-opacity duration-100', {
|
||||||
|
'pointer-events-none opacity-0': isPressed,
|
||||||
|
})}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
setSelectedColor={setSelectedColor}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="absolute right-3 bottom-3 flex gap-2">
|
<div
|
||||||
|
className={cn('absolute right-3 bottom-3 flex gap-2 transition-opacity duration-100', {
|
||||||
|
'pointer-events-none opacity-0': isPressed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
@@ -289,7 +273,11 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSignatureValid === false && (
|
{isSignatureValid === false && (
|
||||||
<div className="absolute bottom-4 left-4 flex gap-2">
|
<div
|
||||||
|
className={cn('absolute bottom-4 left-4 flex gap-2 transition-opacity duration-100', {
|
||||||
|
'pointer-events-none opacity-0': isPressed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<span className="text-destructive text-xs">
|
<span className="text-destructive text-xs">
|
||||||
<Trans>Signature is too small</Trans>
|
<Trans>Signature is too small</Trans>
|
||||||
</span>
|
</span>
|
||||||
@@ -297,10 +285,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isSignatureValid && lines.length > 0 && (
|
{isSignatureValid && lines.length > 0 && (
|
||||||
<div className="absolute bottom-4 left-4 flex gap-2">
|
<div
|
||||||
|
className={cn('absolute bottom-4 left-4 flex gap-2 transition-opacity duration-100', {
|
||||||
|
'pointer-events-none opacity-0': isPressed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="undo"
|
title={t`Undo`}
|
||||||
className="rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
onClick={onUndoClick}
|
onClick={onUndoClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,66 +1,73 @@
|
|||||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||||
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { UploadCloudIcon } from 'lucide-react';
|
import { UploadCloudIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||||
import { useRef } from 'react';
|
import type { PointerEvent } from 'react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
import { useToast } from '../use-toast';
|
||||||
|
import { checkSignatureValidity } from './helper';
|
||||||
|
|
||||||
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
|
const MIN_ZOOM = 0.25;
|
||||||
if (!file) {
|
const MAX_ZOOM = 4;
|
||||||
throw new Error('No file selected');
|
const ZOOM_STEP = 1.1;
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.type.startsWith('image/')) {
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||||
throw new Error('Invalid file type');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
const SignatureUploadErrorCode = {
|
||||||
throw new Error('Image size should be less than 5MB');
|
InvalidFileType: 'INVALID_FILE_TYPE',
|
||||||
}
|
FileTooLarge: 'FILE_TOO_LARGE',
|
||||||
|
InvalidImageDimensions: 'INVALID_IMAGE_DIMENSIONS',
|
||||||
|
ImageLoadFailed: 'IMAGE_LOAD_FAILED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const loadImage = (file: File): Promise<HTMLImageElement> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new AppError(SignatureUploadErrorCode.InvalidFileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
throw new AppError(SignatureUploadErrorCode.FileTooLarge);
|
||||||
|
}
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
|
||||||
|
// Vector images without explicit dimensions, such as an SVG with only a
|
||||||
|
// viewBox, can report a zero width or height. Drawing them would produce
|
||||||
|
// NaN geometry and silently export a blank signature.
|
||||||
|
if (img.width === 0 || img.height === 0) {
|
||||||
|
reject(new AppError(SignatureUploadErrorCode.InvalidImageDimensions));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resolve(img);
|
resolve(img);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
reject(new Error('Failed to load image'));
|
reject(new AppError(SignatureUploadErrorCode.ImageLoadFailed));
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = objectUrl;
|
img.src = objectUrl;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadImageOntoCanvas = (
|
type DragState = {
|
||||||
image: HTMLImageElement,
|
pointerId: number;
|
||||||
canvas: HTMLCanvasElement,
|
startClientX: number;
|
||||||
ctx: CanvasRenderingContext2D,
|
startClientY: number;
|
||||||
): ImageData => {
|
startOffsetX: number;
|
||||||
const scale = Math.min((canvas.width * 0.8) / image.width, (canvas.height * 0.8) / image.height);
|
startOffsetY: number;
|
||||||
|
clientToCanvasScale: number;
|
||||||
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 = {
|
export type SignaturePadUploadProps = {
|
||||||
@@ -70,54 +77,285 @@ export type SignaturePadUploadProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SignaturePadUpload = ({ className, value, onChange, ...props }: SignaturePadUploadProps) => {
|
export const SignaturePadUpload = ({ className, value, onChange, ...props }: SignaturePadUploadProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const $el = useRef<HTMLCanvasElement>(null);
|
const $el = useRef<HTMLCanvasElement>(null);
|
||||||
const $imageData = useRef<ImageData | null>(null);
|
|
||||||
const $fileInput = useRef<HTMLInputElement>(null);
|
const $fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const $sourceImage = useRef<HTMLImageElement | null>(null);
|
||||||
try {
|
const $transform = useRef({ zoom: 1, offsetX: 0, offsetY: 0 });
|
||||||
const img = await loadImage(event.target.files?.[0]);
|
const $drag = useRef<DragState | null>(null);
|
||||||
|
const $pendingFrame = useRef<number | null>(null);
|
||||||
|
|
||||||
if (!$el.current) {
|
/**
|
||||||
return;
|
* Incremented for every image load so stale async loads can be discarded.
|
||||||
}
|
*/
|
||||||
|
const $loadGeneration = useRef(0);
|
||||||
|
|
||||||
const ctx = $el.current.getContext('2d');
|
const [hasImage, setHasImage] = useState(false);
|
||||||
if (!ctx) {
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
return;
|
const [zoom, setZoom] = useState(1);
|
||||||
}
|
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(null);
|
||||||
|
|
||||||
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
|
/**
|
||||||
onChange?.($el.current.toDataURL());
|
* The scale at which the image fits entirely within the canvas while
|
||||||
} catch (error) {
|
* preserving its aspect ratio.
|
||||||
console.error(error);
|
*/
|
||||||
|
const getFitScale = (image: HTMLImageElement, canvas: HTMLCanvasElement) =>
|
||||||
|
Math.min(canvas.width / image.width, canvas.height / image.height);
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const canvas = $el.current;
|
||||||
|
const image = $sourceImage.current;
|
||||||
|
|
||||||
|
if (!canvas || !image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { zoom: currentZoom, offsetX, offsetY } = $transform.current;
|
||||||
|
|
||||||
|
const scale = getFitScale(image, canvas) * currentZoom;
|
||||||
|
|
||||||
|
const drawWidth = image.width * scale;
|
||||||
|
const drawHeight = image.height * scale;
|
||||||
|
|
||||||
|
const x = (canvas.width - drawWidth) / 2 + offsetX;
|
||||||
|
const y = (canvas.height - drawHeight) / 2 + offsetY;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
ctx.drawImage(image, x, y, drawWidth, drawHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDraw = () => {
|
||||||
|
if ($pendingFrame.current !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingFrame.current = requestAnimationFrame(() => {
|
||||||
|
$pendingFrame.current = null;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the canvas exactly as displayed, so the frame is the signature.
|
||||||
|
*
|
||||||
|
* The signature is only committed when it covers enough of the canvas to be
|
||||||
|
* considered valid, otherwise the value is cleared so an invalid signature
|
||||||
|
* cannot be submitted.
|
||||||
|
*/
|
||||||
|
const commitChange = () => {
|
||||||
|
if (!$el.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = checkSignatureValidity($el);
|
||||||
|
|
||||||
|
setIsSignatureValid(isValid);
|
||||||
|
|
||||||
|
onChange?.(isValid ? $el.current.toDataURL() : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyZoom = (nextZoom: number) => {
|
||||||
|
if (!$sourceImage.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
|
||||||
|
|
||||||
|
$transform.current.zoom = clampedZoom;
|
||||||
|
|
||||||
|
setZoom(clampedZoom);
|
||||||
|
|
||||||
|
draw();
|
||||||
|
commitChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = $el.current;
|
||||||
|
|
||||||
|
if (!canvas || !$sourceImage.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only drag with the primary pointer and main button, otherwise a
|
||||||
|
// right/middle click can arm a drag whose pointerup is swallowed by the
|
||||||
|
// context menu, leaving the image glued to the cursor.
|
||||||
|
if (!event.isPrimary || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
$drag.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
startOffsetX: $transform.current.offsetX,
|
||||||
|
startOffsetY: $transform.current.offsetY,
|
||||||
|
clientToCanvasScale: rect.width > 0 ? canvas.width / rect.width : SIGNATURE_CANVAS_DPI,
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const drag = $drag.current;
|
||||||
|
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
$transform.current.offsetX = drag.startOffsetX + (event.clientX - drag.startClientX) * drag.clientToCanvasScale;
|
||||||
|
$transform.current.offsetY = drag.startOffsetY + (event.clientY - drag.startClientY) * drag.clientToCanvasScale;
|
||||||
|
|
||||||
|
requestDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerEnd = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const drag = $drag.current;
|
||||||
|
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMoved =
|
||||||
|
$transform.current.offsetX !== drag.startOffsetX || $transform.current.offsetY !== drag.startOffsetY;
|
||||||
|
|
||||||
|
$drag.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if ($el.current?.hasPointerCapture(event.pointerId)) {
|
||||||
|
$el.current.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pendingFrame.current !== null) {
|
||||||
|
cancelAnimationFrame($pendingFrame.current);
|
||||||
|
$pendingFrame.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
// Avoid emitting an identical signature when the pointer never moved,
|
||||||
|
// such as a plain click on the canvas.
|
||||||
|
if (hasMoved) {
|
||||||
|
commitChange();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
|
// Allow re-selecting the same file to trigger another change event.
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = ++$loadGeneration.current;
|
||||||
|
|
||||||
|
let img: HTMLImageElement;
|
||||||
|
|
||||||
|
try {
|
||||||
|
img = await loadImage(file);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const description = match(error.code)
|
||||||
|
.with(SignatureUploadErrorCode.InvalidFileType, () => t`Please upload a valid image file.`)
|
||||||
|
.with(SignatureUploadErrorCode.FileTooLarge, () => t`The image must be smaller than 5MB.`)
|
||||||
|
.with(
|
||||||
|
SignatureUploadErrorCode.InvalidImageDimensions,
|
||||||
|
() => t`This image is invalid, please upload a valid image file.`,
|
||||||
|
)
|
||||||
|
.otherwise(() => t`The image could not be loaded. Please try again.`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t`Unable to upload image`,
|
||||||
|
description,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard the result if another image load started in the meantime.
|
||||||
|
if (generation !== $loadGeneration.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceImage.current = img;
|
||||||
|
$transform.current = { zoom: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
|
setHasImage(true);
|
||||||
|
setZoom(1);
|
||||||
|
|
||||||
|
draw();
|
||||||
|
commitChange();
|
||||||
|
};
|
||||||
|
|
||||||
unsafe_useEffectOnce(() => {
|
unsafe_useEffectOnce(() => {
|
||||||
// Todo: Not really sure if this is required for uploaded images.
|
|
||||||
if ($el.current) {
|
if ($el.current) {
|
||||||
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
|
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
|
||||||
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
|
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($el.current && value) {
|
if ($el.current && value) {
|
||||||
const ctx = $el.current.getContext('2d');
|
const generation = ++$loadGeneration.current;
|
||||||
|
|
||||||
const { width, height } = $el.current;
|
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
|
// Discard the result if another image load started in the meantime.
|
||||||
|
if (generation !== $loadGeneration.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
|
// Display the existing signature aspect-fitted and centered, ready to
|
||||||
|
// be adjusted further with zoom and drag. This is display-only and
|
||||||
|
// intentionally does not call onChange.
|
||||||
|
$sourceImage.current = img;
|
||||||
|
$transform.current = { zoom: 1, offsetX: 0, offsetY: 0 };
|
||||||
|
|
||||||
$imageData.current = defaultImageData;
|
setHasImage(true);
|
||||||
|
setZoom(1);
|
||||||
|
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error(new AppError(SignatureUploadErrorCode.ImageLoadFailed));
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = value;
|
img.src = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if ($pendingFrame.current !== null) {
|
||||||
|
cancelAnimationFrame($pendingFrame.current);
|
||||||
|
$pendingFrame.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,21 +363,79 @@ export const SignaturePadUpload = ({ className, value, onChange, ...props }: Sig
|
|||||||
<canvas
|
<canvas
|
||||||
data-testid="signature-pad-upload"
|
data-testid="signature-pad-upload"
|
||||||
ref={$el}
|
ref={$el}
|
||||||
className="h-full w-full dark:hue-rotate-180 dark:invert"
|
className={cn('h-full w-full dark:hue-rotate-180 dark:invert', {
|
||||||
|
'cursor-grab': hasImage && !isDragging,
|
||||||
|
'cursor-grabbing': isDragging,
|
||||||
|
})}
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
{...props}
|
{...props}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerEnd}
|
||||||
|
onPointerCancel={onPointerEnd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input ref={$fileInput} type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
<input ref={$fileInput} type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||||
|
|
||||||
<motion.button
|
{hasImage && (
|
||||||
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||||
initial="initial"
|
<button
|
||||||
animate="animate"
|
type="button"
|
||||||
whileHover="hover"
|
title={t`Zoom out`}
|
||||||
onClick={() => $fileInput.current?.click()}
|
disabled={zoom <= MIN_ZOOM}
|
||||||
>
|
className="rounded-full p-0 text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
||||||
{!value && (
|
onClick={() => applyZoom($transform.current.zoom / ZOOM_STEP)}
|
||||||
|
>
|
||||||
|
<ZoomOutIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Zoom out</Trans>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t`Zoom in`}
|
||||||
|
disabled={zoom >= MAX_ZOOM}
|
||||||
|
className="rounded-full p-0 text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
onClick={() => applyZoom($transform.current.zoom * ZOOM_STEP)}
|
||||||
|
>
|
||||||
|
<ZoomInIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Zoom in</Trans>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasImage && (
|
||||||
|
<div className="absolute right-3 bottom-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => $fileInput.current?.click()}
|
||||||
|
>
|
||||||
|
<Trans>Upload New Image</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasImage && (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 flex h-full w-full items-center justify-center"
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
whileHover="hover"
|
||||||
|
onClick={() => $fileInput.current?.click()}
|
||||||
|
>
|
||||||
<motion.div>
|
<motion.div>
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -150,8 +446,8 @@ export const SignaturePadUpload = ({ className, value, onChange, ...props }: Sig
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
</motion.button>
|
||||||
</motion.button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
export function TabsList({ children, className, ...props }: TabsListProps) {
|
export function TabsList({ children, className, ...props }: TabsListProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-wrap border-border border-b', className)} role="tabslist" {...props}>
|
<div className={cn('flex flex-wrap border-border border-b', className)} role="tablist" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user