mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 16:51:38 +10:00
Merge branch 'main' into feat/auto-placing-fields
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import type { TCheckboxFieldMeta } from '../types/field-meta';
|
||||
@ -75,3 +77,15 @@ export const validateCheckboxField = (
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateCheckboxLength = (
|
||||
numberOfSelectedOptions: number,
|
||||
validationRule: '>=' | '=' | '<=',
|
||||
validationLength: number,
|
||||
) => {
|
||||
return match(validationRule)
|
||||
.with('>=', () => numberOfSelectedOptions >= validationLength)
|
||||
.with('=', () => numberOfSelectedOptions === validationLength)
|
||||
.with('<=', () => numberOfSelectedOptions <= validationLength)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@ -29,7 +29,7 @@ export const validateNumberField = (
|
||||
errors.push('Value is required');
|
||||
}
|
||||
|
||||
if (!/^[0-9,.]+$/.test(value.trim())) {
|
||||
if ((isSigningPage || value.length > 0) && !/^[0-9,.]+$/.test(value.trim())) {
|
||||
errors.push(`Value is not a valid number`);
|
||||
}
|
||||
|
||||
|
||||
@ -50,6 +50,7 @@ type UseEditorFieldsResponse = {
|
||||
|
||||
// Field operations
|
||||
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
|
||||
setFieldId: (formId: string, id: number) => void;
|
||||
removeFieldsByFormId: (formIds: string[]) => void;
|
||||
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
|
||||
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
|
||||
@ -123,7 +124,6 @@ export const useEditorFields = ({
|
||||
}
|
||||
|
||||
if (bypassCheck) {
|
||||
console.log(3);
|
||||
setSelectedFieldFormId(formId);
|
||||
return;
|
||||
}
|
||||
@ -136,6 +136,7 @@ export const useEditorFields = ({
|
||||
const field: TLocalField = {
|
||||
...fieldData,
|
||||
formId: nanoid(12),
|
||||
...restrictFieldPosValues(fieldData),
|
||||
};
|
||||
|
||||
append(field);
|
||||
@ -160,12 +161,31 @@ export const useEditorFields = ({
|
||||
[localFields, remove, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const setFieldId = (formId: string, id: number) => {
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, {
|
||||
...localFields[index],
|
||||
id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateFieldByFormId = useCallback(
|
||||
(formId: string, updates: Partial<TLocalField>) => {
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, { ...localFields[index], ...updates });
|
||||
const updatedField = {
|
||||
...localFields[index],
|
||||
...updates,
|
||||
};
|
||||
|
||||
update(index, {
|
||||
...updatedField,
|
||||
...restrictFieldPosValues(updatedField),
|
||||
});
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
@ -261,6 +281,7 @@ export const useEditorFields = ({
|
||||
|
||||
// Field operations
|
||||
addField,
|
||||
setFieldId,
|
||||
removeFieldsByFormId,
|
||||
updateFieldByFormId,
|
||||
duplicateField,
|
||||
@ -279,3 +300,14 @@ export const useEditorFields = ({
|
||||
setSelectedRecipient,
|
||||
};
|
||||
};
|
||||
|
||||
const restrictFieldPosValues = (
|
||||
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
|
||||
) => {
|
||||
return {
|
||||
positionX: Math.max(0, Math.min(100, field.positionX)),
|
||||
positionY: Math.max(0, Math.min(100, field.positionY)),
|
||||
width: Math.max(0, Math.min(100, field.width)),
|
||||
height: Math.max(0, Math.min(100, field.height)),
|
||||
};
|
||||
};
|
||||
|
||||
126
packages/lib/client-only/hooks/use-page-renderer.ts
Normal file
126
packages/lib/client-only/hooks/use-page-renderer.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import Konva from 'konva';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
|
||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
||||
|
||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stage = useRef<Konva.Stage | null>(null);
|
||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
||||
|
||||
/**
|
||||
* The raw viewport with no scaling. Basically the actual PDF size.
|
||||
*/
|
||||
const unscaledViewport = useMemo(
|
||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
* The viewport scaled according to page width.
|
||||
*/
|
||||
const scaledViewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
* Viewport with the device pixel ratio applied so we can render the PDF
|
||||
* in a higher resolution.
|
||||
*/
|
||||
const renderViewport = useMemo(
|
||||
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the PDF and create the scaled Konva stage.
|
||||
*/
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: kContainer } = konvaContainer;
|
||||
|
||||
if (!canvas || !kContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = renderViewport.width;
|
||||
canvas.height = renderViewport.height;
|
||||
|
||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport: renderViewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
stage.current = new Konva.Stage({
|
||||
container: kContainer,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
scale: {
|
||||
x: scale,
|
||||
y: scale,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
|
||||
stage.current.add(pageLayer.current);
|
||||
|
||||
renderFunction({
|
||||
stage: stage.current,
|
||||
pageLayer: pageLayer.current,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, scaledViewport],
|
||||
);
|
||||
|
||||
return {
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
stage,
|
||||
pageLayer,
|
||||
unscaledViewport,
|
||||
scaledViewport,
|
||||
pageContext,
|
||||
};
|
||||
}
|
||||
@ -5,15 +5,14 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import {
|
||||
AVAILABLE_RECIPIENT_COLORS,
|
||||
getRecipientColorStyles,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
|
||||
@ -38,25 +37,35 @@ export const useDebounceFunction = <Args extends unknown[]>(
|
||||
);
|
||||
};
|
||||
|
||||
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
envelope: TEnvelope;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||
|
||||
getFieldColor: (field: TLocalField) => RecipientColorStyles;
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
editorFields: ReturnType<typeof useEditorFields>;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => void;
|
||||
flushAutosave: () => Promise<void>;
|
||||
autosaveError: boolean;
|
||||
|
||||
relativePath: {
|
||||
basePath: string;
|
||||
envelopePath: string;
|
||||
editorPath: string;
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
syncEnvelope: () => Promise<void>;
|
||||
// refetchEnvelope: () => Promise<void>;
|
||||
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
|
||||
};
|
||||
@ -86,12 +95,15 @@ export const EnvelopeEditorProvider = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
});
|
||||
|
||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
||||
onSuccess: (response, input) => {
|
||||
console.log(input.meta?.emailSettings);
|
||||
setEnvelope({
|
||||
...envelope,
|
||||
...response,
|
||||
@ -106,7 +118,9 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@ -122,7 +136,9 @@ export const EnvelopeEditorProvider = ({
|
||||
onSuccess: () => {
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@ -135,10 +151,17 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@ -166,63 +189,65 @@ export const EnvelopeEditorProvider = ({
|
||||
triggerSave: setFieldsDebounced,
|
||||
flush: setFieldsAsync,
|
||||
isPending: isFieldsMutationPending,
|
||||
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
|
||||
await envelopeFieldSetMutationQuery.mutateAsync({
|
||||
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
|
||||
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields,
|
||||
fields: localFields,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Insert the IDs into the local fields.
|
||||
envelopeFields.fields.forEach((field) => {
|
||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||
|
||||
if (localField && !localField.id) {
|
||||
localField.id = field.id;
|
||||
|
||||
editorFields.setFieldId(localField.formId, field.id);
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
const {
|
||||
triggerSave: setEnvelopeDebounced,
|
||||
flush: setEnvelopeAsync,
|
||||
isPending: isEnvelopeMutationPending,
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
data: {
|
||||
...envelopeUpdates,
|
||||
},
|
||||
data: envelopeUpdates.data,
|
||||
meta: envelopeUpdates.meta,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
/**
|
||||
* Updates the local envelope and debounces the update to the server.
|
||||
*/
|
||||
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
|
||||
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
...envelopeUpdates.data,
|
||||
meta: {
|
||||
...prev.documentMeta,
|
||||
...envelopeUpdates.meta,
|
||||
},
|
||||
}));
|
||||
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
});
|
||||
|
||||
const getFieldColor = useCallback(
|
||||
(field: TLocalField) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === field.recipientId,
|
||||
);
|
||||
|
||||
return getRecipientColorStyles(Math.max(recipientIndex, 0));
|
||||
},
|
||||
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||
);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === recipientId,
|
||||
);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
|
||||
return AVAILABLE_RECIPIENT_COLORS[
|
||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
||||
];
|
||||
},
|
||||
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||
@ -234,6 +259,21 @@ export const EnvelopeEditorProvider = ({
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch and sycn the envelope back into the editor.
|
||||
*
|
||||
* Overrides everything.
|
||||
*/
|
||||
const syncEnvelope = async () => {
|
||||
await flushAutosave();
|
||||
|
||||
const fetchedEnvelopeData = await reloadEnvelope();
|
||||
|
||||
if (fetchedEnvelopeData.data) {
|
||||
setEnvelope(fetchedEnvelopeData.data);
|
||||
}
|
||||
};
|
||||
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||
};
|
||||
@ -256,10 +296,23 @@ export const EnvelopeEditorProvider = ({
|
||||
isEnvelopeMutationPending,
|
||||
]);
|
||||
|
||||
const flushAutosave = () => {
|
||||
void setFieldsAsync();
|
||||
void setRecipientsAsync();
|
||||
void setEnvelopeAsync();
|
||||
const relativePath = useMemo(() => {
|
||||
const documentRootPath = formatDocumentsPath(envelope.team.url);
|
||||
const templateRootPath = formatTemplatesPath(envelope.team.url);
|
||||
|
||||
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
|
||||
|
||||
return {
|
||||
basePath,
|
||||
envelopePath: `${basePath}/${envelope.id}`,
|
||||
editorPath: `${basePath}/${envelope.id}/edit`,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
};
|
||||
}, [envelope.type, envelope.id]);
|
||||
|
||||
const flushAutosave = async (): Promise<void> => {
|
||||
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -269,7 +322,6 @@ export const EnvelopeEditorProvider = ({
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
setLocalEnvelope,
|
||||
getFieldColor,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
setRecipientsDebounced,
|
||||
@ -278,6 +330,8 @@ export const EnvelopeEditorProvider = ({
|
||||
autosaveError,
|
||||
flushAutosave,
|
||||
isAutosaving,
|
||||
relativePath,
|
||||
syncEnvelope,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -3,6 +3,9 @@ import React from 'react';
|
||||
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
|
||||
@ -23,6 +26,7 @@ type EnvelopeRenderProviderValue = {
|
||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||
fields: TEnvelope['fields'];
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
};
|
||||
|
||||
interface EnvelopeRenderProviderProps {
|
||||
@ -35,6 +39,13 @@ interface EnvelopeRenderProviderProps {
|
||||
* Only pass if the CustomRenderer you are passing in wants fields.
|
||||
*/
|
||||
fields?: TEnvelope['fields'];
|
||||
|
||||
/**
|
||||
* Optional recipient IDs used to determine the color of the fields.
|
||||
*
|
||||
* Only required for generic page renderers.
|
||||
*/
|
||||
recipientIds?: number[];
|
||||
}
|
||||
|
||||
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
|
||||
@ -56,6 +67,7 @@ export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
envelope,
|
||||
fields,
|
||||
recipientIds = [],
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
// Indexed by documentDataId.
|
||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||
@ -132,6 +144,17 @@ export const EnvelopeRenderProvider = ({
|
||||
}
|
||||
}, [envelope.envelopeItems]);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[
|
||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
||||
];
|
||||
},
|
||||
[recipientIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvelopeRenderContext.Provider
|
||||
value={{
|
||||
@ -140,6 +163,7 @@ export const EnvelopeRenderProvider = ({
|
||||
currentEnvelopeItem: currentItem,
|
||||
setCurrentEnvelopeItem,
|
||||
fields: fields ?? [],
|
||||
getRecipientColorKey,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -14,3 +14,5 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
|
||||
export const API_V2_BETA_URL = '/api/v2-beta';
|
||||
|
||||
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
|
||||
|
||||
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';
|
||||
|
||||
@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||
|
||||
export const DEFAULT_STANDARD_FONT_SIZE = 12;
|
||||
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
export const DEFAULT_SIGNATURE_TEXT_FONT_SIZE = 18;
|
||||
|
||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||
export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
@ -18,6 +18,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
embedSigning: z.literal(true).optional(),
|
||||
embedSigningWhiteLabel: z.literal(true).optional(),
|
||||
cfr21: z.literal(true).optional(),
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import {
|
||||
PDFDocument,
|
||||
RotationTypes,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
radiansToDegrees,
|
||||
rotateDegrees,
|
||||
translate,
|
||||
} from '@cantoo/pdf-lib';
|
||||
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
@ -9,6 +17,8 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { groupBy } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
@ -21,6 +31,7 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
|
||||
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
||||
import { getPageSize } from '../../../server-only/pdf/get-page-size';
|
||||
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
|
||||
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
||||
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||
@ -180,7 +191,7 @@ export const run = async ({
|
||||
|
||||
const newDocumentData = await Promise.all(
|
||||
envelopeItems.map(async (envelopeItem) =>
|
||||
io.runTask('decorate-and-sign-pdf', async () => {
|
||||
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
|
||||
const envelopeItemFields = envelope.envelopeItems.find(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
@ -353,14 +364,95 @@ const decorateAndSignPdf = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of envelopeItemFields) {
|
||||
if (field.inserted) {
|
||||
if (envelope.internalVersion === 2) {
|
||||
await insertFieldInPDFV2(pdfDoc, field);
|
||||
} else if (envelope.useLegacyFieldInsertion) {
|
||||
await legacy_insertFieldInPDF(pdfDoc, field);
|
||||
} else {
|
||||
await insertFieldInPDFV1(pdfDoc, field);
|
||||
// Handle V1 and legacy insertions.
|
||||
if (envelope.internalVersion === 1) {
|
||||
for (const field of envelopeItemFields) {
|
||||
if (field.inserted) {
|
||||
if (envelope.useLegacyFieldInsertion) {
|
||||
await legacy_insertFieldInPDF(pdfDoc, field);
|
||||
} else {
|
||||
await insertFieldInPDFV1(pdfDoc, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle V2 envelope insertions.
|
||||
if (envelope.internalVersion === 2) {
|
||||
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
|
||||
|
||||
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
|
||||
const page = pdfDoc.getPage(Number(pageNumber) - 1);
|
||||
const pageRotation = page.getRotation();
|
||||
|
||||
let { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
|
||||
let pageRotationInDegrees = match(pageRotation.type)
|
||||
.with(RotationTypes.Degrees, () => pageRotation.angle)
|
||||
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
|
||||
.exhaustive();
|
||||
|
||||
// Round to the closest multiple of 90 degrees.
|
||||
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
|
||||
|
||||
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
||||
// However when we load the PDF in the backend, the rotation is applied.
|
||||
// To account for this, we swap the width and height for pages that are rotated by 90/270
|
||||
// degrees. This is so we can calculate the virtual position the field was placed if it
|
||||
// was correctly oriented in the frontend.
|
||||
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
|
||||
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
// Rotate the page to the orientation that the react-pdf renders on the frontend.
|
||||
// Note: These transformations are undone at the end of the function.
|
||||
// If you change this if statement, update the if statement at the end as well
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
|
||||
switch (pageRotationInDegrees) {
|
||||
case 90:
|
||||
translateX = pageHeight;
|
||||
translateY = 0;
|
||||
break;
|
||||
case 180:
|
||||
translateX = pageWidth;
|
||||
translateY = pageHeight;
|
||||
break;
|
||||
case 270:
|
||||
translateX = 0;
|
||||
translateY = pageWidth;
|
||||
break;
|
||||
case 0:
|
||||
default:
|
||||
translateX = 0;
|
||||
translateY = 0;
|
||||
}
|
||||
|
||||
page.pushOperators(pushGraphicsState());
|
||||
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
|
||||
}
|
||||
|
||||
const renderedPdfOverlay = await insertFieldInPDFV2({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
});
|
||||
|
||||
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
|
||||
|
||||
// Draw the SVG on the page
|
||||
page.drawPage(embeddedPage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
});
|
||||
|
||||
// Remove the transformations applied to the page if any were applied.
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
page.pushOperators(popGraphicsState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
|
||||
return {
|
||||
...envelope,
|
||||
envelopeId: envelope.id,
|
||||
internalVersion: envelope.internalVersion,
|
||||
documentData: {
|
||||
...firstDocumentData,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
folderId,
|
||||
};
|
||||
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
|
||||
ownerCountsWhereInput = {
|
||||
...ownerCountsWhereInput,
|
||||
...visibilityFiltersWhereInput,
|
||||
...searchFilter,
|
||||
AND: [
|
||||
...(Array.isArray(visibilityFiltersWhereInput.AND)
|
||||
? visibilityFiltersWhereInput.AND
|
||||
: visibilityFiltersWhereInput.AND
|
||||
? [visibilityFiltersWhereInput.AND]
|
||||
: []),
|
||||
searchFilter,
|
||||
rootPageFilter,
|
||||
folderId ? { folderId } : {},
|
||||
],
|
||||
};
|
||||
|
||||
if (teamEmail) {
|
||||
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
],
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
};
|
||||
|
||||
notSignedCountsGroupByArgs = {
|
||||
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
OR: [
|
||||
{
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
|
||||
},
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@ -13,9 +14,13 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -56,6 +62,7 @@ export const sendDocument = async ({
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
@ -165,6 +172,78 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
|
||||
|
||||
// Auto insert radio and checkboxes that have default values.
|
||||
if (envelope.internalVersion === 2) {
|
||||
for (const field of envelope.fields) {
|
||||
if (field.type === FieldType.RADIO) {
|
||||
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedItemIndex = values.findIndex((value) => value.checked);
|
||||
|
||||
if (checkedItemIndex !== -1) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toRadioCustomText(checkedItemIndex),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
if (defaultValue && values.some((value) => value.value === defaultValue)) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: defaultValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
const {
|
||||
values = [],
|
||||
validationRule,
|
||||
validationLength,
|
||||
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const checkedIndices: number[] = [];
|
||||
|
||||
values.forEach((value, i) => {
|
||||
if (value.checked) {
|
||||
checkedIndices.push(i);
|
||||
}
|
||||
});
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||
|
||||
if (!validation) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid checkbox validation rule',
|
||||
});
|
||||
}
|
||||
|
||||
isValid = validateCheckboxLength(
|
||||
checkedIndices.length,
|
||||
validation.value,
|
||||
validationLength,
|
||||
);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
fieldsToAutoInsert.push({
|
||||
fieldId: field.id,
|
||||
customText: toCheckboxCustomText(checkedIndices),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
@ -177,6 +256,37 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.internalVersion === 2) {
|
||||
const autoInsertedFields = await Promise.all(
|
||||
fieldsToAutoInsert.map(async (field) => {
|
||||
return await tx.field.update({
|
||||
where: {
|
||||
id: field.fieldId,
|
||||
},
|
||||
data: {
|
||||
customText: field.customText,
|
||||
inserted: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
fields: autoInsertedFields.map((field) => ({
|
||||
fieldId: field.id,
|
||||
fieldType: field.type,
|
||||
recipientId: field.recipientId,
|
||||
})),
|
||||
},
|
||||
// Don't put metadata or user here since it's a system event.
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type CreateAttachmentOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
data: {
|
||||
label: string;
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const createAttachment = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
}: CreateAttachmentOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.create({
|
||||
data: {
|
||||
envelopeId,
|
||||
type: 'link',
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.envelopeAttachment.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||
envelopeId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByEnvelopeId = async ({
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type FindAttachmentsByTokenOptions = {
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const findAttachmentsByToken = async ({
|
||||
envelopeId,
|
||||
token,
|
||||
}: FindAttachmentsByTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type FindAttachmentsByTeamOptions = {
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const findAttachmentsByTeam = async ({
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: FindAttachmentsByTeamOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type UpdateAttachmentOptions = {
|
||||
id: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: { label?: string; data?: string };
|
||||
};
|
||||
|
||||
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
|
||||
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Attachment not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||
attachment.envelope.status === DocumentStatus.REJECTED
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.envelopeAttachment.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: TEnvelopeAttachmentType;
|
||||
}>;
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@ -67,6 +73,7 @@ export const createEnvelope = async ({
|
||||
teamId,
|
||||
normalizePdf,
|
||||
data,
|
||||
attachments,
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
@ -246,6 +253,15 @@ export const createEnvelope = async ({
|
||||
})),
|
||||
},
|
||||
},
|
||||
envelopeAttachments: {
|
||||
createMany: {
|
||||
data: (attachments || []).map((attachment) => ({
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
type: attachment.type ?? 'link',
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
@ -338,6 +354,7 @@ export const createEnvelope = async ({
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
envelopeAttachments: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||
|
||||
export type GetRecipientEnvelopeByTokenOptions = {
|
||||
token: string;
|
||||
userId?: number;
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all the values and details for a direct template envelope that a recipient requires.
|
||||
*
|
||||
* Do not overexpose any information that the recipient should not have.
|
||||
*/
|
||||
export const getEnvelopeForDirectTemplateSigning = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Missing token',
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
status: DocumentStatus.DRAFT,
|
||||
directLink: {
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
include: {
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
directLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = (envelope?.recipients || []).find(
|
||||
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!envelope || !recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope has no items',
|
||||
});
|
||||
}
|
||||
|
||||
const documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
? {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
}
|
||||
: {
|
||||
email: envelope.team.teamEmail?.email || '',
|
||||
name: envelope.team.name || '',
|
||||
};
|
||||
|
||||
return ZEnvelopeForSigningResponse.parse({
|
||||
envelope,
|
||||
recipient: {
|
||||
...recipient,
|
||||
token: envelope.directLink?.token || '',
|
||||
},
|
||||
recipientSignature: null,
|
||||
isRecipientsTurn: true,
|
||||
isCompleted: false,
|
||||
isRejected: false,
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
brandingEnabled: settings.brandingEnabled,
|
||||
brandingLogo: settings.brandingLogo,
|
||||
},
|
||||
} satisfies EnvelopeForSigningResponse);
|
||||
};
|
||||
@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const ZEnvelopeForSigningResponse = z.object({
|
||||
export const ZEnvelopeForSigningResponse = z.object({
|
||||
envelope: EnvelopeSchema.pick({
|
||||
type: true,
|
||||
status: true,
|
||||
@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
|
||||
secondaryId: true,
|
||||
internalVersion: true,
|
||||
completedAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
|
||||
@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
directLink: {
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
directLink: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find(
|
||||
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientUserAccount = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: recipient.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
recipientEmail: recipient.email,
|
||||
recipientHasAccount: Boolean(recipientUserAccount),
|
||||
} as const;
|
||||
};
|
||||
|
||||
@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
false,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
@ -304,7 +306,10 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedField;
|
||||
return {
|
||||
...upsertedField,
|
||||
formId: field.formId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
@ -338,17 +343,25 @@ export const setFieldsForDocument = async ({
|
||||
}
|
||||
|
||||
// Filter out fields that have been removed or have been updated.
|
||||
const filteredFields = existingFields.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
const mappedFilteredFields = existingFields
|
||||
.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
return !isRemoved && !isUpdated;
|
||||
})
|
||||
.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: undefined,
|
||||
}));
|
||||
|
||||
const mappedPersistentFields = persistedFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: field?.formId,
|
||||
}));
|
||||
|
||||
return {
|
||||
fields: [...filteredFields, ...persistedFields].map((field) =>
|
||||
mapFieldToLegacyField(field, envelope),
|
||||
),
|
||||
fields: [...mappedFilteredFields, ...mappedPersistentFields],
|
||||
};
|
||||
};
|
||||
|
||||
@ -357,6 +370,7 @@ export const setFieldsForDocument = async ({
|
||||
*/
|
||||
type FieldData = {
|
||||
id?: number | null;
|
||||
formId?: string;
|
||||
envelopeItemId: string;
|
||||
type: FieldType;
|
||||
recipientId: number;
|
||||
|
||||
@ -27,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
fields: {
|
||||
id?: number | null;
|
||||
formId?: string;
|
||||
envelopeItemId: string;
|
||||
type: FieldType;
|
||||
recipientId: number;
|
||||
@ -111,10 +112,10 @@ export const setFieldsForTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
const persistedFields = await Promise.all(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) => {
|
||||
linkedFields.map(async (field) => {
|
||||
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
|
||||
|
||||
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
||||
@ -176,7 +177,7 @@ export const setFieldsForTemplate = async ({
|
||||
}
|
||||
|
||||
// Proceed with upsert operation
|
||||
return prisma.field.upsert({
|
||||
const upsertedField = await prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
envelopeId: envelope.id,
|
||||
@ -219,6 +220,11 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...upsertedField,
|
||||
formId: field.formId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -240,9 +246,17 @@ export const setFieldsForTemplate = async ({
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
const mappedFilteredFields = filteredFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: undefined,
|
||||
}));
|
||||
|
||||
const mappedPersistentFields = persistedFields.map((field) => ({
|
||||
...mapFieldToLegacyField(field, envelope),
|
||||
formId: field?.formId,
|
||||
}));
|
||||
|
||||
return {
|
||||
fields: [...filteredFields, ...persistedFields].map((field) =>
|
||||
mapFieldToLegacyField(field, envelope),
|
||||
),
|
||||
fields: [...mappedFilteredFields, ...mappedPersistentFields],
|
||||
};
|
||||
};
|
||||
|
||||
@ -205,6 +205,7 @@ export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalCla
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
|
||||
@ -1,133 +1,64 @@
|
||||
import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
import { FontLibrary } from 'skia-canvas';
|
||||
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { renderField } from '../../universal/field-renderer/render-field';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
// const font = await pdf.embedFont(
|
||||
// isSignatureField ? fontCaveat : fontNoto,
|
||||
// isSignatureField ? { features: { calt: false } } : undefined,
|
||||
// );
|
||||
// const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
||||
// const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
||||
type InsertFieldInPDFV2Options = {
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
export const insertFieldInPDFV2 = async ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fields,
|
||||
}: InsertFieldInPDFV2Options) => {
|
||||
const fontPath = path.join(process.cwd(), 'public/fonts');
|
||||
|
||||
FontLibrary.use([
|
||||
path.join(fontPath, 'caveat.ttf'),
|
||||
path.join(fontPath, 'noto-sans.ttf'),
|
||||
path.join(fontPath, 'noto-sans-japanese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-chinese.ttf'),
|
||||
path.join(fontPath, 'noto-sans-korean.ttf'),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
const pages = pdf.getPages();
|
||||
|
||||
const page = pages.at(field.page - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${field.page} does not exist`);
|
||||
}
|
||||
|
||||
const pageRotation = page.getRotation();
|
||||
|
||||
let pageRotationInDegrees = match(pageRotation.type)
|
||||
.with(RotationTypes.Degrees, () => pageRotation.angle)
|
||||
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
|
||||
.exhaustive();
|
||||
|
||||
// Round to the closest multiple of 90 degrees.
|
||||
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
|
||||
|
||||
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
||||
|
||||
// Todo: Evenloeps - getPageSize this had extra logic? Ask lucas
|
||||
|
||||
console.log({
|
||||
cropBox: page.getCropBox(),
|
||||
mediaBox: page.getMediaBox(),
|
||||
mediaBox2: page.getSize(),
|
||||
});
|
||||
|
||||
const { width: pageWidth, height: pageHeight } = getPageSize(page);
|
||||
|
||||
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
||||
// However when we load the PDF in the backend, the rotation is applied.
|
||||
//
|
||||
// To account for this, we swap the width and height for pages that are rotated by 90/270
|
||||
// degrees. This is so we can calculate the virtual position the field was placed if it
|
||||
// was correctly oriented in the frontend.
|
||||
//
|
||||
// Then when we insert the fields, we apply a transformation to the position of the field
|
||||
// so it is rotated correctly.
|
||||
if (isPageRotatedToLandscape) {
|
||||
// [pageWidth, pageHeight] = [pageHeight, pageWidth];
|
||||
}
|
||||
|
||||
console.log({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fieldWidth: field.width,
|
||||
fieldHeight: field.height,
|
||||
});
|
||||
|
||||
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
|
||||
const layer = new Konva.Layer();
|
||||
|
||||
// Will render onto the layer.
|
||||
renderField({
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
const insertedFields = fields.filter((field) => field.inserted);
|
||||
|
||||
// Render the fields onto the layer.
|
||||
for (const field of insertedFields) {
|
||||
renderField({
|
||||
scale: 1,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
},
|
||||
translations: null,
|
||||
pageLayer: layer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode: 'export',
|
||||
});
|
||||
}
|
||||
|
||||
stage.add(layer);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const canvas = layer.canvas._canvas as unknown as Canvas;
|
||||
|
||||
const renderedField = await canvas.toBuffer('svg');
|
||||
|
||||
fs.writeFileSync(
|
||||
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||
renderedField.toString('utf-8'),
|
||||
);
|
||||
|
||||
// Embed the SVG into the PDF
|
||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||
|
||||
// Calculate position to cover the whole page
|
||||
// pdf-lib coordinates: (0,0) is bottom-left, y increases upward
|
||||
const svgWidth = pageWidth; // Use full page width
|
||||
const svgHeight = pageHeight; // Use full page height
|
||||
|
||||
const x = 0; // Start from left edge
|
||||
const y = pageHeight; // Start from bottom edge
|
||||
|
||||
// Draw the SVG on the page
|
||||
page.drawSvg(svgElement, {
|
||||
x: x,
|
||||
y: y,
|
||||
width: svgWidth,
|
||||
height: svgHeight,
|
||||
});
|
||||
|
||||
return pdf;
|
||||
return await canvas.toBuffer('pdf');
|
||||
};
|
||||
|
||||
@ -162,12 +162,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||
}
|
||||
|
||||
if (user && user.email !== directRecipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Email must match if you are logged in',
|
||||
});
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
|
||||
extractDocumentAuthMethods({
|
||||
documentAuth: directTemplateEnvelope.authOptions,
|
||||
@ -340,7 +334,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: 1,
|
||||
internalVersion: directTemplateEnvelope.internalVersion,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
templateId: directTemplateEnvelopeLegacyId,
|
||||
@ -640,6 +634,23 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
data: auditLogsToCreate,
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: directTemplateEnvelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (templateAttachments.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: templateAttachments.map((attachment) => ({
|
||||
envelopeId: createdEnvelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Send email to template owner.
|
||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||
recipientName: directRecipientEmail,
|
||||
|
||||
@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
envelopeItemId?: string;
|
||||
}[];
|
||||
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
data: string;
|
||||
type?: 'link';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Values that will override the predefined values in the template.
|
||||
*/
|
||||
@ -203,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
|
||||
type: 'radio',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
direction: radioMeta.direction ?? 'vertical',
|
||||
};
|
||||
|
||||
return meta;
|
||||
@ -295,6 +302,7 @@ export const createDocumentFromTemplate = async ({
|
||||
requestMetadata,
|
||||
folderId,
|
||||
prefillFields,
|
||||
attachments,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
@ -388,8 +396,6 @@ export const createDocumentFromTemplate = async ({
|
||||
};
|
||||
});
|
||||
|
||||
const firstEnvelopeItemId = template.envelopeItems[0].id;
|
||||
|
||||
// Key = original envelope item ID
|
||||
// Value = duplicated envelope item ID.
|
||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||
@ -400,10 +406,14 @@ export const createDocumentFromTemplate = async ({
|
||||
template.envelopeItems.map(async (item, i) => {
|
||||
let documentDataIdToDuplicate = item.documentDataId;
|
||||
|
||||
const foundCustomDocumentData = customDocumentData.find(
|
||||
(customDocumentDataItem) =>
|
||||
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
|
||||
);
|
||||
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
|
||||
// Handle empty envelopeItemId for backwards compatibility reasons.
|
||||
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return customDocumentDataItem.envelopeItemId === item.id;
|
||||
});
|
||||
|
||||
if (foundCustomDocumentData) {
|
||||
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;
|
||||
@ -667,6 +677,33 @@ export const createDocumentFromTemplate = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||
where: {
|
||||
envelopeId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
const attachmentsToCreate = [
|
||||
...templateAttachments.map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type,
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
...(attachments || []).map((attachment) => ({
|
||||
envelopeId: envelope.id,
|
||||
type: attachment.type || 'link',
|
||||
label: attachment.label,
|
||||
data: attachment.data,
|
||||
})),
|
||||
];
|
||||
|
||||
if (attachmentsToCreate.length > 0) {
|
||||
await tx.envelopeAttachment.createMany({
|
||||
data: attachmentsToCreate,
|
||||
});
|
||||
}
|
||||
|
||||
const createdEnvelope = await tx.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@ -38,7 +38,6 @@ export const getTemplateByDirectLinkToken = async ({
|
||||
|
||||
const directLink = envelope?.directLink;
|
||||
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = envelope?.envelopeItems[0]?.documentData;
|
||||
|
||||
// Doing this to enforce type safety for directLink.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -21,10 +21,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'RECIPIENT_DELETED',
|
||||
'RECIPIENT_UPDATED',
|
||||
|
||||
'ENVELOPE_ITEM_CREATED',
|
||||
'ENVELOPE_ITEM_DELETED',
|
||||
|
||||
// Document events.
|
||||
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
|
||||
'DOCUMENT_CREATED', // When the document is created.
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
|
||||
@ -181,6 +185,28 @@ const ZBaseRecipientDataSchema = z.object({
|
||||
recipientRole: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Envelope item created.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventEnvelopeItemCreatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED),
|
||||
data: z.object({
|
||||
envelopeItemId: z.string(),
|
||||
envelopeItemTitle: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Envelope item deleted.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED),
|
||||
data: z.object({
|
||||
envelopeItemId: z.string(),
|
||||
envelopeItemTitle: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Email sent.
|
||||
*/
|
||||
@ -315,6 +341,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field auto inserted.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED),
|
||||
data: z.object({
|
||||
fields: z.array(
|
||||
z.object({
|
||||
fieldId: z.number(),
|
||||
fieldType: z.nativeEnum(FieldType),
|
||||
recipientId: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field uninserted.
|
||||
*/
|
||||
@ -652,11 +694,14 @@ export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
|
||||
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
z.union([
|
||||
ZDocumentAuditLogEventEnvelopeItemCreatedSchema,
|
||||
ZDocumentAuditLogEventEnvelopeItemDeletedSchema,
|
||||
ZDocumentAuditLogEventEmailSentSchema,
|
||||
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
|
||||
|
||||
@ -33,6 +33,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
folderId: true,
|
||||
}).extend({
|
||||
envelopeId: z.string(),
|
||||
internalVersion: z.number(),
|
||||
|
||||
// Which "Template" the document was created from.
|
||||
templateId: z
|
||||
@ -114,6 +115,7 @@ export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
envelopeId: z.string(),
|
||||
internalVersion: z.number(),
|
||||
|
||||
// Backwards compatibility.
|
||||
documentDataId: z.string().default(''),
|
||||
@ -149,6 +151,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
envelopeId: z.string(),
|
||||
internalVersion: z.number(),
|
||||
|
||||
// Backwards compatibility.
|
||||
documentDataId: z.string().default(''),
|
||||
|
||||
5
packages/lib/types/envelope-attachment.ts
Normal file
5
packages/lib/types/envelope-attachment.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
|
||||
|
||||
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;
|
||||
@ -79,7 +79,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true, // Todo: Envelopes - Maybe this hide this.
|
||||
initialData: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../constants/pdf';
|
||||
|
||||
export const DEFAULT_FIELD_FONT_SIZE = 14;
|
||||
|
||||
export const ZBaseFieldMeta = z.object({
|
||||
@ -8,6 +10,7 @@ export const ZBaseFieldMeta = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
|
||||
});
|
||||
|
||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||
@ -18,7 +21,6 @@ export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
|
||||
|
||||
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('initials'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -26,7 +28,6 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
|
||||
|
||||
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('name'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -34,7 +35,6 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
|
||||
|
||||
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('email'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -42,7 +42,6 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
|
||||
|
||||
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('date'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -52,7 +51,6 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('text'),
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.number().optional(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -64,7 +62,6 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
|
||||
value: z.string().optional(),
|
||||
minValue: z.coerce.number().nullish(),
|
||||
maxValue: z.coerce.number().nullish(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
@ -81,6 +78,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
|
||||
});
|
||||
|
||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||
@ -111,7 +109,14 @@ export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
|
||||
|
||||
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
|
||||
|
||||
export const ZSignatureFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('signature'),
|
||||
});
|
||||
|
||||
export type TSignatureFieldMeta = z.infer<typeof ZSignatureFieldMeta>;
|
||||
|
||||
export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
|
||||
ZSignatureFieldMeta,
|
||||
ZInitialsFieldMeta,
|
||||
ZNameFieldMeta,
|
||||
ZEmailFieldMeta,
|
||||
@ -231,13 +236,13 @@ export type TFieldAndMeta = z.infer<typeof ZFieldAndMetaSchema>;
|
||||
|
||||
export const FIELD_DATE_META_DEFAULT_VALUES: TDateFieldMeta = {
|
||||
type: 'date',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
|
||||
type: 'text',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
@ -245,9 +250,10 @@ export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
||||
type: 'number',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
@ -257,31 +263,34 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
||||
|
||||
export const FIELD_INITIALS_META_DEFAULT_VALUES: TInitialsFieldMeta = {
|
||||
type: 'initials',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
export const FIELD_NAME_META_DEFAULT_VALUES: TNameFieldMeta = {
|
||||
type: 'name',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
export const FIELD_EMAIL_META_DEFAULT_VALUES: TEmailFieldMeta = {
|
||||
type: 'email',
|
||||
fontSize: 14,
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
|
||||
type: 'radio',
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
required: false,
|
||||
readOnly: false,
|
||||
direction: 'vertical',
|
||||
};
|
||||
|
||||
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||
type: 'checkbox',
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
validationRule: '',
|
||||
validationLength: 0,
|
||||
@ -292,14 +301,20 @@ export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||
|
||||
export const FIELD_DROPDOWN_META_DEFAULT_VALUES: TDropdownFieldMeta = {
|
||||
type: 'dropdown',
|
||||
fontSize: DEFAULT_FIELD_FONT_SIZE,
|
||||
values: [{ value: 'Option 1' }],
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export const FIELD_SIGNATURE_META_DEFAULT_VALUES: TSignatureFieldMeta = {
|
||||
type: 'signature',
|
||||
fontSize: DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
|
||||
};
|
||||
|
||||
export const FIELD_META_DEFAULT_VALUES: Record<FieldType, TFieldMetaSchema> = {
|
||||
[FieldType.SIGNATURE]: undefined,
|
||||
[FieldType.SIGNATURE]: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
[FieldType.FREE_SIGNATURE]: undefined,
|
||||
[FieldType.INITIALS]: FIELD_INITIALS_META_DEFAULT_VALUES,
|
||||
[FieldType.NAME]: FIELD_NAME_META_DEFAULT_VALUES,
|
||||
|
||||
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import { FieldSchema } from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
|
||||
|
||||
import {
|
||||
FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
ZCheckboxFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
ZNameFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZSignatureFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from './field-meta';
|
||||
|
||||
@ -69,7 +71,6 @@ export const ZFieldHeightSchema = z.number().min(1).describe('The height of the
|
||||
|
||||
// ---------------------------------------------
|
||||
|
||||
// Todo: Envelopes - dunno man
|
||||
const PrismaDecimalSchema = z.preprocess(
|
||||
(val) => (typeof val === 'string' ? new Prisma.Decimal(val) : val),
|
||||
z.instanceof(Prisma.Decimal, { message: 'Must be a Decimal' }),
|
||||
@ -91,7 +92,7 @@ export type TFieldText = z.infer<typeof ZFieldTextSchema>;
|
||||
|
||||
export const ZFieldSignatureSchema = BaseFieldSchemaUsingNumbers.extend({
|
||||
type: z.literal(FieldType.SIGNATURE),
|
||||
fieldMeta: z.literal(null),
|
||||
fieldMeta: ZSignatureFieldMeta.catch(FIELD_SIGNATURE_META_DEFAULT_VALUES),
|
||||
});
|
||||
|
||||
export type TFieldSignature = z.infer<typeof ZFieldSignatureSchema>;
|
||||
|
||||
@ -30,6 +30,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
cfr21: z.boolean().optional(),
|
||||
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
|
||||
allowEnvelopes: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@ -82,6 +84,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
},
|
||||
allowEnvelopes: {
|
||||
key: 'allowEnvelopes',
|
||||
label: 'Allow envelopes',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
@ -105,6 +111,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Free',
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 5,
|
||||
locked: true,
|
||||
flags: {},
|
||||
},
|
||||
@ -113,6 +120,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Individual',
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 5,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
@ -123,6 +131,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Teams',
|
||||
teamCount: 1,
|
||||
memberCount: 5,
|
||||
envelopeItemCount: 5,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
@ -135,6 +144,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Platform',
|
||||
teamCount: 1,
|
||||
memberCount: 0,
|
||||
envelopeItemCount: 10,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
@ -152,6 +162,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Enterprise',
|
||||
teamCount: 0,
|
||||
memberCount: 0,
|
||||
envelopeItemCount: 10,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
@ -171,6 +182,7 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Early Adopter',
|
||||
teamCount: 0,
|
||||
memberCount: 0,
|
||||
envelopeItemCount: 5,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
|
||||
@ -8,11 +8,15 @@ import {
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
export const konvaTextFontFamily =
|
||||
'Noto Sans, Noto Sans Japanese, Noto Sans Chinese, Noto Sans Korean, sans-serif';
|
||||
export const konvaTextFill = 'black';
|
||||
|
||||
export const upsertFieldGroup = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Group => {
|
||||
const { pageWidth, pageHeight, pageLayer, editable } = options;
|
||||
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
||||
field,
|
||||
@ -27,6 +31,9 @@ export const upsertFieldGroup = (
|
||||
name: 'field-group',
|
||||
});
|
||||
|
||||
const maxXPosition = (pageWidth - fieldWidth) * scale;
|
||||
const maxYPosition = (pageHeight - fieldHeight) * scale;
|
||||
|
||||
fieldGroup.setAttrs({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
@ -34,8 +41,9 @@ export const upsertFieldGroup = (
|
||||
y: fieldY,
|
||||
draggable: editable,
|
||||
dragBoundFunc: (pos) => {
|
||||
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
|
||||
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
|
||||
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
||||
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
|
||||
|
||||
return { x: newX, y: newY };
|
||||
},
|
||||
} satisfies Partial<Konva.GroupConfig>);
|
||||
@ -89,14 +97,18 @@ export const createSpinner = ({
|
||||
width: fieldWidth - 8,
|
||||
height: fieldHeight - 8,
|
||||
fill: 'white',
|
||||
opacity: 1,
|
||||
opacity: 0.8,
|
||||
});
|
||||
|
||||
const maxSpinnerSize = 10;
|
||||
const smallerDimension = Math.min(fieldWidth, fieldHeight);
|
||||
const spinnerSize = Math.min(smallerDimension, maxSpinnerSize);
|
||||
|
||||
const spinner = new Konva.Arc({
|
||||
x: fieldWidth / 2,
|
||||
y: fieldHeight / 2,
|
||||
innerRadius: fieldWidth / 10,
|
||||
outerRadius: fieldHeight / 10,
|
||||
innerRadius: spinnerSize,
|
||||
outerRadius: spinnerSize / 2,
|
||||
angle: 270,
|
||||
rotation: 0,
|
||||
fill: 'rgba(122, 195, 85, 1)',
|
||||
@ -117,3 +129,58 @@ export const createSpinner = ({
|
||||
|
||||
return loadingGroup;
|
||||
};
|
||||
|
||||
type CreateFieldHoverInteractionOptions = {
|
||||
options: RenderFieldElementOptions;
|
||||
fieldGroup: Konva.Group;
|
||||
fieldRect: Konva.Rect;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
|
||||
*/
|
||||
export const createFieldHoverInteraction = ({
|
||||
options,
|
||||
fieldGroup,
|
||||
fieldRect,
|
||||
}: CreateFieldHoverInteractionOptions) => {
|
||||
const { mode } = options;
|
||||
|
||||
if (mode === 'export' || !options.color) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
|
||||
|
||||
fieldGroup.on('mouseover', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: hoverColor,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.on('mouseout', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: DEFAULT_RECT_BACKGROUND,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.on('transformstart', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: hoverColor,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.on('transformend', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: DEFAULT_RECT_BACKGROUND,
|
||||
}).play();
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Signature } from '@prisma/client';
|
||||
import type { FieldType, Signature } from '@prisma/client';
|
||||
import { type Field } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
@ -26,9 +26,11 @@ export type RenderFieldElementOptions = {
|
||||
pageLayer: Konva.Layer;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
mode?: 'edit' | 'sign' | 'export';
|
||||
mode: 'edit' | 'sign' | 'export';
|
||||
editable?: boolean;
|
||||
scale: number;
|
||||
color?: TRecipientColor;
|
||||
translations: Record<FieldType, string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -107,6 +109,11 @@ type CalculateMultiItemPositionOptions = {
|
||||
*/
|
||||
fieldPadding: number;
|
||||
|
||||
/**
|
||||
* The direction of the items.
|
||||
*/
|
||||
direction: 'horizontal' | 'vertical';
|
||||
|
||||
type: 'checkbox' | 'radio';
|
||||
};
|
||||
|
||||
@ -122,6 +129,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
itemSize,
|
||||
spacingBetweenItemAndText,
|
||||
fieldPadding,
|
||||
direction,
|
||||
type,
|
||||
} = options;
|
||||
|
||||
@ -130,6 +138,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
const innerFieldX = fieldPadding;
|
||||
const innerFieldY = fieldPadding;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
const itemHeight = innerFieldHeight;
|
||||
const itemWidth = innerFieldWidth / itemCount;
|
||||
|
||||
const y = innerFieldY;
|
||||
const x = itemIndex * itemWidth + innerFieldX;
|
||||
|
||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||
let itemInputX = x;
|
||||
|
||||
// We need a little different logic to center the radio circle icon.
|
||||
if (type === 'radio') {
|
||||
itemInputX = x + itemSize / 2;
|
||||
itemInputY = y + itemHeight / 2;
|
||||
}
|
||||
|
||||
const textX = x + itemSize + spacingBetweenItemAndText;
|
||||
const textY = y;
|
||||
|
||||
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
|
||||
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
|
||||
const textHeight = itemHeight;
|
||||
|
||||
return {
|
||||
itemInputX,
|
||||
itemInputY,
|
||||
textX,
|
||||
textY,
|
||||
textWidth,
|
||||
textHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const itemHeight = innerFieldHeight / itemCount;
|
||||
|
||||
const y = itemIndex * itemHeight + innerFieldY;
|
||||
@ -137,6 +178,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||
let itemInputX = innerFieldX;
|
||||
|
||||
// We need a little different logic to center the radio circle icon.
|
||||
if (type === 'radio') {
|
||||
itemInputX = innerFieldX + itemSize / 2;
|
||||
itemInputY = y + itemHeight / 2;
|
||||
|
||||
@ -1,154 +1,184 @@
|
||||
import Konva from 'konva';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
||||
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
konvaTextFill,
|
||||
konvaTextFontFamily,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
|
||||
// Do not change any of these values without consulting with the team.
|
||||
const checkboxFieldPadding = 8;
|
||||
const checkboxSize = 16;
|
||||
const spacingBetweenCheckboxAndText = 8;
|
||||
|
||||
const calculateCheckboxSize = (fontSize: number) => {
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
export const renderCheckboxFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode } = options;
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// Clear previous children to re-render fresh
|
||||
fieldGroup.removeChildren();
|
||||
|
||||
fieldGroup.add(upsertFieldRect(field, options));
|
||||
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
|
||||
// Handle rescaling items during transforms.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Todo: Envelopes - check sorting more than 10
|
||||
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
|
||||
const squares = fieldGroup
|
||||
.find('.checkbox-square')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup
|
||||
.find('.checkbox-checkmark')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = squares.map((square, i) => ({
|
||||
squareElement: square,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { squareElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: checkboxSize,
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
squareElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.setAttrs({
|
||||
width: rectWidth,
|
||||
height: rectHeight,
|
||||
});
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
}
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
|
||||
const checkboxValues = checkboxMeta?.values || [];
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
}
|
||||
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
fieldGroup.add(fieldRect);
|
||||
|
||||
const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
// Handle rescaling items during transforms.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Todo: Envelopes - check sorting more than 10
|
||||
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
|
||||
const squares = fieldGroup
|
||||
.find('.checkbox-square')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup
|
||||
.find('.checkbox-checkmark')
|
||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = squares.map((square, i) => ({
|
||||
squareElement: square,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { squareElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateCheckboxSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
squareElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.setAttrs({
|
||||
width: rectWidth,
|
||||
height: rectHeight,
|
||||
});
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
|
||||
|
||||
checkboxValues.forEach(({ value, checked }, index) => {
|
||||
const isCheckboxChecked = match(mode)
|
||||
.with('edit', () => checked)
|
||||
.with('sign', () => checkedValues.includes(index))
|
||||
.with('export', () => {
|
||||
// If it's read-only, check the originally checked state.
|
||||
if (checkboxMeta.readOnly) {
|
||||
return checked;
|
||||
}
|
||||
|
||||
return checkedValues.includes(index);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
const itemSize = calculateCheckboxSize(fontSize);
|
||||
|
||||
checkboxValues.forEach(({ id, value, checked }, index) => {
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: checkboxValues.length,
|
||||
itemIndex: index,
|
||||
itemSize: checkboxSize,
|
||||
itemSize,
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
const square = new Konva.Rect({
|
||||
internalCheckboxId: id,
|
||||
internalCheckboxValue: value,
|
||||
internalCheckboxIndex: index,
|
||||
id: `checkbox-square-${index}`,
|
||||
name: 'checkbox-square',
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
width: checkboxSize,
|
||||
height: checkboxSize,
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
stroke: '#374151',
|
||||
strokeWidth: 2,
|
||||
cornerRadius: 2,
|
||||
fill: 'white',
|
||||
});
|
||||
|
||||
const checkboxScale = itemSize / 16;
|
||||
|
||||
const checkmark = new Konva.Line({
|
||||
internalCheckboxId: id,
|
||||
internalCheckboxValue: value,
|
||||
internalCheckboxIndex: index,
|
||||
id: `checkbox-checkmark-${index}`,
|
||||
name: 'checkbox-checkmark',
|
||||
x: itemInputX,
|
||||
@ -156,12 +186,12 @@ export const renderCheckboxFieldElement = (
|
||||
strokeWidth: 2,
|
||||
stroke: '#111827',
|
||||
points: [3, 8, 7, 12, 13, 4],
|
||||
visible: checked,
|
||||
scale: { x: checkboxScale, y: checkboxScale },
|
||||
visible: isCheckboxChecked,
|
||||
});
|
||||
|
||||
const text = new Konva.Text({
|
||||
internalCheckboxId: id,
|
||||
internalCheckboxValue: value,
|
||||
internalCheckboxIndex: index,
|
||||
id: `checkbox-text-${index}`,
|
||||
name: 'checkbox-text',
|
||||
x: textX,
|
||||
@ -169,10 +199,10 @@ export const renderCheckboxFieldElement = (
|
||||
text: value,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
fontSize: DEFAULT_STANDARD_FONT_SIZE,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
fill: konvaTextFill,
|
||||
verticalAlign: 'middle',
|
||||
fill: '#111827', // Todo: Envelopes - Sort colours
|
||||
});
|
||||
|
||||
fieldGroup.add(square);
|
||||
@ -180,6 +210,8 @@ export const renderCheckboxFieldElement = (
|
||||
fieldGroup.add(text);
|
||||
});
|
||||
|
||||
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
|
||||
|
||||
return {
|
||||
fieldGroup,
|
||||
isFirstRender,
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TDropdownFieldMeta } from '../../types/field-meta';
|
||||
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
konvaTextFill,
|
||||
konvaTextFontFamily,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
|
||||
@ -26,7 +33,7 @@ const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) =>
|
||||
const textY = fieldPadding;
|
||||
|
||||
const arrowX = fieldWidth - arrowSize - fieldPadding;
|
||||
const arrowY = fieldHeight / 2;
|
||||
const arrowY = fieldHeight / 2 - arrowSize / 4;
|
||||
|
||||
return {
|
||||
arrowX,
|
||||
@ -43,76 +50,29 @@ export const renderDropdownFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode } = options;
|
||||
const { pageWidth, pageHeight, pageLayer, mode, translations } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
|
||||
|
||||
let selectedValue = translations?.[FieldType.DROPDOWN] || 'Select Option';
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// Clear previous children to re-render fresh.
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
fieldGroup.add(upsertFieldRect(field, options));
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
fieldGroup.add(fieldRect);
|
||||
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
const text = fieldGroup.findOne('.dropdown-selected-text');
|
||||
const arrow = fieldGroup.findOne('.dropdown-arrow');
|
||||
|
||||
if (!fieldRect || !text || !arrow) {
|
||||
console.log('fieldRect or text or arrow not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
});
|
||||
|
||||
arrow.setAttrs({
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
text.setAttrs({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
x: textX,
|
||||
y: textY,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
|
||||
fieldRect.setAttrs({
|
||||
width: rectWidth,
|
||||
height: rectHeight,
|
||||
});
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
}
|
||||
|
||||
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
// Todo: Envelopes - Translations
|
||||
let selectedValue = 'Select Option';
|
||||
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
if (field.inserted) {
|
||||
selectedValue = field.customText;
|
||||
@ -132,9 +92,9 @@ export const renderDropdownFieldElement = (
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
text: selectedValue,
|
||||
fontSize: DEFAULT_STANDARD_FONT_SIZE,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fill: '#111827',
|
||||
fontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
fill: konvaTextFill,
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
@ -151,27 +111,63 @@ export const renderDropdownFieldElement = (
|
||||
visible: mode !== 'export',
|
||||
});
|
||||
|
||||
// Add hover state for dropdown
|
||||
fieldGroup.on('mouseenter', () => {
|
||||
// dropdownContainer.stroke('#2563EB');
|
||||
// dropdownContainer.strokeWidth(2);
|
||||
document.body.style.cursor = 'pointer';
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
fieldGroup.on('mouseleave', () => {
|
||||
// dropdownContainer.stroke('#374151');
|
||||
// dropdownContainer.strokeWidth(2);
|
||||
document.body.style.cursor = 'default';
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
fieldGroup.add(selectedText);
|
||||
|
||||
if (!field.inserted || mode === 'export') {
|
||||
fieldGroup.add(arrow);
|
||||
}
|
||||
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
const text = fieldGroup.findOne('.dropdown-selected-text');
|
||||
const arrow = fieldGroup.findOne('.dropdown-arrow');
|
||||
|
||||
if (!fieldRect || !text || !arrow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
});
|
||||
|
||||
arrow.setAttrs({
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
text.setAttrs({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
x: textX,
|
||||
y: textY,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
|
||||
fieldRect.setAttrs({
|
||||
width: rectWidth,
|
||||
height: rectHeight,
|
||||
});
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
|
||||
|
||||
return {
|
||||
fieldGroup,
|
||||
isFirstRender,
|
||||
|
||||
@ -36,6 +36,8 @@ type RenderFieldOptions = {
|
||||
|
||||
color?: TRecipientColor;
|
||||
|
||||
translations: Record<FieldType, string> | null;
|
||||
|
||||
/**
|
||||
* The render type.
|
||||
*
|
||||
@ -47,15 +49,18 @@ type RenderFieldOptions = {
|
||||
*/
|
||||
mode: 'edit' | 'sign' | 'export';
|
||||
|
||||
scale: number;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
export const renderField = ({
|
||||
field,
|
||||
translations,
|
||||
pageLayer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode,
|
||||
scale,
|
||||
editable,
|
||||
color,
|
||||
}: RenderFieldOptions) => {
|
||||
@ -63,9 +68,11 @@ export const renderField = ({
|
||||
pageLayer,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
translations,
|
||||
mode,
|
||||
color,
|
||||
editable,
|
||||
scale,
|
||||
};
|
||||
|
||||
return match(field.type)
|
||||
@ -74,5 +81,5 @@ export const renderField = ({
|
||||
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
|
||||
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
|
||||
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
|
||||
.otherwise(() => renderTextFieldElement(field, options)); // Todo
|
||||
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
|
||||
};
|
||||
|
||||
@ -1,135 +1,163 @@
|
||||
import Konva from 'konva';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TRadioFieldMeta } from '../../types/field-meta';
|
||||
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
konvaTextFill,
|
||||
konvaTextFontFamily,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
|
||||
// Do not change any of these values without consulting with the team.
|
||||
const radioFieldPadding = 8;
|
||||
const radioSize = 16;
|
||||
const spacingBetweenRadioAndText = 8;
|
||||
|
||||
const calculateRadioSize = (fontSize: number) => {
|
||||
return fontSize;
|
||||
};
|
||||
|
||||
export const renderRadioFieldElement = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
) => {
|
||||
const { pageWidth, pageHeight, pageLayer, mode } = options;
|
||||
|
||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||
const radioValues = radioMeta?.values || [];
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
// Clear previous children and listeners to re-render fresh
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// Clear previous children to re-render fresh
|
||||
fieldGroup.removeChildren();
|
||||
|
||||
fieldGroup.add(upsertFieldRect(field, options));
|
||||
fieldGroup.off('transform');
|
||||
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
|
||||
// Handle rescaling items during transforms.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = circles.map((circle, i) => ({
|
||||
circleElement: circle,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { circleElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: radioSize,
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
});
|
||||
|
||||
circleElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.width(rectWidth);
|
||||
fieldRect.height(rectHeight);
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
}
|
||||
|
||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||
const radioValues = radioMeta?.values || [];
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
fieldGroup.add(fieldRect);
|
||||
|
||||
const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
|
||||
// Handle rescaling items during transforms.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = circles.map((circle, i) => ({
|
||||
circleElement: circle,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { circleElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
circleElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.width(rectWidth);
|
||||
fieldRect.height(rectHeight);
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
radioValues.forEach(({ value, checked }, index) => {
|
||||
const isRadioValueChecked = match(mode)
|
||||
.with('edit', () => checked)
|
||||
.with('sign', () => index.toString() === field.customText)
|
||||
.with('export', () => {
|
||||
// If it's read-only, check the originally checked state.
|
||||
if (radioMeta.readOnly) {
|
||||
return checked;
|
||||
}
|
||||
|
||||
return index.toString() === field.customText;
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
fieldHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: index,
|
||||
itemSize: radioSize,
|
||||
itemSize: calculateRadioSize(fontSize),
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
// Circle which represents the radio button.
|
||||
const circle = new Konva.Circle({
|
||||
internalRadioValue: value,
|
||||
internalRadioIndex: index,
|
||||
id: `radio-circle-${index}`,
|
||||
name: 'radio-circle',
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
radius: radioSize / 2,
|
||||
radius: calculateRadioSize(fontSize) / 2,
|
||||
stroke: '#374151',
|
||||
strokeWidth: 2,
|
||||
fill: 'white',
|
||||
@ -137,20 +165,18 @@ export const renderRadioFieldElement = (
|
||||
|
||||
// Dot which represents the selected state.
|
||||
const dot = new Konva.Circle({
|
||||
internalRadioValue: value,
|
||||
internalRadioIndex: index,
|
||||
id: `radio-dot-${index}`,
|
||||
name: 'radio-dot',
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
radius: radioSize / 4,
|
||||
radius: calculateRadioSize(fontSize) / 4,
|
||||
fill: '#111827',
|
||||
// Todo: Envelopes
|
||||
visible: value === field.customText,
|
||||
// visible: checked,
|
||||
visible: isRadioValueChecked,
|
||||
});
|
||||
|
||||
const text = new Konva.Text({
|
||||
internalRadioValue: value,
|
||||
internalRadioIndex: index,
|
||||
id: `radio-text-${index}`,
|
||||
name: 'radio-text',
|
||||
x: textX,
|
||||
@ -158,10 +184,10 @@ export const renderRadioFieldElement = (
|
||||
text: value,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
fontSize: DEFAULT_STANDARD_FONT_SIZE,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
fill: konvaTextFill,
|
||||
verticalAlign: 'middle',
|
||||
fill: '#111827', // Todo: Envelopes - Sort colours
|
||||
});
|
||||
|
||||
fieldGroup.add(circle);
|
||||
@ -169,6 +195,8 @@ export const renderRadioFieldElement = (
|
||||
fieldGroup.add(text);
|
||||
});
|
||||
|
||||
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
|
||||
|
||||
return {
|
||||
fieldGroup,
|
||||
isFirstRender,
|
||||
|
||||
@ -1,62 +1,77 @@
|
||||
import Konva from 'konva';
|
||||
|
||||
import {
|
||||
DEFAULT_RECT_BACKGROUND,
|
||||
RECIPIENT_COLOR_STYLES,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
} from '../../constants/pdf';
|
||||
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
|
||||
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
|
||||
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SkiaImage: any = undefined;
|
||||
|
||||
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
|
||||
void (async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
const mod = await import('skia-canvas');
|
||||
SkiaImage = mod.Image;
|
||||
}
|
||||
})();
|
||||
|
||||
console.log({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeight: number) => {
|
||||
let imageWidth = img.width;
|
||||
let imageHeight = img.height;
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
|
||||
const imageX = (fieldWidth - imageWidth) / 2;
|
||||
const imageY = (fieldHeight - imageHeight) / 2;
|
||||
|
||||
return {
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
};
|
||||
};
|
||||
|
||||
const createFieldSignature = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Text | Konva.Image => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
|
||||
|
||||
const fieldText: Konva.Text =
|
||||
pageLayer.findOne(`#${field.renderId}-text`) ||
|
||||
new Konva.Text({
|
||||
id: `${field.renderId}-text`,
|
||||
name: 'field-text',
|
||||
});
|
||||
const fieldText = new Konva.Text({
|
||||
id: `${field.renderId}-text`,
|
||||
name: 'field-text',
|
||||
});
|
||||
|
||||
const fieldTypeName = translations?.[field.type] || field.type;
|
||||
|
||||
// Calculate text positioning based on alignment
|
||||
const textX = 0;
|
||||
const textY = 0;
|
||||
const textAlign = 'center';
|
||||
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
|
||||
let textFontSize = DEFAULT_STANDARD_FONT_SIZE;
|
||||
const textPadding = 10;
|
||||
|
||||
let textToRender: string = field.type;
|
||||
let textToRender: string = fieldTypeName;
|
||||
|
||||
const signature = field.signature;
|
||||
|
||||
// Handle edit mode.
|
||||
if (mode === 'edit') {
|
||||
textToRender = field.type; // Todo: Envelope - Need translations
|
||||
textToRender = fieldTypeName;
|
||||
}
|
||||
|
||||
// Handle sign mode.
|
||||
if (mode === 'sign' || mode === 'export') {
|
||||
textToRender = field.type; // Todo: Envelope - Need translations
|
||||
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
|
||||
textVerticalAlign = 'middle';
|
||||
textToRender = fieldTypeName;
|
||||
|
||||
if (field.inserted && !signature) {
|
||||
throw new AppError('MISSING_SIGNATURE');
|
||||
@ -65,20 +80,57 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
if (signature?.typedSignature) {
|
||||
textToRender = signature.typedSignature;
|
||||
}
|
||||
|
||||
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({
|
||||
...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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldText.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
verticalAlign: textVerticalAlign,
|
||||
wrap: 'word',
|
||||
padding: textPadding,
|
||||
|
||||
verticalAlign: 'middle',
|
||||
wrap: 'char',
|
||||
text: textToRender,
|
||||
|
||||
fontSize: textFontSize,
|
||||
fontFamily: 'Caveat, Inter', // Todo: Envelopes - Fix all fonts for sans
|
||||
align: textAlign,
|
||||
fontSize,
|
||||
fontFamily: 'Caveat, sans-serif',
|
||||
align: 'center',
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
} satisfies Partial<Konva.TextConfig>);
|
||||
@ -96,111 +148,70 @@ export const renderSignatureFieldElement = (
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// ABOVE IS GENERIC, EXTRACT IT.
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldText = upsertFieldText(field, options);
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
pageLayer.add(fieldGroup);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
}
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldSignature = createFieldSignature(field, options);
|
||||
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldSignature);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldSignature.scaleX(1 / groupScaleX);
|
||||
fieldSignature.scaleY(1 / groupScaleY);
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// Update text dimensions
|
||||
fieldSignature.width(rectWidth);
|
||||
fieldSignature.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldSignature.height();
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldSignature.scaleX(1);
|
||||
fieldSignature.scaleY(1);
|
||||
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Update text dimensions
|
||||
fieldSignature.width(rectWidth); // Account for padding
|
||||
fieldSignature.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
fieldSignature.height();
|
||||
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Handle export mode.
|
||||
if (mode === 'export') {
|
||||
// Hide the rectangle.
|
||||
fieldRect.opacity(0);
|
||||
}
|
||||
|
||||
// Todo: Doesn't work.
|
||||
if (mode !== 'export') {
|
||||
const hoverColor = options.color
|
||||
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
|
||||
: '#e5e7eb';
|
||||
|
||||
// Todo: Envelopes - On hover add text color
|
||||
|
||||
// Add smooth transition-like behavior for hover effects
|
||||
fieldGroup.on('mouseover', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: hoverColor,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.on('mouseout', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: DEFAULT_RECT_BACKGROUND,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.add(fieldRect);
|
||||
}
|
||||
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
|
||||
|
||||
return {
|
||||
fieldGroup,
|
||||
|
||||
@ -1,23 +1,26 @@
|
||||
import Konva from 'konva';
|
||||
|
||||
import {
|
||||
DEFAULT_RECT_BACKGROUND,
|
||||
RECIPIENT_COLOR_STYLES,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TTextFieldMeta } from '../../types/field-meta';
|
||||
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
|
||||
import {
|
||||
createFieldHoverInteraction,
|
||||
konvaTextFill,
|
||||
konvaTextFontFamily,
|
||||
upsertFieldGroup,
|
||||
upsertFieldRect,
|
||||
} from './field-generic-items';
|
||||
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
|
||||
import { calculateFieldPosition } from './field-renderer';
|
||||
|
||||
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
|
||||
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
|
||||
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
const textMeta = field.fieldMeta as TTextFieldMeta | undefined;
|
||||
|
||||
const fieldTypeName = translations?.[field.type] || field.type;
|
||||
|
||||
const fieldText: Konva.Text =
|
||||
pageLayer.findOne(`#${field.renderId}-text`) ||
|
||||
new Konva.Text({
|
||||
@ -30,24 +33,21 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
const textY = 0;
|
||||
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
|
||||
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
|
||||
let textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
const textPadding = 10;
|
||||
|
||||
let textToRender: string = field.type;
|
||||
let textToRender: string = fieldTypeName;
|
||||
|
||||
// Handle edit mode.
|
||||
if (mode === 'edit') {
|
||||
textToRender = field.type; // Todo: Envelope - Need translations
|
||||
textToRender = fieldTypeName;
|
||||
textAlign = 'center';
|
||||
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
|
||||
textVerticalAlign = 'middle';
|
||||
|
||||
if (textMeta?.label) {
|
||||
textToRender = textMeta.label;
|
||||
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
} else if (textMeta?.text) {
|
||||
textToRender = textMeta.text;
|
||||
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
@ -59,19 +59,16 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
|
||||
// Handle sign mode.
|
||||
if (mode === 'sign' || mode === 'export') {
|
||||
textToRender = field.type; // Todo: Envelope - Need translations
|
||||
textToRender = fieldTypeName;
|
||||
textAlign = 'center';
|
||||
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
|
||||
textVerticalAlign = 'middle';
|
||||
|
||||
if (textMeta?.label) {
|
||||
textToRender = textMeta.label;
|
||||
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
}
|
||||
|
||||
if (textMeta?.text) {
|
||||
textToRender = textMeta.text;
|
||||
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
@ -82,7 +79,6 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
|
||||
if (field.inserted) {
|
||||
textToRender = field.customText;
|
||||
textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
|
||||
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
|
||||
|
||||
// Todo: Envelopes - Handle this on signatures
|
||||
@ -98,11 +94,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
|
||||
verticalAlign: textVerticalAlign,
|
||||
wrap: 'word',
|
||||
padding: textPadding,
|
||||
|
||||
text: textToRender,
|
||||
|
||||
fontSize: textFontSize,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontFamily: konvaTextFontFamily,
|
||||
fill: konvaTextFill,
|
||||
align: textAlign,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
@ -119,79 +114,63 @@ export const renderTextFieldElement = (
|
||||
|
||||
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
|
||||
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
// ABOVE IS GENERIC, EXTRACT IT.
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
}
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldText = upsertFieldText(field, options);
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
pageLayer.add(fieldGroup);
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth);
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
// Force Konva to recalculate text layout
|
||||
fieldText.height();
|
||||
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
// Force Konva to recalculate text layout
|
||||
fieldText.height();
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
}
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Handle export mode.
|
||||
if (mode === 'export') {
|
||||
@ -199,33 +178,7 @@ export const renderTextFieldElement = (
|
||||
fieldRect.opacity(0);
|
||||
}
|
||||
|
||||
// Todo: Doesn't work.
|
||||
if (mode !== 'export') {
|
||||
const hoverColor = options.color
|
||||
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
|
||||
: '#e5e7eb';
|
||||
|
||||
// Todo: Envelopes - On hover add text color
|
||||
|
||||
// Add smooth transition-like behavior for hover effects
|
||||
fieldGroup.on('mouseover', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: hoverColor,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.on('mouseout', () => {
|
||||
new Konva.Tween({
|
||||
node: fieldRect,
|
||||
duration: 0.3,
|
||||
fill: DEFAULT_RECT_BACKGROUND,
|
||||
}).play();
|
||||
});
|
||||
|
||||
fieldGroup.add(fieldRect);
|
||||
}
|
||||
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
|
||||
|
||||
return {
|
||||
fieldGroup,
|
||||
|
||||
@ -353,6 +353,13 @@ export const formatDocumentAuditLogAction = (
|
||||
}),
|
||||
identified: msg`${prefix} deleted the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `System auto inserted fields`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
identified: msg`System auto inserted fields`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Field signed`,
|
||||
@ -515,6 +522,14 @@ export const formatDocumentAuditLogAction = (
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item created`,
|
||||
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
|
||||
anonymous: msg`Envelope item deleted`,
|
||||
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
|
||||
@ -76,6 +76,7 @@ export const mapEnvelopeToDocumentLite = (envelope: Envelope): TDocumentLite =>
|
||||
return {
|
||||
id: documentId, // Use legacy ID.
|
||||
envelopeId: envelope.id,
|
||||
internalVersion: envelope.internalVersion,
|
||||
visibility: envelope.visibility,
|
||||
status: envelope.status,
|
||||
source: envelope.source,
|
||||
@ -115,6 +116,7 @@ export const mapEnvelopesToDocumentMany = (
|
||||
return {
|
||||
id: legacyDocumentId, // Use legacy ID.
|
||||
envelopeId: envelope.id,
|
||||
internalVersion: envelope.internalVersion,
|
||||
visibility: envelope.visibility,
|
||||
status: envelope.status,
|
||||
source: envelope.source,
|
||||
|
||||
263
packages/lib/utils/envelope-signing.ts
Normal file
263
packages/lib/utils/envelope-signing.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TDocumentMeta } from '@documenso/lib/types/document-meta';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
export type ExtractFieldInsertionValuesOptions = {
|
||||
fieldValue: TSignEnvelopeFieldValue;
|
||||
field: Field;
|
||||
documentMeta: Pick<TDocumentMeta, 'timezone' | 'dateFormat' | 'typedSignatureEnabled'>;
|
||||
};
|
||||
|
||||
export const extractFieldInsertionValues = ({
|
||||
fieldValue,
|
||||
field,
|
||||
documentMeta,
|
||||
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
|
||||
return match(fieldValue)
|
||||
.with({ type: FieldType.EMAIL }, (fieldValue) => {
|
||||
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
|
||||
|
||||
if (!parsedEmailValue.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid email',
|
||||
});
|
||||
}
|
||||
|
||||
if (parsedEmailValue.data === null) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customText: parsedEmailValue.data,
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: P.union(FieldType.NAME, FieldType.INITIALS) }, (fieldValue) => {
|
||||
const parsedGenericStringValue = z.string().min(1).nullable().safeParse(fieldValue.value);
|
||||
|
||||
if (!parsedGenericStringValue.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Value is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (parsedGenericStringValue.data === null) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customText: parsedGenericStringValue.data,
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.DATE }, (fieldValue) => {
|
||||
if (!fieldValue.value) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customText: DateTime.now()
|
||||
.setZone(documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.NUMBER }, (fieldValue) => {
|
||||
if (!fieldValue.value) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
|
||||
|
||||
// Todo
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid number',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customText: fieldValue.value.toString(),
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.TEXT }, (fieldValue) => {
|
||||
if (fieldValue.value === null) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
|
||||
|
||||
// Todo
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid email',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customText: fieldValue.value,
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.RADIO }, (fieldValue) => {
|
||||
if (fieldValue.value === null) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedRadioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const radioFieldValues = parsedRadioFieldParsedMeta.values || [];
|
||||
|
||||
if (!radioFieldValues[fieldValue.value]) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid radio value',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customText: toRadioCustomText(fieldValue.value),
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.CHECKBOX }, (fieldValue) => {
|
||||
if (fieldValue.value === null || fieldValue.value.length === 0) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedCheckboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const checkboxFieldValues = parsedCheckboxFieldParsedMeta.values || [];
|
||||
|
||||
const { value } = fieldValue;
|
||||
|
||||
const selectedValues = value.map((valueIndex) => checkboxFieldValues[valueIndex]);
|
||||
|
||||
if (selectedValues.some((value) => !value)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid checkbox values',
|
||||
});
|
||||
}
|
||||
|
||||
const { validationRule, validationLength } = parsedCheckboxFieldParsedMeta;
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const checkboxValidationRule = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === validationRule,
|
||||
);
|
||||
|
||||
// Todo: Envelopes - Test this.
|
||||
if (checkboxValidationRule) {
|
||||
const isValid = validateCheckboxLength(
|
||||
selectedValues.length,
|
||||
checkboxValidationRule.value,
|
||||
validationLength,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Checkbox values failed length validation',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Should throw an error, but we don't want to throw configuration errors during signing.
|
||||
// Todo: Logging.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customText: toCheckboxCustomText(fieldValue.value),
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.DROPDOWN }, (fieldValue) => {
|
||||
if (fieldValue.value === null) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
|
||||
|
||||
// Todo: Envelopes
|
||||
if (errors.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid dropdown value',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customText: fieldValue.value,
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.SIGNATURE }, (fieldValue) => {
|
||||
const { value } = fieldValue;
|
||||
|
||||
if (!value) {
|
||||
return {
|
||||
customText: '',
|
||||
inserted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isBase64 = isBase64Image(value);
|
||||
|
||||
if (documentMeta.typedSignatureEnabled === false && !isBase64) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Typed signatures are not allowed. Please draw your signature',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customText: '',
|
||||
inserted: true,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
import { type Envelope, type Field } from '@prisma/client';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type Envelope, type Field, FieldType } from '@prisma/client';
|
||||
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
@ -77,3 +79,35 @@ export const mapFieldToLegacyField = (
|
||||
...legacyId,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseCheckboxCustomText = (customText: string): number[] => {
|
||||
return JSON.parse(customText);
|
||||
};
|
||||
|
||||
export const toCheckboxCustomText = (checkedValues: number[]): string => {
|
||||
return JSON.stringify(checkedValues);
|
||||
};
|
||||
|
||||
export const parseRadioCustomText = (customText: string): number => {
|
||||
return Number(customText);
|
||||
};
|
||||
|
||||
export const toRadioCustomText = (value: number): string => {
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export const getClientSideFieldTranslations = ({ t }: I18n): Record<FieldType, string> => {
|
||||
return {
|
||||
[FieldType.TEXT]: t(msg`Text`),
|
||||
[FieldType.CHECKBOX]: t(msg`Checkbox`),
|
||||
[FieldType.RADIO]: t(msg`Radio`),
|
||||
[FieldType.DROPDOWN]: t(msg`Select Option`),
|
||||
[FieldType.SIGNATURE]: t(msg`Signature`),
|
||||
[FieldType.FREE_SIGNATURE]: t(msg`Free Signature`),
|
||||
[FieldType.INITIALS]: t(msg`Initials`),
|
||||
[FieldType.NAME]: t(msg`Name`),
|
||||
[FieldType.NUMBER]: t(msg`Number`),
|
||||
[FieldType.DATE]: t(msg`Date`),
|
||||
[FieldType.EMAIL]: t(msg`Email`),
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
|
||||
|
||||
export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
SubscriptionClaim,
|
||||
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
|
||||
@ -8,6 +10,7 @@ export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
name: '',
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
locked: false,
|
||||
flags: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user