diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 9e2b1e4aa..ad7359c3f 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -25,12 +25,17 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender envelopeStatus, currentEnvelopeItem, fields, + signatures, recipients, getRecipientColorKey, setRenderError, overrideSettings, } = useCurrentEnvelopeRender(); + const signaturesByFieldId = useMemo(() => { + return new Map(signatures.map((signature) => [signature.fieldId, signature])); + }, [signatures]); + const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer( ({ stage, pageLayer }) => { createPageCanvas(stage, pageLayer); @@ -80,6 +85,16 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender const fieldTranslations = getClientSideFieldTranslations(i18n); + // Look up an inserted signature for this field. If we don't have one (e.g. + // the signatures haven't been loaded, or the field hasn't been signed yet) + // fall back to a placeholder so the field still renders something. + const insertedSignature = signaturesByFieldId.get(field.id); + + const signature = insertedSignature ?? { + signatureImageAsBase64: '', + typedSignature: fieldTranslations.SIGNATURE, + }; + renderField({ scale, pageLayer: pageLayer.current, @@ -91,10 +106,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender positionX: Number(field.positionX), positionY: Number(field.positionY), fieldMeta: field.fieldMeta, - signature: { - signatureImageAsBase64: '', - typedSignature: fieldTranslations.SIGNATURE, - }, + signature, }, translations: fieldTranslations, pageWidth: unscaledViewport.width, @@ -150,7 +162,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender }); pageLayer.current.batchDraw(); - }, [localPageFields]); + }, [localPageFields, signaturesByFieldId]); if (!currentEnvelopeItem) { return null; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index aac61078f..534cc1ab2 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -62,6 +62,17 @@ export default function DocumentPage({ params }: Route.ComponentProps) { }, ); + const { data: fieldSignatures } = trpc.envelope.field.getSignatures.useQuery( + { + envelopeId: params.id, + }, + { + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + enabled: + envelope && envelope.internalVersion === 2 && envelope.status === DocumentStatus.PENDING, + }, + ); + if (isLoadingEnvelope) { return (
@@ -159,6 +170,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) { envelopeItems={envelope.envelopeItems} token={undefined} fields={envelope.fields} + signatures={fieldSignatures} recipients={envelope.recipients} overrideSettings={{ showRecipientSigningStatus: true, diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index c66b0a38f..101488315 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -11,6 +11,18 @@ import { getRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TEnvelope } from '../../types/envelope'; import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; +/** + * The signature data for an inserted signature field. + * + * Loaded separately from the envelope to avoid bloating the envelope.get response + * with potentially large base64 image payloads. + */ +export type EnvelopeRenderFieldSignature = { + fieldId: number; + signatureImageAsBase64: string | null; + typedSignature: string | null; +}; + export type PageRenderData = { scale: number; pageIndex: number; @@ -50,6 +62,7 @@ type EnvelopeRenderProviderValue = { currentEnvelopeItem: EnvelopeRenderItem | null; setCurrentEnvelopeItem: (envelopeItemId: string) => void; fields: Field[]; + signatures: EnvelopeRenderFieldSignature[]; recipients: Pick[]; getRecipientColorKey: (recipientId: number) => TRecipientColor; @@ -89,6 +102,15 @@ interface EnvelopeRenderProviderProps { */ fields?: Field[]; + /** + * Optional inserted signature data for signature fields. + * + * Fetched separately from the envelope to keep the envelope response lean. + * If a signature field has no entry here, the renderer will fall back to + * showing the field type placeholder. + */ + signatures?: EnvelopeRenderFieldSignature[]; + /** * Optional recipient used to determine the color of the fields and hover * previews. @@ -137,6 +159,7 @@ export const EnvelopeRenderProvider = ({ envelope, envelopeItems: envelopeItemsFromProps, fields, + signatures, token, presignToken, recipients = [], @@ -212,6 +235,7 @@ export const EnvelopeRenderProvider = ({ currentEnvelopeItem: currentItem, setCurrentEnvelopeItem, fields: fields ?? [], + signatures: signatures ?? [], recipients, getRecipientColorKey, renderError, diff --git a/packages/lib/universal/field-renderer/render-signature-field.ts b/packages/lib/universal/field-renderer/render-signature-field.ts index c289770da..d13602b63 100644 --- a/packages/lib/universal/field-renderer/render-signature-field.ts +++ b/packages/lib/universal/field-renderer/render-signature-field.ts @@ -40,6 +40,52 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig }; }; +/** + * Build a Konva.Image for a base64 signature, sized to fit within the given + * field dimensions. Works in both browser and Node.js (via skia-canvas). + */ +const createSignatureImage = ( + signatureImageAsBase64: string, + fieldWidth: number, + fieldHeight: number, +): Konva.Image => { + if (typeof window !== 'undefined') { + const img = new Image(); + + const image = new Konva.Image({ + image: img, + x: 0, + y: 0, + width: fieldWidth, + height: fieldHeight, + }); + + img.onload = () => { + image.setAttrs({ + image: img, + ...getImageDimensions(img, fieldWidth, fieldHeight), + }); + }; + + img.src = signatureImageAsBase64; + + return image; + } + + // Node.js with skia-canvas + if (!SkiaImage) { + throw new Error('Skia image not found'); + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const img = new SkiaImage(signatureImageAsBase64) as unknown as HTMLImageElement; + + return new Konva.Image({ + image: img, + ...getImageDimensions(img, fieldWidth, fieldHeight), + }); +}; + const createFieldSignature = ( field: FieldToRender, options: RenderFieldElementOptions, @@ -67,6 +113,16 @@ const createFieldSignature = ( // Handle edit mode. if (mode === 'edit') { textToRender = fieldTypeName; + + // If the field has already been signed and we have the signature data + // available, render it. Otherwise leave the field type label as a placeholder. + if (field.inserted && signature?.typedSignature) { + textToRender = signature.typedSignature; + } + + if (field.inserted && signature?.signatureImageAsBase64) { + return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight); + } } // Handle sign mode. @@ -82,44 +138,7 @@ const createFieldSignature = ( } if (signature?.signatureImageAsBase64) { - if (typeof window !== 'undefined') { - // Create a new HTML Image element - const img = new Image(); - - const image = new Konva.Image({ - image: img, - x: 0, - y: 0, - width: fieldWidth, - height: fieldHeight, - }); - - img.onload = () => { - image.setAttrs({ - image: img, - ...getImageDimensions(img, fieldWidth, fieldHeight), - }); - }; - - img.src = signature.signatureImageAsBase64; - - return image; - } else { - // Node.js with skia-canvas - if (!SkiaImage) { - throw new Error('Skia image not found'); - } - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement; - - const image = new Konva.Image({ - image: img, - ...getImageDimensions(img, fieldWidth, fieldHeight), - }); - - return image; - } + return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight); } } diff --git a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts new file mode 100644 index 000000000..4f3c5e7e1 --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.ts @@ -0,0 +1,65 @@ +import { FieldType } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../../trpc'; +import { + ZGetEnvelopeFieldSignaturesRequestSchema, + ZGetEnvelopeFieldSignaturesResponseSchema, +} from './get-envelope-field-signatures.types'; + +export const getEnvelopeFieldSignaturesRoute = authenticatedProcedure + .input(ZGetEnvelopeFieldSignaturesRequestSchema) + .output(ZGetEnvelopeFieldSignaturesResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeId } = input; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + // Validate the user has access to the envelope. + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + type: null, + userId: user.id, + teamId, + }); + + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + include: { + fields: { + where: { + inserted: true, + type: FieldType.SIGNATURE, + }, + include: { + signature: true, + }, + }, + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + + const signatures = envelope.fields.map((field) => ({ + fieldId: field.id, + signatureImageAsBase64: field.signature?.signatureImageAsBase64 ?? null, + typedSignature: field.signature?.typedSignature ?? null, + })); + + return signatures; + }); diff --git a/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.types.ts b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.types.ts new file mode 100644 index 000000000..d2fefaa17 --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-fields/get-envelope-field-signatures.types.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const ZGetEnvelopeFieldSignaturesRequestSchema = z.object({ + envelopeId: z.string().min(1), +}); + +export const ZGetEnvelopeFieldSignaturesResponseSchema = z + .object({ + fieldId: z.number(), + signatureImageAsBase64: z.string().nullable(), + typedSignature: z.string().nullable(), + }) + .array(); + +export type TGetEnvelopeFieldSignaturesRequest = z.infer< + typeof ZGetEnvelopeFieldSignaturesRequestSchema +>; +export type TGetEnvelopeFieldSignaturesResponse = z.infer< + typeof ZGetEnvelopeFieldSignaturesResponseSchema +>; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index faa20d03f..83b8281b8 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -15,6 +15,7 @@ import { duplicateEnvelopeRoute } from './duplicate-envelope'; import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields'; import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field'; import { getEnvelopeFieldRoute } from './envelope-fields/get-envelope-field'; +import { getEnvelopeFieldSignaturesRoute } from './envelope-fields/get-envelope-field-signatures'; import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fields'; import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients'; import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient'; @@ -68,6 +69,7 @@ export const envelopeRouter = router({ }, field: { get: getEnvelopeFieldRoute, + getSignatures: getEnvelopeFieldSignaturesRoute, createMany: createEnvelopeFieldsRoute, updateMany: updateEnvelopeFieldsRoute, delete: deleteEnvelopeFieldRoute,