From 1a9dcadba5876e89fe2e684a1ccf1675fae40103 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 18 Oct 2024 04:25:19 +0100 Subject: [PATCH 1/8] feat: add typed signature (#1357) Add the ability to insert typed signatures. Once the signature field is placed on the document, a checkbox appears in the document editor where the document owner can allow signers to add typed signatures. Typed signatures are disabled by default. ![CleanShot 2024-09-30 at 14 57 54](https://github.com/user-attachments/assets/c388abb5-bcb1-49d0-aad8-9148c3020420) --- .../documents/[id]/edit-document.tsx | 24 + .../src/app/(signing)/sign/[token]/form.tsx | 6 +- .../sign/[token]/signature-field.tsx | 9 +- .../sign/[token]/signing-page-view.tsx | 7 +- .../document-meta/upsert-document-meta.ts | 4 + packages/lib/translations/de/common.po | 50 +- packages/lib/translations/de/marketing.js | 1 - packages/lib/translations/de/web.js | 1 - packages/lib/translations/de/web.po | 68 ++- packages/lib/translations/en/common.po | 50 +- packages/lib/translations/en/marketing.js | 1 - packages/lib/translations/en/web.js | 1 - packages/lib/translations/en/web.po | 68 ++- packages/lib/translations/fr/common.po | 50 +- packages/lib/translations/fr/marketing.js | 1 - packages/lib/translations/fr/web.js | 1 - packages/lib/translations/fr/web.po | 60 +- .../migration.sql | 2 + .../migration.sql | 9 + packages/prisma/schema.prisma | 21 +- .../trpc/server/document-router/router.ts | 41 ++ .../trpc/server/document-router/schema.ts | 10 + .../primitives/document-flow/add-fields.tsx | 553 ++++++++++-------- .../document-flow/add-fields.types.ts | 1 + .../signature-pad/signature-pad.tsx | 139 ++++- 25 files changed, 716 insertions(+), 462 deletions(-) delete mode 100644 packages/lib/translations/de/marketing.js delete mode 100644 packages/lib/translations/de/web.js delete mode 100644 packages/lib/translations/en/marketing.js delete mode 100644 packages/lib/translations/en/web.js delete mode 100644 packages/lib/translations/fr/marketing.js delete mode 100644 packages/lib/translations/fr/web.js create mode 100644 packages/prisma/migrations/20240920084713_add_typed_signature_option/migration.sql create mode 100644 packages/prisma/migrations/20241017035042_rename_typed_signature_option/migration.sql diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 9c37ab7b7..571ca535f 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -112,6 +112,24 @@ export const EditDocumentForm = ({ }, }); + const { mutateAsync: updateTypedSignature } = + trpc.document.updateTypedSignatureSettings.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ + ...(oldData || initialDocument), + ...newData, + id: Number(newData.id), + }), + ); + }, + }); + const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newRecipients) => { @@ -258,6 +276,11 @@ export const EditDocumentForm = ({ fields: data.fields, }); + await updateTypedSignature({ + documentId: document.id, + typedSignatureEnabled: data.typedSignatureEnabled, + }); + // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); @@ -387,6 +410,7 @@ export const EditDocumentForm = ({ fields={fields} onSubmit={onAddFieldsFormSubmit} isDocumentPdfLoaded={isDocumentPdfLoaded} + typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} teamId={team?.id} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 51918cec8..b3f3a0587 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -9,9 +9,10 @@ import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; -import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; +import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; @@ -25,7 +26,7 @@ import { useRequiredSigningContext } from './provider'; import { SignDialog } from './sign-dialog'; export type SigningFormProps = { - document: Document; + document: DocumentAndSender; recipient: Recipient; fields: Field[]; redirectUrl?: string | null; @@ -196,6 +197,7 @@ export const SigningForm = ({ onChange={(value) => { setSignature(value); }} + allowTypedSignature={document.documentMeta?.typedSignatureEnabled} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 990dfe057..b564ea11c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -31,12 +31,12 @@ import { useRequiredSigningContext } from './provider'; import { SigningFieldContainer } from './signing-field-container'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; - export type SignatureFieldProps = { field: FieldWithSignature; recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; + typedSignatureEnabled?: boolean; }; export const SignatureField = ({ @@ -44,6 +44,7 @@ export const SignatureField = ({ recipient, onSignField, onUnsignField, + typedSignatureEnabled, }: SignatureFieldProps) => { const router = useRouter(); @@ -92,14 +93,12 @@ export const SignatureField = ({ return true; }; - /** * When the user clicks the sign button in the dialog where they enter their signature. */ const onDialogSignClick = () => { setShowSignatureModal(false); setProvidedSignature(localSignature); - if (!localSignature) { return; } @@ -109,7 +108,6 @@ export const SignatureField = ({ actionTarget: field.type, }); }; - const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => { try { const value = signature || providedSignature; @@ -231,11 +229,11 @@ export const SignatureField = ({ id="signature" className="border-border mt-2 h-44 w-full rounded-md border" onChange={(value) => setLocalSignature(value)} + allowTypedSignature={typedSignatureEnabled} /> -
- +
+ ( + + + field.onChange(checked)} + disabled={form.formState.isSubmitting} + /> + - + + Enable Typed Signatures + + + )} + /> - + + +

+ Signature +

+
+
+ - + + +

+ + Initials +

+
+
+ - + + +

+ + Email +

+
+
+ - + + +

+ + Name +

+
+
+ - + + +

+ + Date +

+
+
+ - + + +

+ + Text +

+
+
+ - + + +

+ + Number +

+
+
+ - - -
+ + +

+ + Radio +

+
+
+ + + + + + + + @@ -1059,8 +1093,9 @@ export const AddFieldsFormPartial = ({ { previousStep(); remove(); diff --git a/packages/ui/primitives/document-flow/add-fields.types.ts b/packages/ui/primitives/document-flow/add-fields.types.ts index 7309250a8..4d9c89e73 100644 --- a/packages/ui/primitives/document-flow/add-fields.types.ts +++ b/packages/ui/primitives/document-flow/add-fields.types.ts @@ -18,6 +18,7 @@ export const ZAddFieldsFormSchema = z.object({ fieldMeta: ZFieldMetaSchema, }), ), + typedSignatureEnabled: z.boolean(), }); export type TAddFieldsFormSchema = z.infer; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 6859d21ec..82e6b52b3 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -3,12 +3,15 @@ import type { HTMLAttributes, MouseEvent, PointerEvent, TouchEvent } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { Caveat } from 'next/font/google'; + import { Trans } from '@lingui/macro'; import { Undo2 } from 'lucide-react'; import type { StrokeOptions } from 'perfect-freehand'; import { getStroke } from 'perfect-freehand'; import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; +import { Input } from '@documenso/ui/primitives/input'; import { Select, SelectContent, @@ -21,12 +24,20 @@ import { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; +const fontCaveat = Caveat({ + weight: ['500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-caveat', +}); + const DPI = 2; export type SignaturePadProps = Omit, 'onChange'> & { onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; disabled?: boolean; + allowTypedSignature?: boolean; }; export const SignaturePad = ({ @@ -35,6 +46,7 @@ export const SignaturePad = ({ defaultValue, onChange, disabled = false, + allowTypedSignature, ...props }: SignaturePadProps) => { const $el = useRef(null); @@ -44,6 +56,7 @@ export const SignaturePad = ({ const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); const [selectedColor, setSelectedColor] = useState('black'); + const [typedSignature, setTypedSignature] = useState(''); const perfectFreehandOptions = useMemo(() => { const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; @@ -181,34 +194,107 @@ export const SignaturePad = ({ onChange?.(null); + setTypedSignature(''); setLines([]); setCurrentLine([]); }; + const renderTypedSignature = () => { + if ($el.current && typedSignature) { + const ctx = $el.current.getContext('2d'); + + if (ctx) { + const canvasWidth = $el.current.width; + const canvasHeight = $el.current.height; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = selectedColor; + + // Calculate the desired width (25ch) + const desiredWidth = canvasWidth * 0.85; // 85% of canvas width + + // Start with a base font size + let fontSize = 18; + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + + // Measure 10 characters and calculate scale factor + const characterWidth = ctx.measureText('m'.repeat(10)).width; + const scaleFactor = desiredWidth / characterWidth; + + // Apply scale factor to font size + fontSize = fontSize * scaleFactor; + + // Adjust font size if it exceeds canvas width + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + + const textWidth = ctx.measureText(typedSignature).width; + + if (textWidth > desiredWidth) { + fontSize = fontSize * (desiredWidth / textWidth); + } + + // Set final font and render text + ctx.font = `${fontSize}px ${fontCaveat.style.fontFamily}`; + ctx.fillText(typedSignature, canvasWidth / 2, canvasHeight / 2); + } + } + }; + + const handleTypedSignatureChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setTypedSignature(newValue); + + if (newValue.trim() !== '') { + onChange?.($el.current?.toDataURL() || null); + } else { + onChange?.(null); + } + }; + + useEffect(() => { + if (typedSignature.trim() !== '') { + renderTypedSignature(); + onChange?.($el.current?.toDataURL() || null); + } else { + onClearClick(); + } + }, [typedSignature, selectedColor]); + const onUndoClick = () => { - if (lines.length === 0) { + if (lines.length === 0 && typedSignature.length === 0) { return; } - const newLines = lines.slice(0, -1); - setLines(newLines); + if (typedSignature.length > 0) { + const newTypedSignature = typedSignature.slice(0, -1); + setTypedSignature(newTypedSignature); + // You might want to call onChange here as well + // onChange?.(newTypedSignature); + } else { + const newLines = lines.slice(0, -1); + setLines(newLines); - // Clear the canvas - if ($el.current) { - const ctx = $el.current.getContext('2d'); - const { width, height } = $el.current; - ctx?.clearRect(0, 0, width, height); + // Clear and redraw the canvas + if ($el.current) { + const ctx = $el.current.getContext('2d'); + const { width, height } = $el.current; + ctx?.clearRect(0, 0, width, height); - if (typeof defaultValue === 'string' && $imageData.current) { - ctx?.putImageData($imageData.current, 0, 0); + if (typeof defaultValue === 'string' && $imageData.current) { + ctx?.putImageData($imageData.current, 0, 0); + } + + newLines.forEach((line) => { + const pathData = new Path2D( + getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)), + ); + ctx?.fill(pathData); + }); + + onChange?.($el.current.toDataURL()); } - - newLines.forEach((line) => { - const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); - ctx?.fill(pathData); - }); - - onChange?.($el.current.toDataURL()); } }; @@ -263,6 +349,21 @@ export const SignaturePad = ({ {...props} /> + {allowTypedSignature && ( +
0 || typedSignature.length > 0, + })} + > + +
+ )} +
+ + + + )} + /> + )} + Date: Wed, 23 Oct 2024 13:28:54 +1100 Subject: [PATCH 8/8] v1.7.2-rc.1 --- apps/marketing/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 52d0d5de8..de0b41c88 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -1,6 +1,6 @@ { "name": "@documenso/marketing", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "private": true, "license": "AGPL-3.0", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 235755674..f8c996cfb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@documenso/web", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "private": true, "license": "AGPL-3.0", "scripts": { diff --git a/package-lock.json b/package-lock.json index 4f804a363..0432c882d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "workspaces": [ "apps/*", "packages/*" @@ -80,7 +80,7 @@ }, "apps/marketing": { "name": "@documenso/marketing", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "license": "AGPL-3.0", "dependencies": { "@documenso/assets": "*", @@ -441,7 +441,7 @@ }, "apps/web": { "name": "@documenso/web", - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "license": "AGPL-3.0", "dependencies": { "@documenso/api": "*", diff --git a/package.json b/package.json index 92bcb46c0..2f0e6d146 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "1.7.2-rc.0", + "version": "1.7.2-rc.1", "scripts": { "build": "turbo run build", "build:web": "turbo run build --filter=@documenso/web",