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,
|
envelopeStatus,
|
||||||
currentEnvelopeItem,
|
currentEnvelopeItem,
|
||||||
fields,
|
fields,
|
||||||
|
signatures,
|
||||||
recipients,
|
recipients,
|
||||||
getRecipientColorKey,
|
getRecipientColorKey,
|
||||||
setRenderError,
|
setRenderError,
|
||||||
overrideSettings,
|
overrideSettings,
|
||||||
} = useCurrentEnvelopeRender();
|
} = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const signaturesByFieldId = useMemo(() => {
|
||||||
|
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
|
||||||
|
}, [signatures]);
|
||||||
|
|
||||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||||
({ stage, pageLayer }) => {
|
({ stage, pageLayer }) => {
|
||||||
createPageCanvas(stage, pageLayer);
|
createPageCanvas(stage, pageLayer);
|
||||||
@@ -80,6 +85,16 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
|||||||
|
|
||||||
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
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({
|
renderField({
|
||||||
scale,
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
@@ -91,10 +106,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
|||||||
positionX: Number(field.positionX),
|
positionX: Number(field.positionX),
|
||||||
positionY: Number(field.positionY),
|
positionY: Number(field.positionY),
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
signature: {
|
signature,
|
||||||
signatureImageAsBase64: '',
|
|
||||||
typedSignature: fieldTranslations.SIGNATURE,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
translations: fieldTranslations,
|
translations: fieldTranslations,
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: unscaledViewport.width,
|
||||||
@@ -150,7 +162,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
|||||||
});
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
}, [localPageFields]);
|
}, [localPageFields, signaturesByFieldId]);
|
||||||
|
|
||||||
if (!currentEnvelopeItem) {
|
if (!currentEnvelopeItem) {
|
||||||
return null;
|
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) {
|
if (isLoadingEnvelope) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-screen flex-col items-center justify-center gap-2 py-64 text-foreground">
|
<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}
|
envelopeItems={envelope.envelopeItems}
|
||||||
token={undefined}
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
|
signatures={fieldSignatures}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
overrideSettings={{
|
overrideSettings={{
|
||||||
showRecipientSigningStatus: true,
|
showRecipientSigningStatus: true,
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
|||||||
import type { TEnvelope } from '../../types/envelope';
|
import type { TEnvelope } from '../../types/envelope';
|
||||||
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
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 = {
|
export type PageRenderData = {
|
||||||
scale: number;
|
scale: number;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -50,6 +62,7 @@ type EnvelopeRenderProviderValue = {
|
|||||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
signatures: EnvelopeRenderFieldSignature[];
|
||||||
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||||
|
|
||||||
@@ -89,6 +102,15 @@ interface EnvelopeRenderProviderProps {
|
|||||||
*/
|
*/
|
||||||
fields?: Field[];
|
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
|
* Optional recipient used to determine the color of the fields and hover
|
||||||
* previews.
|
* previews.
|
||||||
@@ -137,6 +159,7 @@ export const EnvelopeRenderProvider = ({
|
|||||||
envelope,
|
envelope,
|
||||||
envelopeItems: envelopeItemsFromProps,
|
envelopeItems: envelopeItemsFromProps,
|
||||||
fields,
|
fields,
|
||||||
|
signatures,
|
||||||
token,
|
token,
|
||||||
presignToken,
|
presignToken,
|
||||||
recipients = [],
|
recipients = [],
|
||||||
@@ -212,6 +235,7 @@ export const EnvelopeRenderProvider = ({
|
|||||||
currentEnvelopeItem: currentItem,
|
currentEnvelopeItem: currentItem,
|
||||||
setCurrentEnvelopeItem,
|
setCurrentEnvelopeItem,
|
||||||
fields: fields ?? [],
|
fields: fields ?? [],
|
||||||
|
signatures: signatures ?? [],
|
||||||
recipients,
|
recipients,
|
||||||
getRecipientColorKey,
|
getRecipientColorKey,
|
||||||
renderError,
|
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 = (
|
const createFieldSignature = (
|
||||||
field: FieldToRender,
|
field: FieldToRender,
|
||||||
options: RenderFieldElementOptions,
|
options: RenderFieldElementOptions,
|
||||||
@@ -67,6 +113,16 @@ const createFieldSignature = (
|
|||||||
// Handle edit mode.
|
// Handle edit mode.
|
||||||
if (mode === 'edit') {
|
if (mode === 'edit') {
|
||||||
textToRender = fieldTypeName;
|
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.
|
// Handle sign mode.
|
||||||
@@ -82,44 +138,7 @@ const createFieldSignature = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (signature?.signatureImageAsBase64) {
|
if (signature?.signatureImageAsBase64) {
|
||||||
if (typeof window !== 'undefined') {
|
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
|
||||||
import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field';
|
import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field';
|
||||||
import { getEnvelopeFieldRoute } from './envelope-fields/get-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 { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fields';
|
||||||
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
|
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
|
||||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||||
@@ -68,6 +69,7 @@ export const envelopeRouter = router({
|
|||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
get: getEnvelopeFieldRoute,
|
get: getEnvelopeFieldRoute,
|
||||||
|
getSignatures: getEnvelopeFieldSignaturesRoute,
|
||||||
createMany: createEnvelopeFieldsRoute,
|
createMany: createEnvelopeFieldsRoute,
|
||||||
updateMany: updateEnvelopeFieldsRoute,
|
updateMany: updateEnvelopeFieldsRoute,
|
||||||
delete: deleteEnvelopeFieldRoute,
|
delete: deleteEnvelopeFieldRoute,
|
||||||
|
|||||||
Reference in New Issue
Block a user