mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
feat: render signatures on pending envelopes (#2743)
This commit is contained in:
+17
-5
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex w-screen flex-col items-center justify-center gap-2 py-64 text-foreground">
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
+20
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user