mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
feat: add qr signatures
This commit is contained in:
@@ -77,9 +77,20 @@ export class Point implements PointLike {
|
||||
let x = Math.min(Math.max(left, clientX), right) - left;
|
||||
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
||||
|
||||
// adjust for DPI
|
||||
x *= dpi;
|
||||
y *= dpi;
|
||||
// Adjust for DPI. Canvas bitmaps are sized once at mount, so if the element
|
||||
// has been resized since (fluid container, device rotation) the nominal dpi
|
||||
// no longer matches reality — use the actual bitmap / CSS box ratio so the
|
||||
// ink always lands under the pointer.
|
||||
let scaleX = dpi;
|
||||
let scaleY = dpi;
|
||||
|
||||
if (target instanceof HTMLCanvasElement && right - left > 0 && bottom - top > 0) {
|
||||
scaleX = target.width / (right - left);
|
||||
scaleY = target.height / (bottom - top);
|
||||
}
|
||||
|
||||
x *= scaleX;
|
||||
y *= scaleY;
|
||||
|
||||
return new Point(x, y);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
|
||||
@@ -22,6 +23,8 @@ export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'o
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
qrSignatureContext?: TQrSignatureContext;
|
||||
};
|
||||
|
||||
export const SignaturePadDialog = ({
|
||||
@@ -34,6 +37,8 @@ export const SignaturePadDialog = ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
qrSignatureContext,
|
||||
dialogConfirmText,
|
||||
}: SignaturePadDialogProps) => {
|
||||
const { i18n } = useLingui();
|
||||
@@ -121,6 +126,8 @@ export const SignaturePadDialog = ({
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
qrSignatureContext={qrSignatureContext}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -260,14 +260,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', className)}>
|
||||
<div className={cn('h-full w-full select-none', 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' }}
|
||||
style={{ touchAction: 'none', WebkitTouchCallout: 'none' }}
|
||||
onPointerMove={(event) => onMouseMove(event)}
|
||||
onPointerDown={(event) => onMouseDown(event)}
|
||||
onPointerUp={(event) => onMouseUp(event)}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader2Icon, RefreshCwIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderSVG } from 'uqr';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../button';
|
||||
import { SignatureRender } from './signature-render';
|
||||
|
||||
export type QrSignatureSession = {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redraw a signature onto a canvas of the given size, scaled to fit and
|
||||
* centered.
|
||||
*
|
||||
* The phone pad's canvas has different dimensions to the local draw pad, and
|
||||
* the draw pad renders its value at natural size without scaling - committing
|
||||
* the phone's PNG directly would make it render smaller (or larger) than the
|
||||
* preview. Normalising to the local pad's dimensions keeps every consumer of
|
||||
* the value untouched.
|
||||
*/
|
||||
const normalizeSignatureSize = async (dataUrl: string, targetWidth: number, targetHeight: number): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
resolve(dataUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const scale = Math.min(targetWidth / img.width, targetHeight / img.height);
|
||||
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
|
||||
ctx.drawImage(img, (targetWidth - scaledWidth) / 2, (targetHeight - scaledHeight) / 2, scaledWidth, scaledHeight);
|
||||
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.onerror = () => resolve(dataUrl);
|
||||
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
export type SignaturePadQrProps = {
|
||||
className?: string;
|
||||
value: string;
|
||||
onChange: (_signatureDataUrl: string) => void;
|
||||
session: QrSignatureSession | null;
|
||||
onSessionChange: (_session: QrSignatureSession | null) => void;
|
||||
|
||||
/**
|
||||
* What the handoff signature is for. Rendered on the mobile signing page so
|
||||
* the signer can see the context of what they are signing. When omitted the
|
||||
* mobile page shows a generic "Signature requested".
|
||||
*/
|
||||
context?: TQrSignatureContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Mobile" tab of the signature pad.
|
||||
*
|
||||
* Displays a QR code linking to a public mobile drawing page, then polls until
|
||||
* the phone submits a signature. The received signature is committed as a
|
||||
* drawn (base64 PNG) signature via `onChange`.
|
||||
*
|
||||
* The session lives in the parent so that switching tabs does not invalidate
|
||||
* an in-flight handoff (tab contents unmount when inactive).
|
||||
*/
|
||||
export const SignaturePadQr = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
session,
|
||||
onSessionChange,
|
||||
context,
|
||||
}: SignaturePadQrProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const hasFiredCreateRef = useRef(false);
|
||||
const $container = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Only show the preview for a signature received during this mount - a value
|
||||
// drawn on another tab renders the QR code so the handoff stays available
|
||||
// without destroying the committed signature.
|
||||
const [hasReceivedSignature, setHasReceivedSignature] = useState(false);
|
||||
|
||||
const { mutate: createQrSignatureSession, isError: isCreateSessionError } = trpc.signature.qr.create.useMutation({
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
onSuccess: (data) => {
|
||||
onSessionChange(data);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: qrSignatureData } = trpc.signature.qr.get.useQuery(
|
||||
{
|
||||
token: session?.token ?? '',
|
||||
},
|
||||
{
|
||||
enabled: Boolean(session),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.status === 'COMPLETED' || query.state.data?.status === 'EXPIRED' ? false : 2500,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session && !hasFiredCreateRef.current) {
|
||||
hasFiredCreateRef.current = true;
|
||||
createQrSignatureSession({ context });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (qrSignatureData?.status !== 'COMPLETED') {
|
||||
return;
|
||||
}
|
||||
|
||||
// The tab container has the same box as the draw tab, so its measured size
|
||||
// matches the draw pad's canvas dimensions.
|
||||
const container = $container.current;
|
||||
|
||||
const targetWidth = container ? Math.round(container.clientWidth * SIGNATURE_CANVAS_DPI) : 0;
|
||||
const targetHeight = container ? Math.round(container.clientHeight * SIGNATURE_CANVAS_DPI) : 0;
|
||||
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
onChange(qrSignatureData.signature);
|
||||
onSessionChange(null);
|
||||
setHasReceivedSignature(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
void normalizeSignatureSize(qrSignatureData.signature, targetWidth, targetHeight).then((normalizedSignature) => {
|
||||
if (!isCancelled) {
|
||||
onChange(normalizedSignature);
|
||||
onSessionChange(null);
|
||||
setHasReceivedSignature(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
// Note: `onChange`/`onSessionChange` are fresh closures from the parent each
|
||||
// render, so including them would re-fire this effect spuriously.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qrSignatureData]);
|
||||
|
||||
const onGenerateNewCodeClick = () => {
|
||||
onSessionChange(null);
|
||||
createQrSignatureSession({ context });
|
||||
};
|
||||
|
||||
const onScanAgainClick = () => {
|
||||
setHasReceivedSignature(false);
|
||||
onSessionChange(null);
|
||||
createQrSignatureSession({ context });
|
||||
};
|
||||
|
||||
// Only a signature received via this tab shows the preview; any other
|
||||
// value keeps the QR available.
|
||||
if (value && hasReceivedSignature) {
|
||||
return (
|
||||
<div
|
||||
data-testid="signature-pad-qr-preview"
|
||||
className={cn('relative flex h-full w-full flex-col items-center justify-center', className)}
|
||||
>
|
||||
<SignatureRender value={value} className="h-full w-full" />
|
||||
|
||||
<div className="absolute right-3 bottom-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 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={() => onScanAgainClick()}
|
||||
>
|
||||
<RefreshCwIcon className="size-3" />
|
||||
<Trans>Scan again</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCreateSessionError || qrSignatureData?.status === 'EXPIRED') {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center gap-2', className)}>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isCreateSessionError ? (
|
||||
<Trans>Something went wrong. Please try again.</Trans>
|
||||
) : (
|
||||
<Trans>This QR code has expired.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onGenerateNewCodeClick()}>
|
||||
<RefreshCwIcon className="mr-2 size-4" />
|
||||
<Trans>Generate new code</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Session is being created (or is about to be) - show the loader. This must
|
||||
// never depend on the mutation's isPending, which can be stale after a
|
||||
// StrictMode double-mount.
|
||||
if (!session) {
|
||||
return (
|
||||
<div role="status" className={cn('flex h-full w-full items-center justify-center', className)}>
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="sr-only">
|
||||
<Trans>Loading</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mobileSigningUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/mobile-signature/${session.token}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={$container}
|
||||
data-testid="signature-pad-qr"
|
||||
className={cn(
|
||||
'flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 overflow-hidden p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* The QR absorbs whatever vertical space is left over, so the labels
|
||||
below always keep their room and can never be pushed out of the pad. */}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t`QR code for mobile signing`}
|
||||
className="aspect-square h-full rounded-md bg-white p-1.5 [&>svg]:block [&>svg]:h-full [&>svg]:w-full"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Expected usage to render QR.
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(mobileSigningUrl),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="shrink-0 text-muted-foreground text-xs">
|
||||
<Trans>Scan with your phone to draw your signature</Trans>
|
||||
</p>
|
||||
|
||||
<p
|
||||
data-testid="signature-pad-qr-url"
|
||||
className="w-full shrink-0 truncate px-2 text-center text-[0.688rem] text-muted-foreground/60"
|
||||
>
|
||||
{mobileSigningUrl}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,16 @@
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
||||
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import { KeyboardIcon, SmartphoneIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { match, P } from 'ts-pattern';
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SignaturePadDraw } from './signature-pad-draw';
|
||||
import type { QrSignatureSession } from './signature-pad-qr';
|
||||
import { SignaturePadQr } from './signature-pad-qr';
|
||||
import { SignaturePadType } from './signature-pad-type';
|
||||
import { SignaturePadUpload } from './signature-pad-upload';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
||||
@@ -29,6 +30,8 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
qrSignatureContext?: TQrSignatureContext;
|
||||
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
};
|
||||
@@ -41,11 +44,15 @@ export const SignaturePad = ({
|
||||
typedSignatureEnabled = true,
|
||||
uploadSignatureEnabled = true,
|
||||
drawSignatureEnabled = true,
|
||||
qrSignatureEnabled = true,
|
||||
qrSignatureContext,
|
||||
}: SignaturePadProps) => {
|
||||
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
||||
|
||||
const [qrSession, setQrSession] = useState<QrSignatureSession | null>(null);
|
||||
|
||||
/**
|
||||
* This is cooked.
|
||||
*
|
||||
@@ -53,7 +60,7 @@ export const SignaturePad = ({
|
||||
* the first enabled tab.
|
||||
*/
|
||||
const [tab, setTab] = useState(
|
||||
((): 'draw' | 'text' | 'image' => {
|
||||
((): 'draw' | 'text' | 'image' | 'qr' => {
|
||||
// First passthrough to check to see if there's a signature for a given tab.
|
||||
if (drawSignatureEnabled && drawSignature) {
|
||||
return 'draw';
|
||||
@@ -80,6 +87,10 @@ export const SignaturePad = ({
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (qrSignatureEnabled) {
|
||||
return 'qr';
|
||||
}
|
||||
|
||||
throw new Error('No signature enabled');
|
||||
})(),
|
||||
);
|
||||
@@ -111,7 +122,7 @@ export const SignaturePad = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onTabChange = (value: 'draw' | 'text' | 'image') => {
|
||||
const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +130,7 @@ export const SignaturePad = ({
|
||||
setTab(value);
|
||||
|
||||
match(value)
|
||||
.with('draw', () => {
|
||||
.with(P.union('draw', 'qr'), () => {
|
||||
onDrawSignatureChange(drawSignature);
|
||||
})
|
||||
.with('text', () => {
|
||||
@@ -131,7 +142,7 @@ export const SignaturePad = ({
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
|
||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -142,7 +153,7 @@ export const SignaturePad = ({
|
||||
'pointer-events-none': disabled,
|
||||
})}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
|
||||
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'qr')}
|
||||
>
|
||||
<TabsList>
|
||||
{drawSignatureEnabled && (
|
||||
@@ -152,6 +163,13 @@ export const SignaturePad = ({
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{qrSignatureEnabled && (
|
||||
<TabsTrigger value="qr" className="max-sm:hidden">
|
||||
<SmartphoneIcon className="mr-2 size-4" />
|
||||
<Trans context="Sign using a mobile phone">Mobile</Trans>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{typedSignatureEnabled && (
|
||||
<TabsTrigger value="text">
|
||||
<KeyboardIcon className="mr-2 size-4" />
|
||||
@@ -174,6 +192,19 @@ export const SignaturePad = ({
|
||||
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="qr"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||
>
|
||||
<SignaturePadQr
|
||||
value={drawSignature}
|
||||
onChange={onDrawSignatureChange}
|
||||
session={qrSession}
|
||||
context={qrSignatureContext}
|
||||
onSessionChange={setQrSession}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="text"
|
||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||
|
||||
Reference in New Issue
Block a user