diff --git a/packages/lib/client-only/hooks/use-effect-once.ts b/packages/lib/client-only/hooks/use-effect-once.ts new file mode 100644 index 000000000..dc6d062dd --- /dev/null +++ b/packages/lib/client-only/hooks/use-effect-once.ts @@ -0,0 +1,13 @@ +import type { EffectCallback } from 'react'; +import { useEffect } from 'react'; + +/** + * Dangerously runs an effect "once" by ignoring the depedencies of a given effect. + * + * DANGER: The effect will run twice in concurrent react and development environments. + */ +export const unsafe_useEffectOnce = (callback: EffectCallback) => { + // Intentionally avoiding exhaustive deps and rule of hooks here + // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks + return useEffect(callback, []); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index eb9403df4..8524450fc 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -7,6 +7,8 @@ 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 { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; @@ -28,6 +30,7 @@ export const SignaturePad = ({ ...props }: SignaturePadProps) => { const $el = useRef(null); + const $imageData = useRef(null); const [isPressed, setIsPressed] = useState(false); const [lines, setLines] = useState([]); @@ -134,7 +137,6 @@ export const SignaturePad = ({ }); onChange?.($el.current.toDataURL()); - ctx.save(); } } @@ -163,6 +165,7 @@ export const SignaturePad = ({ const ctx = $el.current.getContext('2d'); ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + $imageData.current = null; } onChange?.(null); @@ -176,19 +179,25 @@ export const SignaturePad = ({ return; } - const newLines = [...lines]; - newLines.pop(); // Remove the last line + const newLines = lines.slice(0, -1); setLines(newLines); // Clear the canvas if ($el.current) { const ctx = $el.current.getContext('2d'); - ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + 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()); } }; @@ -199,7 +208,7 @@ export const SignaturePad = ({ } }, []); - useEffect(() => { + unsafe_useEffectOnce(() => { if ($el.current && typeof defaultValue === 'string') { const ctx = $el.current.getContext('2d'); @@ -209,11 +218,15 @@ export const SignaturePad = ({ 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; } - }, [defaultValue]); + }); return (