feat: render signatures on pending envelopes (#2743)

This commit is contained in:
David Nguyen
2026-04-30 14:43:48 +10:00
committed by GitHub
parent ae497092d7
commit 5d92aaf20a
7 changed files with 197 additions and 43 deletions
@@ -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<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
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,
@@ -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);
}
}
@@ -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;
});
@@ -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
>;
@@ -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,