Compare commits

..
Author SHA1 Message Date
David Nguyen b5ac3ca7b0 fix: allow zooming and dragging uploaded signatures 2026-08-12 18:12:52 +10:00
13 changed files with 694 additions and 510 deletions
@@ -21,12 +21,6 @@ export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -41,7 +35,6 @@ export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -136,7 +129,7 @@ export const DocumentAttachmentsPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
@@ -19,12 +19,6 @@ import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -38,7 +32,6 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -97,7 +90,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<span>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
@@ -24,7 +24,6 @@ import {
UserIcon,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -89,18 +88,11 @@ export const fieldButtonList = [
type EnvelopeEditorFieldDragDropProps = {
selectedRecipientId: number | null;
selectedEnvelopeItemId: string | null;
/**
* Called when the user picks a field type from the palette, before the field
* is placed on the document.
*/
onFieldSelect?: (fieldType: FieldType) => void;
};
export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
onFieldSelect,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
@@ -253,10 +245,7 @@ export const EnvelopeEditorFieldDragDrop = ({
disabled={isFieldsDisabled}
key={field.type}
type="button"
onClick={() => {
setSelectedField(field.type);
onFieldSelect?.(field.type);
}}
onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
@@ -278,35 +267,27 @@ export const EnvelopeEditorFieldDragDrop = ({
))}
</div>
{/*
* Portalled to the body because the fields panel ancestor carries a CSS
* transform (the slide-in overlay below `md`), which would otherwise
* become the containing block for this fixed-position preview and pin
* it inside the panel instead of the viewport.
*/}
{selectedField &&
createPortal(
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>,
document.body,
)}
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
</>
);
};
@@ -30,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, MousePointerClickIcon, PencilIcon, SparklesIcon, XIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -89,10 +89,6 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
// Whether the fields panel overlay is open. Only relevant below the `md`
// breakpoint, where the panel is hidden behind a floating trigger button.
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
@@ -191,10 +187,6 @@ export const EnvelopeEditorFieldsPage = () => {
}, []);
const onDetectClick = () => {
// Close the panel overlay on small viewports so the document stays
// visible while the AI dialogs are open.
setIsMobileFieldsPanelOpen(false);
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
@@ -316,264 +308,212 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<>
{/* Floating trigger for the fields panel, visible below the `md` breakpoint. */}
<Button
type="button"
className="fixed right-4 bottom-4 z-30 shadow-lg md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(true)}
>
<MousePointerClickIcon className="mr-2 h-4 w-4" />
<Trans>Fields</Trans>
</Button>
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
{isMobileFieldsPanelOpen && (
<div
role="presentation"
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(false)}
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
)}
{/*
* Below `md` the panel is a slide-in overlay, from `md` upwards it is a
* static sidebar.
*
* This is deliberately one always-mounted element toggled with CSS rather
* than a Sheet/Drawer: the field palette registers window-level mouse
* listeners that must survive the panel closing mid click-to-place, and
* unmounting would also double-mount the field settings forms and AI
* dialogs. `invisible` (with the transition covering visibility) keeps the
* closed overlay out of the tab order and accessibility tree.
*/}
<div
className={cn(
'fixed inset-y-0 right-0 z-50 w-80 max-w-[85vw] overflow-y-auto border-border border-l bg-background py-4 transition-[transform,visibility] duration-200',
isMobileFieldsPanelOpen ? 'translate-x-0 shadow-xl' : 'translate-x-full max-md:invisible',
'md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:translate-x-0 md:shadow-none md:transition-none',
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
>
{/* Panel header with close button, visible below the `md` breakpoint. */}
<div className="mb-4 flex items-center justify-between px-4 md:hidden">
<h3 className="font-semibold text-foreground text-sm">
<Trans>Fields</Trans>
</h3>
</section>
<Button variant="ghost" size="sm" onClick={() => setIsMobileFieldsPanelOpen(false)}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
onFieldSelect={() => setIsMobileFieldsPanelOpen(false)}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.recipientId}
{selectedField.id}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
</>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
)}
</div>
);
@@ -70,18 +70,17 @@ export default function EnvelopeEditorHeader() {
return (
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
{/* The logo and separator are hidden on small viewports to leave room for the title. */}
<div className="flex min-w-0 flex-1 items-center space-x-4">
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="hidden h-6 w-auto sm:block" />
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
) : (
<Link to="/" className="hidden sm:block">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="hidden h-6 shrink-0 sm:block" />
<Separator orientation="vertical" className="h-6 shrink-0" />
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center space-x-2">
<EnvelopeItemTitleInput
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
@@ -99,19 +98,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
<Badge variant="secondary" className="shrink-0">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="hidden shrink-0 sm:inline-flex">
<Badge variant="orange" className="shrink-0">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="hidden shrink-0 sm:inline-flex">
<Badge variant="default" className="shrink-0">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
</Badge>
@@ -119,7 +118,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="hidden shrink-0 py-1 sm:flex"
className="shrink-0 py-1"
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
@@ -181,9 +180,9 @@ export default function EnvelopeEditorHeader() {
<div className="flex shrink-0 items-center space-x-2">
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" collapseLabelOnMobile />
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" collapseLabelOnMobile />
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
))}
{editorConfig.settings && (
@@ -203,10 +202,8 @@ export default function EnvelopeEditorHeader() {
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Send Document</Trans>
</span>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
@@ -215,10 +212,8 @@ export default function EnvelopeEditorHeader() {
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Resend Document</Trans>
</span>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
@@ -592,7 +592,7 @@ export const EnvelopeEditorRecipientForm = () => {
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-col justify-between gap-4 space-y-0 sm:flex-row">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>
<Trans>Recipients</Trans>
@@ -602,7 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
</CardDescription>
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<div className="flex flex-row items-center space-x-2">
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
@@ -842,9 +842,9 @@ export const EnvelopeEditorRecipientForm = () => {
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row flex-wrap items-center gap-2 sm:flex-nowrap">
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto hidden h-10 w-[4.25rem] flex-shrink-0 sm:block" />
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
@@ -900,9 +900,8 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.email,
})}
>
{/* Below `sm` the rows are stacked, so every row needs its own labels. */}
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -948,8 +947,8 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
@@ -448,7 +448,7 @@ export const EnvelopeEditorUploadPage = () => {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-4 md:p-8">
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
@@ -1,4 +1,3 @@
import { useIsMobileViewport } from '@documenso/lib/client-only/hooks/use-media-query';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
@@ -90,8 +89,6 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams();
const isMobileViewport = useIsMobileViewport();
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: {
@@ -104,10 +101,6 @@ export const EnvelopeEditor = () => {
},
} = editorConfig;
// Fall back to the icon-only sidebar on small viewports since the full
// sidebar would consume most of the screen.
const isSidebarMinimized = minimizeLeftSidebar || isMobileViewport;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
@@ -182,19 +175,19 @@ export const EnvelopeEditor = () => {
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-[100dvh] w-full bg-envelope-editor-background">
<div className="h-screen w-screen bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100dvh-4rem)] w-full">
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': isSidebarMinimized,
'w-14': minimizeLeftSidebar,
})}
>
{/* Left section step selector. */}
{isSidebarMinimized ? (
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
@@ -261,8 +254,8 @@ export const EnvelopeEditor = () => {
<div
className={cn('space-y-3', {
'px-4': !isSidebarMinimized,
'mt-4 flex flex-col items-center': isSidebarMinimized,
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
@@ -281,7 +274,7 @@ export const EnvelopeEditor = () => {
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`,
{
'p-3': !isSidebarMinimized,
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
@@ -297,7 +290,7 @@ export const EnvelopeEditor = () => {
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
</div>
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<div>
<div
className={`font-medium text-sm ${
@@ -319,17 +312,17 @@ export const EnvelopeEditor = () => {
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': isSidebarMinimized,
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': isSidebarMinimized,
'px-2': minimizeLeftSidebar,
})}
>
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<h4 className="font-semibold text-foreground text-sm">
<Trans>Quick Actions</Trans>
</h4>
@@ -341,7 +334,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span>
@@ -359,7 +352,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
@@ -374,7 +367,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
@@ -396,7 +389,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
@@ -414,7 +407,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span>
@@ -431,7 +424,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
@@ -451,7 +444,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
@@ -479,7 +472,7 @@ export const EnvelopeEditor = () => {
>
<Trash2Icon className="h-4 w-4" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span>
@@ -501,20 +494,20 @@ export const EnvelopeEditor = () => {
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
'px-2': isSidebarMinimized,
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': isSidebarMinimized,
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!isSidebarMinimized && (
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span>
@@ -1,34 +0,0 @@
import { useCallback, useSyncExternalStore } from 'react';
/**
* Tracks whether the given CSS media query currently matches.
*
* Returns `false` on the server since the viewport is unknown until hydration.
*/
export const useMediaQuery = (query: string) => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
const mediaQueryList = window.matchMedia(query);
mediaQueryList.addEventListener('change', onStoreChange);
return () => mediaQueryList.removeEventListener('change', onStoreChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// The server snapshot: the viewport is unknown during SSR, so we assume
// the query does not match rather than throwing. React re-renders with
// the real value after hydration, so the only cost is a brief first
// paint with the non-matching variant.
() => false,
);
};
/**
* Whether the viewport is below the Tailwind `md` breakpoint (768px).
*/
export const useIsMobileViewport = () => useMediaQuery('(max-width: 767px)');
@@ -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>
);