mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 11:01:47 +10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ac3ca7b0 |
@@ -81,7 +81,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -34,118 +32,33 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
const DOCUMENT_NAME_SOURCE = {
|
||||
TEMPLATE: 'template',
|
||||
UPLOAD: 'upload',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE];
|
||||
|
||||
type TCustomDocumentData = {
|
||||
data?: File;
|
||||
uploadSequence?: number;
|
||||
};
|
||||
|
||||
const getUploadedDocumentTitle = (file: File) => {
|
||||
return file.name.replace(/\.[^/.]+$/, '').trim();
|
||||
};
|
||||
|
||||
const getLastUploadedFile = (customDocumentData?: TCustomDocumentData[]) => {
|
||||
const uploadedFiles = customDocumentData?.filter(
|
||||
(item): item is Required<TCustomDocumentData> => item.data !== undefined && item.uploadSequence !== undefined,
|
||||
);
|
||||
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return uploadedFiles.reduce((lastUploadedFile, uploadedFile) =>
|
||||
uploadedFile.uploadSequence > lastUploadedFile.uploadSequence ? uploadedFile : lastUploadedFile,
|
||||
).data;
|
||||
};
|
||||
|
||||
const getTemplateUseDocumentTitle = ({
|
||||
documentNameSource,
|
||||
customDocumentName,
|
||||
customDocumentData,
|
||||
}: {
|
||||
documentNameSource: TDocumentNameSource;
|
||||
customDocumentName: string;
|
||||
customDocumentData?: TCustomDocumentData[];
|
||||
}) =>
|
||||
match(documentNameSource)
|
||||
.with(DOCUMENT_NAME_SOURCE.UPLOAD, () => {
|
||||
const uploadedFile = getLastUploadedFile(customDocumentData);
|
||||
|
||||
return uploadedFile ? getUploadedDocumentTitle(uploadedFile) : undefined;
|
||||
})
|
||||
.with(DOCUMENT_NAME_SOURCE.CUSTOM, () => customDocumentName.trim())
|
||||
.with(DOCUMENT_NAME_SOURCE.TEMPLATE, () => undefined)
|
||||
.exhaustive();
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
documentNameSource: z.enum([
|
||||
DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
DOCUMENT_NAME_SOURCE.UPLOAD,
|
||||
DOCUMENT_NAME_SOURCE.CUSTOM,
|
||||
]),
|
||||
customDocumentName: z.string(),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
uploadSequence: z.number().optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.TEMPLATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = getTemplateUseDocumentTitle(data);
|
||||
const path = data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM ? 'customDocumentName' : 'documentNameSource';
|
||||
|
||||
if (!title) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Document name is required`.id,
|
||||
path: [path],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.length > DOCUMENT_TITLE_MAX_LENGTH) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Document name is too long`.id,
|
||||
path: [path],
|
||||
});
|
||||
}
|
||||
});
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@@ -174,7 +87,6 @@ export function TemplateUseDialog({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const uploadSequenceRef = useRef(0);
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
@@ -194,8 +106,6 @@ export function TemplateUseDialog({
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
customDocumentName: '',
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
@@ -232,10 +142,9 @@ export function TemplateUseDialog({
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
const documentTitle = getTemplateUseDocumentTitle(data);
|
||||
|
||||
const customFilesToUpload = (data.customDocumentData ?? []).filter(
|
||||
(item): item is typeof item & { data: File } => item.data !== undefined,
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
);
|
||||
|
||||
const customDocumentData = await Promise.all(
|
||||
@@ -254,7 +163,6 @@ export function TemplateUseDialog({
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentData,
|
||||
...(documentTitle ? { override: { title: documentTitle } } : {}),
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -287,12 +195,6 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const useCustomDocument = form.watch('useCustomDocument');
|
||||
const documentNameSource = form.watch('documentNameSource');
|
||||
const customDocumentData = form.watch('customDocumentData');
|
||||
const lastUploadedFile = useCustomDocument ? getLastUploadedFile(customDocumentData) : undefined;
|
||||
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
@@ -311,15 +213,6 @@ export function TemplateUseDialog({
|
||||
}
|
||||
}, [envelopeItems, form, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
|
||||
form.clearErrors('documentNameSource');
|
||||
}, [canUseUploadedDocumentName, documentNameSource, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -345,9 +238,9 @@ export function TemplateUseDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{formRecipients.map((recipient, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
|
||||
@@ -508,16 +401,7 @@ export function TemplateUseDialog({
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
const customDocumentData = form.getValues('customDocumentData');
|
||||
|
||||
form.setValue(
|
||||
'customDocumentData',
|
||||
customDocumentData?.map((item) => ({
|
||||
...item,
|
||||
data: undefined,
|
||||
})),
|
||||
);
|
||||
form.clearErrors('customDocumentData');
|
||||
form.setValue('customDocumentData', undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -544,7 +428,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomDocument && (
|
||||
{form.watch('useCustomDocument') && (
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
@@ -559,7 +443,7 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -567,15 +451,13 @@ export function TemplateUseDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">
|
||||
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
|
||||
</h4>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
|
||||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||||
{field.value ? (
|
||||
<span>
|
||||
<div>
|
||||
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Trans>Default file</Trans>
|
||||
)}
|
||||
@@ -635,7 +517,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(msg`Please select a PDF file`),
|
||||
});
|
||||
@@ -644,7 +526,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
|
||||
form.setError(`customDocumentData.${i}.data`, {
|
||||
form.setError('customDocumentData', {
|
||||
type: 'manual',
|
||||
message: _(
|
||||
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
|
||||
@@ -655,11 +537,6 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
form.setValue(
|
||||
`customDocumentData.${i}.uploadSequence`,
|
||||
++uploadSequenceRef.current,
|
||||
);
|
||||
form.clearErrors(`customDocumentData.${i}.data`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -673,112 +550,6 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentNameSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
aria-label={_(msg`Document name`)}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
|
||||
<label className="text-sm" htmlFor="document-name-source-template">
|
||||
<Trans>Use template name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
id="document-name-source-upload"
|
||||
value={DOCUMENT_NAME_SOURCE.UPLOAD}
|
||||
disabled={!canUseUploadedDocumentName}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<label
|
||||
className={cn('text-sm', {
|
||||
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
|
||||
})}
|
||||
htmlFor="document-name-source-upload"
|
||||
>
|
||||
<Trans>Use uploaded file name</Trans>
|
||||
</label>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={_(msg`About uploaded file naming`)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-[99999] max-w-xs">
|
||||
<Trans>
|
||||
The document name will use the most recently uploaded file name without its
|
||||
extension.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{lastUploadedFile && (
|
||||
<p
|
||||
className="max-w-sm truncate text-muted-foreground text-xs"
|
||||
title={lastUploadedFile.name}
|
||||
>
|
||||
{lastUploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canUseUploadedDocumentName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Upload a custom document to use its file name.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
|
||||
<label className="text-sm" htmlFor="document-name-source-custom">
|
||||
<Trans>Enter custom document name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customDocumentName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="ml-6">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
aria-label={_(msg`Custom document name`)}
|
||||
placeholder={_(msg`Enter a document name`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
volumes:
|
||||
- documenso_database:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?err}
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=documenso
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
test: ['CMD-SHELL', 'pg_isready -U documenso']
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -156,8 +156,8 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
// Get input with Email label placeholder.
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(teamMemberUser.email);
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('name');
|
||||
await page.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('name');
|
||||
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
|
||||
+147
-147
File diff suppressed because it is too large
Load Diff
@@ -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 getSvgPathFromStroke = (points: number[][], closed = true) => {
|
||||
|
||||
@@ -1,46 +1,18 @@
|
||||
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 type { StrokeOptions } 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 { cn } from '../../lib/utils';
|
||||
import { getSvgPathFromStroke } from './helper';
|
||||
import { checkSignatureValidity, 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;
|
||||
@@ -48,6 +20,8 @@ export type SignaturePadDrawProps = {
|
||||
};
|
||||
|
||||
export const SignaturePadDraw = ({ className, value, onChange, ...props }: SignaturePadDrawProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const $imageData = useRef<ImageData | null>(null);
|
||||
@@ -276,9 +250,19 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
||||
{...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
|
||||
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"
|
||||
@@ -289,7 +273,11 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<Trans>Signature is too small</Trans>
|
||||
</span>
|
||||
@@ -297,10 +285,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
||||
)}
|
||||
|
||||
{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
|
||||
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"
|
||||
onClick={onUndoClick}
|
||||
>
|
||||
|
||||
@@ -1,66 +1,73 @@
|
||||
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
|
||||
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 { UploadCloudIcon } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { UploadCloudIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useToast } from '../use-toast';
|
||||
import { checkSignatureValidity } from './helper';
|
||||
|
||||
const loadImage = async (file: File | undefined): Promise<HTMLImageElement> => {
|
||||
if (!file) {
|
||||
throw new Error('No file selected');
|
||||
}
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 4;
|
||||
const ZOOM_STEP = 1.1;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
throw new Error('Image size should be less than 5MB');
|
||||
}
|
||||
const SignatureUploadErrorCode = {
|
||||
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) => {
|
||||
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 objectUrl = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
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);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
reject(new AppError(SignatureUploadErrorCode.ImageLoadFailed));
|
||||
};
|
||||
|
||||
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;
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startOffsetX: number;
|
||||
startOffsetY: number;
|
||||
clientToCanvasScale: number;
|
||||
};
|
||||
|
||||
export type SignaturePadUploadProps = {
|
||||
@@ -70,54 +77,285 @@ export type SignaturePadUploadProps = {
|
||||
};
|
||||
|
||||
export const SignaturePadUpload = ({ className, value, onChange, ...props }: SignaturePadUploadProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
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]);
|
||||
const $sourceImage = useRef<HTMLImageElement | null>(null);
|
||||
const $transform = useRef({ zoom: 1, offsetX: 0, offsetY: 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');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
const [hasImage, setHasImage] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [isSignatureValid, setIsSignatureValid] = useState<boolean | null>(null);
|
||||
|
||||
$imageData.current = loadImageOntoCanvas(img, $el.current, ctx);
|
||||
onChange?.($el.current.toDataURL());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
/**
|
||||
* The scale at which the image fits entirely within the canvas while
|
||||
* preserving its aspect ratio.
|
||||
*/
|
||||
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(() => {
|
||||
// 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 generation = ++$loadGeneration.current;
|
||||
|
||||
const img = new Image();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if ($pendingFrame.current !== null) {
|
||||
cancelAnimationFrame($pendingFrame.current);
|
||||
$pendingFrame.current = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -125,21 +363,79 @@ export const SignaturePadUpload = ({ className, value, onChange, ...props }: Sig
|
||||
<canvas
|
||||
data-testid="signature-pad-upload"
|
||||
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' }}
|
||||
{...props}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerEnd}
|
||||
onPointerCancel={onPointerEnd}
|
||||
/>
|
||||
|
||||
<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 && (
|
||||
{hasImage && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title={t`Zoom out`}
|
||||
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"
|
||||
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>
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -150,8 +446,8 @@ export const SignaturePadUpload = ({ className, value, onChange, ...props }: Sig
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
export function TabsList({ children, className, ...props }: TabsListProps) {
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user