feat: polish envelopes (#2090)

## Description

The rest of the owl
This commit is contained in:
David Nguyen
2025-10-24 16:22:06 +11:00
committed by GitHub
parent 88836404d1
commit 03eb6af69a
141 changed files with 5171 additions and 2402 deletions

View File

@ -1,22 +1,25 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { type Field, FieldType } from '@prisma/client';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
import { handleInitialsFieldClick } from '~/utils/field-signing/initial-field';
@ -28,24 +31,13 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { i18n } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const {
envelopeData,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
@ -56,71 +48,39 @@ export default function EnvelopeSignerPageRenderer() {
setFullName,
signature,
setSignature,
selectedAssistantRecipientFields,
selectedAssistantRecipient,
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
console.log({ fullName });
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData;
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const localPageFields = useMemo(() => {
let fieldsToRender = recipientFields;
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
if (recipient.role === RecipientRole.ASSISTANT) {
fieldsToRender = selectedAssistantRecipientFields;
}
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
return fieldsToRender.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const localPageFields = useMemo(
() =>
recipientFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[recipientFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -137,6 +97,7 @@ export default function EnvelopeSignerPageRenderer() {
}
const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: fieldToRender.id.toString(),
@ -145,9 +106,11 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color,
mode: 'sign',
});
@ -158,19 +121,35 @@ export default function EnvelopeSignerPageRenderer() {
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
const foundField = recipientFields.find((f) => f.id === unparsedField.id);
const foundField = localPageFields.find((f) => f.id === unparsedField.id);
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
if (!foundField || foundLoadingGroup || foundField.fieldMeta?.readOnly) {
return;
}
const loadingSpinnerGroup = createSpinner({
fieldWidth,
fieldHeight,
});
let localEmail: string | null = email;
let localFullName: string | null = fullName;
let placeholderEmail: string | null = null;
fieldGroup.add(loadingSpinnerGroup);
if (recipient.role === RecipientRole.ASSISTANT) {
localEmail = selectedAssistantRecipient?.email || null;
localFullName = selectedAssistantRecipient?.name || null;
}
// Allows us let the user set a different email than their current logged in email.
if (isDirectTemplate) {
placeholderEmail = sessionData?.user?.email || email || recipient.email;
if (!placeholderEmail || placeholderEmail === DIRECT_TEMPLATE_RECIPIENT_EMAIL) {
placeholderEmail = null;
}
}
const loadingSpinnerGroup = createSpinner({
fieldWidth: fieldWidth / scale,
fieldHeight: fieldHeight / scale,
});
const parsedFoundField = ZFullFieldSchema.parse(foundField);
@ -179,34 +158,39 @@ export default function EnvelopeSignerPageRenderer() {
* CHECKBOX FIELD.
*/
.with({ type: FieldType.CHECKBOX }, (field) => {
const { fieldMeta } = field;
const clickedCheckboxIndex = Number(target.getAttr('internalCheckboxIndex'));
const { values } = fieldMeta;
if (Number.isNaN(clickedCheckboxIndex)) {
return;
}
const checkedValues = (values || [])
.map((v) => ({
...v,
checked: v.id === target.getAttr('internalCheckboxId') ? !v.checked : v.checked,
}))
.filter((v) => v.checked);
void signField(field.id, {
type: FieldType.CHECKBOX,
value: checkedValues.map((v) => v.id),
}).finally(() => {
loadingSpinnerGroup.destroy();
});
handleCheckboxFieldClick({ field, clickedCheckboxIndex })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
})
.finally(() => {
loadingSpinnerGroup.destroy();
});
})
/**
* RADIO FIELD.
*/
.with({ type: FieldType.RADIO }, (field) => {
const { fieldMeta } = foundField;
const selectedRadioIndex = Number(target.getAttr('internalRadioIndex'));
const fieldCustomText = Number(field.customText);
const checkedValue = target.getAttr('internalRadioValue');
if (Number.isNaN(selectedRadioIndex)) {
return;
}
fieldGroup.add(loadingSpinnerGroup);
// Uncheck the value if it's already pressed.
const value = field.inserted && checkedValue === field.customText ? null : checkedValue;
const value =
field.inserted && selectedRadioIndex === fieldCustomText ? null : selectedRadioIndex;
void signField(field.id, {
type: FieldType.RADIO,
@ -222,6 +206,7 @@ export default function EnvelopeSignerPageRenderer() {
handleNumberFieldClick({ field, number: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
})
@ -236,6 +221,7 @@ export default function EnvelopeSignerPageRenderer() {
handleTextFieldClick({ field, text: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
})
@ -247,9 +233,10 @@ export default function EnvelopeSignerPageRenderer() {
* EMAIL FIELD.
*/
.with({ type: FieldType.EMAIL }, (field) => {
handleEmailFieldClick({ field, email })
handleEmailFieldClick({ field, email: localEmail, placeholderEmail })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); // Todo: Envelopes - Handle errors
}
@ -265,11 +252,12 @@ export default function EnvelopeSignerPageRenderer() {
* INITIALS FIELD.
*/
.with({ type: FieldType.INITIALS }, (field) => {
const initials = fullName ? extractInitials(fullName) : null;
const initials = localFullName ? extractInitials(localFullName) : null;
handleInitialsFieldClick({ field, initials })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
})
@ -281,9 +269,10 @@ export default function EnvelopeSignerPageRenderer() {
* NAME FIELD.
*/
.with({ type: FieldType.NAME }, (field) => {
handleNameFieldClick({ field, name: fullName })
handleNameFieldClick({ field, name: localFullName })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
@ -302,6 +291,7 @@ export default function EnvelopeSignerPageRenderer() {
handleDropdownFieldClick({ field, text: null })
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
@ -315,6 +305,8 @@ export default function EnvelopeSignerPageRenderer() {
* DATE FIELD.
*/
.with({ type: FieldType.DATE }, (field) => {
fieldGroup.add(loadingSpinnerGroup);
void signField(field.id, {
type: FieldType.DATE,
value: !field.inserted,
@ -336,6 +328,7 @@ export default function EnvelopeSignerPageRenderer() {
})
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
@ -348,38 +341,22 @@ export default function EnvelopeSignerPageRenderer() {
});
})
.exhaustive();
console.log('Field clicked');
};
fieldGroup.off('click');
fieldGroup.on('click', handleFieldGroupClick);
fieldGroup.off('pointerdown');
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
console.log({
localPageFields,
});
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}
pageLayer.current.batchDraw();
currentPageLayer.batchDraw();
};
/**
@ -392,25 +369,61 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
/**
* Rerender the whole page if the selected assistant recipient changes.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// Rerender the whole page.
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
color="warning"
>
<Trans>Click to insert field</Trans>
</EnvelopeFieldToolTip>
)}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);