Compare commits

..

5 Commits

Author SHA1 Message Date
0aae25e423 fix: build errors 2025-10-20 22:19:31 +00:00
a39b4efc28 Merge branch 'main' into feat/team-dashboard 2025-10-20 15:27:25 +00:00
06cb8b1f23 fix: email attachment formats (#2077) 2025-10-16 14:16:00 +11:00
7f09ba72f4 feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
2025-10-14 21:56:36 +11:00
202702b1c7 feat: team analytics 2025-08-20 15:19:09 +00:00
47 changed files with 1768 additions and 874 deletions

View File

@ -0,0 +1,67 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { MonthlyStats } from '@documenso/lib/server-only/analytics';
import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts';
export type AnalyticsChartsProps = {
monthlyData?: MonthlyStats;
showActiveUsers?: boolean;
};
export const AnalyticsCharts = ({ monthlyData, showActiveUsers = false }: AnalyticsChartsProps) => {
const { _ } = useLingui();
if (!monthlyData) {
return null;
}
// Ensure all data has cume_count for chart compatibility
const formatDataForChart = (data: MonthlyStats) => {
return data.map((item) => ({
...item,
count: item.count,
cume_count: item.cume_count || 0,
}));
};
return (
<div className="mt-16">
<h3 className="text-3xl font-semibold">
<Trans>Charts</Trans>
</h3>
<div className="mt-5 grid grid-cols-1 gap-8 lg:grid-cols-2">
{showActiveUsers && (
<>
<MonthlyActiveUsersChart
title={_(msg`Active Users (created document)`)}
data={formatDataForChart(monthlyData)}
/>
<MonthlyActiveUsersChart
title={_(msg`Cumulative Active Users`)}
data={formatDataForChart(monthlyData)}
cummulative
/>
</>
)}
{!showActiveUsers && (
<>
<MonthlyActiveUsersChart
title={_(msg`Documents Created`)}
data={formatDataForChart(monthlyData)}
/>
<MonthlyActiveUsersChart
title={_(msg`Documents Completed`)}
data={formatDataForChart(
monthlyData.map((d) => ({ ...d, count: d.signed_count || 0 })),
)}
/>
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,88 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
export type DateFilterPeriod = 'all' | 'year' | 'month' | 'week' | 'day';
export type AnalyticsDateFilterProps = {
value: DateFilterPeriod;
onChange: (value: DateFilterPeriod) => void;
className?: string;
};
export const AnalyticsDateFilter = ({ value, onChange, className }: AnalyticsDateFilterProps) => {
const { _ } = useLingui();
const options = useMemo(
() => [
{ value: 'all' as const, label: _(msg`All Time`) },
{ value: 'year' as const, label: _(msg`This Year`) },
{ value: 'month' as const, label: _(msg`This Month`) },
{ value: 'week' as const, label: _(msg`This Week`) },
{ value: 'day' as const, label: _(msg`Today`) },
],
[_],
);
const selectedOption = options.find((option) => option.value === value);
return (
<div className={className}>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder={_(msg`Select period`)}>{selectedOption?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
export const getDateRangeFromPeriod = (period: DateFilterPeriod) => {
const now = DateTime.now();
switch (period) {
case 'day':
return {
dateFrom: now.startOf('day').toJSDate(),
dateTo: now.endOf('day').toJSDate(),
};
case 'week':
return {
dateFrom: now.startOf('week').toJSDate(),
dateTo: now.endOf('week').toJSDate(),
};
case 'month':
return {
dateFrom: now.startOf('month').toJSDate(),
dateTo: now.endOf('month').toJSDate(),
};
case 'year':
return {
dateFrom: now.startOf('year').toJSDate(),
dateTo: now.endOf('year').toJSDate(),
};
case 'all':
default:
return {
dateFrom: undefined,
dateTo: undefined,
};
}
};

View File

@ -0,0 +1,105 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
File,
FileCheck,
FileClock,
FileEdit,
Mail,
MailOpen,
PenTool,
UserSquare2,
} from 'lucide-react';
import type { DocumentStats, RecipientStats } from '@documenso/lib/server-only/analytics';
import { cn } from '@documenso/ui/lib/utils';
import { CardMetric } from '~/components/general/metric-card';
export type AnalyticsMetricsProps = {
docStats: DocumentStats;
recipientStats: RecipientStats;
isLoading?: boolean;
className?: string;
};
export const AnalyticsMetrics = ({
docStats,
recipientStats,
isLoading = false,
className,
}: AnalyticsMetricsProps) => {
const { _ } = useLingui();
return (
<div className={className}>
{/* Overview Metrics */}
<div
className={cn('grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4', {
'pointer-events-none opacity-50': isLoading,
})}
>
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
<CardMetric
icon={UserSquare2}
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
{/* Document Metrics Section */}
<div className="mt-16">
<h3 className="text-3xl font-semibold">
<Trans>Document metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
<CardMetric icon={FileClock} title={_(msg`Pending Documents`)} value={docStats.PENDING} />
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
</div>
</div>
{/* Recipients Metrics Section */}
<div>
<h3 className="text-3xl font-semibold">
<Trans>Recipients metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title={_(msg`Documents Sent`)} value={recipientStats.SENT} />
<CardMetric
icon={MailOpen}
title={_(msg`Documents Viewed`)}
value={recipientStats.OPENED}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,71 @@
import { Trans } from '@lingui/react/macro';
import { useNavigation } from 'react-router';
import type {
DocumentStats,
MonthlyStats,
RecipientStats,
} from '@documenso/lib/server-only/analytics';
import { AnalyticsCharts } from './analytics-charts';
import { AnalyticsDateFilter, type DateFilterPeriod } from './analytics-date-filter';
import { AnalyticsMetrics } from './analytics-metrics';
export type AnalyticsPageData = {
docStats: DocumentStats;
recipientStats: RecipientStats;
monthlyData?: MonthlyStats;
period: DateFilterPeriod;
};
export type AnalyticsPageProps = {
title: string;
subtitle: string;
data: AnalyticsPageData;
showCharts?: boolean;
onPeriodChange: (period: DateFilterPeriod) => void;
containerClassName?: string;
};
export const AnalyticsPage = ({
title,
subtitle,
data,
showCharts = true,
onPeriodChange,
containerClassName = 'mx-auto w-full max-w-screen-xl px-4 md:px-8',
}: AnalyticsPageProps) => {
const navigation = useNavigation();
const { docStats, recipientStats, monthlyData, period } = data;
const isLoading = navigation.state === 'loading';
return (
<div className={containerClassName}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-4xl font-semibold">
<Trans>{title}</Trans>
</h2>
<p className="text-muted-foreground mt-2">
<Trans>{subtitle}</Trans>
</p>
</div>
<AnalyticsDateFilter value={period} onChange={onPeriodChange} />
</div>
<AnalyticsMetrics
docStats={docStats}
recipientStats={recipientStats}
isLoading={isLoading}
className="mt-8"
/>
{showCharts && (
<AnalyticsCharts
monthlyData={monthlyData}
showActiveUsers={!!monthlyData?.[0]?.cume_count}
/>
)}
</div>
);
};

View File

@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
const distributionMethod = watch('meta.distributionMethod'); const distributionMethod = watch('meta.distributionMethod');
const recipientsMissingSignatureFields = useMemo( const everySignerHasSignature = useMemo(
() => () =>
envelope.recipients.filter( envelope.recipients
(recipient) => .filter((recipient) => recipient.role === RecipientRole.SIGNER)
recipient.role === RecipientRole.SIGNER && .every((recipient) =>
!envelope.fields.some( envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
), ),
), ),
[envelope.recipients, envelope.fields], [envelope.recipients, envelope.fields],
); );
@ -178,7 +178,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<Trans>Recipients will be able to sign the document once sent</Trans> <Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{recipientsMissingSignatureFields.length === 0 ? ( {everySignerHasSignature ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}> <fieldset disabled={isSubmitting}>
@ -350,8 +350,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</div> </div>
) : ( ) : (
<ul className="text-muted-foreground divide-y"> <ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && ( {recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm"> <li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans> <Trans>No recipients</Trans>
@ -429,13 +427,10 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<> <>
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription>
<Trans>The following signers are missing signature fields:</Trans> <Trans>
Some signers have not been assigned a signature field. Please assign at least 1
<ul className="ml-2 mt-1 list-inside list-disc"> signature field to each signer before proceeding.
{recipientsMissingSignatureFields.map((recipient) => ( </Trans>
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@ -1,15 +1,40 @@
import { useLingui } from '@lingui/react/macro'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call'; import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta'; import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { import {
CommandDialog, Dialog,
CommandEmpty, DialogContent,
CommandGroup, DialogDescription,
CommandInput, DialogFooter,
CommandItem, DialogHeader,
CommandList, DialogTitle,
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZSignFieldDropdownFormSchema = z.object({
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
});
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
export type SignFieldDropdownDialogProps = { export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta; fieldMeta: TDropdownFieldMeta;
@ -21,20 +46,72 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? []; const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return ( return (
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<CommandInput placeholder={t`Select an option`} /> <DialogContent position="center">
<CommandList> <DialogHeader>
<CommandEmpty>No results found.</CommandEmpty> <DialogTitle>
<CommandGroup heading={t`Options`}> <Trans>Sign Dropdown Field</Trans>
{values.map((value, i) => ( </DialogTitle>
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value} <DialogDescription className="mt-4">
</CommandItem> <Trans>Select a value to sign into the field</Trans>
))} </DialogDescription>
</CommandGroup> </DialogHeader>
</CommandList>
</CommandDialog> <Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="dropdown"
render={({ field }) => (
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => (
<SelectItem key={i} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
); );
}, },
); );

View File

@ -45,32 +45,26 @@ const ZDropdownFieldFormSchema = z
.min(1, { .min(1, {
message: msg`Dropdown must have at least one option`.id, message: msg`Dropdown must have at least one option`.id,
}) })
.superRefine((values, ctx) => { .refine(
const seen = new Map<string, number[]>(); // value → indices (data) => {
values.forEach((item, index) => { // Todo: Envelopes - This doesn't work.
const key = item.value; console.log({
if (!seen.has(key)) { data,
seen.set(key, []); });
}
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) { if (data) {
if (indices.length > 1 && key.trim() !== '') { const values = data.map((item) => item.value);
for (const i of indices) { return new Set(values).size === values.length;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
}
} }
} return true;
}), },
{
message: 'Duplicate values are not allowed',
},
),
required: z.boolean().optional(), required: z.boolean().optional(),
readOnly: z.boolean().optional(), readOnly: z.boolean().optional(),
}) })
// Todo: Envelopes - This doesn't work
.refine( .refine(
(data) => { (data) => {
// Default value must be one of the available options // Default value must be one of the available options
@ -117,20 +111,7 @@ export const EditorFieldDropdownForm = ({
const addValue = () => { const addValue = () => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
let newValue = 'New option';
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
} else {
break;
}
}
const newValues = [...currentValues, { value: newValue }];
form.setValue('values', newValues); form.setValue('values', newValues);
}; };
@ -146,10 +127,6 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1); newValues.splice(index, 1);
form.setValue('values', newValues); form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
}; };
useEffect(() => { useEffect(() => {
@ -186,26 +163,20 @@ export const EditorFieldDropdownForm = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field} {...field}
value={field.value === null ? '-1' : field.value} value={field.value}
onValueChange={(value) => field.onChange(value === undefined ? null : value)} onValueChange={(val) => field.onChange(val)}
> >
{/* Todo: Envelopes - THis is cooked */}
<SelectTrigger className="text-muted-foreground bg-background w-full"> <SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} /> <SelectValue placeholder={t`Default Value`} />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{(formValues.values || []) {(formValues.values || []).map((item, index) => (
.filter((item) => item.value) <SelectItem key={index} value={item.value || ''}>
.map((item, index) => ( {item.value}
<SelectItem key={index} value={item.value || ''}> </SelectItem>
{item.value} ))}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>None</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -1,32 +1,15 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react'; import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import { z } from 'zod';
import { import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
type TRadioFieldMeta as RadioFieldMeta,
ZRadioFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
@ -34,26 +17,31 @@ import {
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({ const ZRadioFieldFormSchema = z
label: true, .object({
direction: true, label: z.string().optional(),
values: true, values: z
required: true, .object({ id: z.number(), checked: z.boolean(), value: z.string() })
readOnly: true, .array()
}).refine( .min(1)
(data) => { .optional(),
// There cannot be more than one checked option required: z.boolean().optional(),
if (data.values) { readOnly: z.boolean().optional(),
const checkedValues = data.values.filter((option) => option.checked); })
return checkedValues.length <= 1; .refine(
} (data) => {
return true; // There cannot be more than one checked option
}, if (data.values) {
{ const checkedValues = data.values.filter((option) => option.checked);
message: 'There cannot be more than one checked option', return checkedValues.length <= 1;
path: ['values'], }
}, return true;
); },
{
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>; type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
@ -65,12 +53,9 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({ export const EditorFieldRadioForm = ({
value = { value = {
type: 'radio', type: 'radio',
direction: 'vertical',
}, },
onValueChange, onValueChange,
}: EditorFieldRadioFormProps) => { }: EditorFieldRadioFormProps) => {
const { t } = useLingui();
const form = useForm<TRadioFieldFormSchema>({ const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema), resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange', mode: 'onChange',
@ -79,7 +64,6 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }], values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
}, },
}); });
@ -123,35 +107,7 @@ export const EditorFieldRadioForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2 pb-2">
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Direction</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
<EditorGenericReadOnlyField formControl={form.control} /> <EditorGenericReadOnlyField formControl={form.control} />

View File

@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId, selectedRecipientId,
selectedEnvelopeItemId, selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => { }: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
const { t } = useLingui(); const { t } = useLingui();
@ -262,10 +262,6 @@ export const EnvelopeEditorFieldDragDrop = ({
}; };
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
return ( return (
<> <>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
@ -277,23 +273,12 @@ export const EnvelopeEditorFieldDragDrop = ({
onClick={() => setSelectedField(field.type)} onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className, field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
)} )}
> >
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />} {field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@ -307,7 +292,8 @@ export const EnvelopeEditorFieldDragDrop = ({
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base, // selectedSignerStyles?.base,
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,

View File

@ -3,12 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client'; import type { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer'; import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react'; import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@ -23,10 +26,27 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui(); const { t } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const interactiveTransformer = useRef<Transformer | null>(null); const interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]); const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@ -34,17 +54,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false); const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null); const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const { const viewport = useMemo(
stage, () => page.getViewport({ scale, rotation: rotate }),
pageLayer, [page, rotate, scale],
canvasElement, );
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@ -55,6 +68,44 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved'); console.log('Field resized or moved');
@ -69,7 +120,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group; const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id(); const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const { const {
width: fieldPixelWidth, width: fieldPixelWidth,
height: fieldPixelHeight, height: fieldPixelHeight,
@ -80,8 +130,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true, skipShadow: true,
}); });
const pageHeight = scaledViewport.height; const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100; const positionPercentX = (fieldX / pageWidth) * 100;
@ -116,7 +165,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
const renderFieldOnLayer = (field: TLocalField) => { const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current || !interactiveTransformer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
} }
@ -125,8 +174,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable = const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields); recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({ const { fieldGroup, isFirstRender } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.formId, renderId: field.formId,
@ -135,8 +183,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId), color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable, editable: isFieldEditable,
mode: 'edit', mode: 'edit',
@ -162,14 +210,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Initialize snap guides layer // Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current); // snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating. // Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer); interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
@ -177,12 +235,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// Handle stage click to deselect. // Handle stage click to deselect.
currentStage.on('click', (e) => { stage.current?.on('click', (e) => {
removePendingField(); removePendingField();
if (e.target === stage.current) { if (e.target === stage.current) {
setSelectedFields([]); setSelectedFields([]);
currentPageLayer.batchDraw(); pageLayer.current?.batchDraw();
} }
}); });
@ -209,12 +267,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]); setSelectedFields([e.target]);
}; };
currentStage.on('dragstart', onDragStartOrEnd); stage.current?.on('dragstart', onDragStartOrEnd);
currentStage.on('dragend', onDragStartOrEnd); stage.current?.on('dragend', onDragStartOrEnd);
currentStage.on('transformstart', () => setIsFieldChanging(true)); stage.current?.on('transformstart', () => setIsFieldChanging(true));
currentStage.on('transformend', () => setIsFieldChanging(false)); stage.current?.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -226,10 +284,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields * - Selecting multiple fields
* - Selecting empty area to create fields * - Selecting empty area to create fields
*/ */
const createInteractiveTransformer = ( const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({ const transformer = new Konva.Transformer({
rotateEnabled: false, rotateEnabled: false,
keepRatio: false, keepRatio: false,
@ -246,39 +301,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}, },
}); });
currentPageLayer.add(transformer); layer.add(transformer);
// Add selection rectangle. // Add selection rectangle.
const selectionRectangle = new Konva.Rect({ const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
visible: false, visible: false,
}); });
currentPageLayer.add(selectionRectangle); layer.add(selectionRectangle);
let x1: number; let x1: number;
let y1: number; let y1: number;
let x2: number; let x2: number;
let y2: number; let y2: number;
currentStage.on('mousedown touchstart', (e) => { stage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape // do nothing if we mousedown on any shape
if (e.target !== currentStage) { if (e.target !== stage) {
return; return;
} }
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
console.log(`pointerPosition.x: ${pointerPosition.x}`); x1 = pointerPosition.x;
console.log(`pointerPosition.y: ${pointerPosition.y}`); y1 = pointerPosition.y;
x2 = pointerPosition.x;
x1 = pointerPosition.x / scale; y2 = pointerPosition.y;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: x1, x: x1,
@ -289,7 +341,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mousemove touchmove', () => { stage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -297,14 +349,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop(); selectionRectangle.moveToTop();
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x2 = pointerPosition.x / scale; x2 = pointerPosition.x;
y2 = pointerPosition.y / scale; y2 = pointerPosition.y;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: Math.min(x1, x2), x: Math.min(x1, x2),
@ -314,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mouseup touchend', () => { stage.on('mouseup touchend', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -325,41 +377,38 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false); selectionRectangle.visible(false);
}); });
const stageFieldGroups = currentStage.find('.field-group') || []; const stageFieldGroups = stage.find('.field-group') || [];
const box = selectionRectangle.getClientRect(); const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter( const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(), (shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
); );
setSelectedFields(selectedFieldGroups); setSelectedFields(selectedFieldGroups);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current && canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX && box.width > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && box.height > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) { ) {
const pendingFieldCreation = new Konva.Rect({ const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation', name: 'pending-field-creation',
x: box.x / scale, x: box.x,
y: box.y / scale, y: box.y,
width: unscaledBoxWidth, width: box.width,
height: unscaledBoxHeight, height: box.height,
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
}); });
currentPageLayer.add(pendingFieldCreation); layer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation); setPendingFieldCreation(pendingFieldCreation);
} }
}); });
// Clicks should select/deselect shapes // Clicks should select/deselect shapes
currentStage.on('click tap', function (e) { stage.on('click tap', function (e) {
// if we are selecting with rect, do nothing // if we are selecting with rect, do nothing
if ( if (
selectionRectangle.visible() && selectionRectangle.visible() &&
@ -370,7 +419,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// If empty area clicked, remove all selections // If empty area clicked, remove all selections
if (e.target === stage.current) { if (e.target === stage) {
setSelectedFields([]); setSelectedFields([]);
return; return;
} }
@ -506,13 +555,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return; return;
} }
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({ const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth, width: pixelWidth,
height: pixelHeight, height: pixelHeight,
positionX: pixelX, positionX: pixelX,
positionY: pixelY, positionY: pixelY,
pageWidth: unscaledViewport.width, pageWidth,
pageHeight: unscaledViewport.height, pageHeight,
}); });
editorFields.addField({ editorFields.addField({
@ -546,10 +597,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@ -606,15 +654,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
pendingFieldCreation.y() * scale + left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 50, zIndex: 50,
}} }}
@ -632,15 +673,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
{/* The element Konva will inject it's canvas into. */} <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`, [FieldType.DROPDOWN]: msg`Dropdown Settings`,
}; };
export const EnvelopeEditorFieldsPage = () => { export const EnvelopeEditorPageFields = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -109,7 +109,7 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex justify-center p-4"> <div className="mt-4 flex justify-center">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : ( ) : (

View File

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeEditorPagePreviewRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
renderField({
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'export',
});
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
};
/**
* Render fields when they are added or removed from the localFields.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
/>
</div>
);
}

View File

@ -13,9 +13,11 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer')); const EnvelopeEditorPagePreviewRenderer = lazy(
async () => import('./envelope-editor-page-preview-renderer'),
);
export const EnvelopeEditorPreviewPage = () => { export const EnvelopeEditorPagePreview = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -49,7 +51,7 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert> </Alert>
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <FileTextIcon className="text-muted-foreground h-10 w-10" />

View File

@ -41,7 +41,7 @@ type LocalFile = {
isError: boolean; isError: boolean;
}; };
export const EnvelopeEditorUploadPage = () => { export const EnvelopeEditorPageUpload = () => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useLingui(); const { t } = useLingui();
@ -224,12 +224,8 @@ export const EnvelopeEditorUploadPage = () => {
<div className="mx-auto max-w-4xl space-y-6 p-8"> <div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle> <CardTitle>Documents</CardTitle>
<Trans>Documents</Trans> <CardDescription>Add and configure multiple documents</CardDescription>
</CardTitle>
<CardDescription>
<Trans>Add and configure multiple documents</Trans>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header'; import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page'; import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page'; import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@ -128,18 +128,6 @@ export default function EnvelopeEditor() {
} }
}; };
// Watch the URL params and setStep if the step changes.
useEffect(() => {
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!isAutosaving) { if (!isAutosaving) {
setIsStepLoading(false); setIsStepLoading(false);
@ -163,9 +151,7 @@ export default function EnvelopeEditor() {
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>} {isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs"> <span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
<Trans context="The step counter"> Step {currentStepData.order}/{envelopeEditorSteps.length}
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span> </span>
</h3> </h3>
@ -354,12 +340,13 @@ export default function EnvelopeEditor() {
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
<AnimateGenericFadeInOut key={currentStep}> <AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading }) {match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />) .with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />) .with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />) .with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
.exhaustive()} .exhaustive()}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</div> </div>

View File

@ -1,31 +1,41 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer() {
const { t } = useLingui(); const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const { const canvasElement = useRef<HTMLCanvasElement>(null);
stage, const konvaContainer = useRef<HTMLDivElement>(null);
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { _className, scale } = pageContext; const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@ -36,6 +46,44 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber], [fields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
@ -43,7 +91,6 @@ export default function EnvelopeGenericPageRenderer() {
} }
renderField({ renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
@ -56,8 +103,8 @@ export default function EnvelopeGenericPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
// color: getRecipientColorKey(field.recipientId), // color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo color: 'purple', // Todo
editable: false, editable: false,
@ -66,15 +113,25 @@ export default function EnvelopeGenericPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field);
} }
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -110,19 +167,14 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full" <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -1,12 +1,14 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { type Field, FieldType, type Signature } from '@prisma/client'; import { type Field, FieldType } from '@prisma/client';
import type Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
@ -26,6 +28,18 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui(); const { t } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -44,20 +58,21 @@ export default function EnvelopeSignerPageRenderer() {
setSignature, setSignature,
} = useRequiredEnvelopeSigningContext(); } = useRequiredEnvelopeSigningContext();
const { console.log({ fullName });
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData; const { envelope } = envelopeData;
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
recipientFields.filter( recipientFields.filter(
@ -67,7 +82,45 @@ export default function EnvelopeSignerPageRenderer() {
[recipientFields, pageContext.pageNumber], [recipientFields, pageContext.pageNumber],
); );
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { // Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
@ -84,7 +137,6 @@ export default function EnvelopeSignerPageRenderer() {
} }
const { fieldGroup } = renderField({ const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: fieldToRender.id.toString(), renderId: fieldToRender.id.toString(),
@ -93,10 +145,9 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height), height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX), positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY), positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
color, color,
mode: 'sign', mode: 'sign',
}); });
@ -306,19 +357,29 @@ export default function EnvelopeSignerPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
console.log({ console.log({
localPageFields, localPageFields,
}); });
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering renderFieldOnLayer(field);
} }
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -331,7 +392,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas'); console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering renderFieldOnLayer(field);
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
@ -342,19 +403,14 @@ export default function EnvelopeSignerPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -19,8 +19,6 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card'; import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = { export type FolderGridProps = {
type: FolderType; type: FolderType;
parentId: string | null; parentId: string | null;
@ -100,7 +98,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end"> <div className="flex gap-4 sm:flex-row sm:justify-end">
{/* Todo: Envelopes - Feature flag */} {/* Todo: Envelopes - Feature flag */}
<EnvelopeUploadButton type={type} folderId={parentId || undefined} /> {/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
{type === FolderType.DOCUMENT ? ( {type === FolderType.DOCUMENT ? (
<DocumentUploadButton /> <DocumentUploadButton />

View File

@ -97,6 +97,12 @@ export const MenuSwitcher = () => {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/analytics">
<Trans>Personal analytics</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-muted-foreground px-4 py-2" className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)} onClick={() => setLanguageSwitcherOpen(true)}

View File

@ -307,11 +307,18 @@ export const OrgMenuSwitcher = () => {
)} )}
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && ( {currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild> <>
<Link to={`/t/${currentTeam.url}/settings`}> <DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Trans>Team settings</Trans> <Link to={`/t/${currentTeam.url}/settings`}>
</Link> <Trans>Team settings</Trans>
</DropdownMenuItem> </Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to={`/t/${currentTeam.url}/analytics`}>
<Trans>Team analytics</Trans>
</Link>
</DropdownMenuItem>
</>
)} )}
<DropdownMenuItem <DropdownMenuItem

View File

@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import {
BarChart3Icon,
BracesIcon, BracesIcon,
CreditCardIcon, CreditCardIcon,
Globe2Icon, Globe2Icon,
@ -128,6 +129,19 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
</Button> </Button>
</Link> </Link>
<Link to="/settings/analytics">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
)}
>
<BarChart3Icon className="mr-2 h-5 w-5" />
<Trans>Analytics</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link to="/settings/billing"> <Link to="/settings/billing">
<Button <Button

View File

@ -2,6 +2,7 @@ import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import {
BarChart3Icon,
BracesIcon, BracesIcon,
CreditCardIcon, CreditCardIcon,
Globe2Icon, Globe2Icon,
@ -128,6 +129,19 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
</Button> </Button>
</Link> </Link>
<Link to="/settings/analytics">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/analytics') && 'bg-secondary',
)}
>
<BarChart3Icon className="mr-2 h-5 w-5" />
<Trans>Analytics</Trans>
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link to="/settings/billing"> <Link to="/settings/billing">
<Button <Button

View File

@ -0,0 +1,24 @@
import { useSearchParams } from 'react-router';
import type { DateFilterPeriod } from '~/components/analytics/analytics-date-filter';
export const useAnalyticsFilter = () => {
const [searchParams, setSearchParams] = useSearchParams();
const period = (searchParams.get('period') as DateFilterPeriod) || 'all';
const handlePeriodChange = (newPeriod: DateFilterPeriod) => {
const params = new URLSearchParams(searchParams);
if (newPeriod === 'all') {
params.delete('period');
} else {
params.set('period', newPeriod);
}
setSearchParams(params);
};
return {
period,
handlePeriodChange,
};
};

View File

@ -0,0 +1,55 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getUserDocumentStats, getUserRecipientsStats } from '@documenso/lib/server-only/analytics';
import {
type DateFilterPeriod,
getDateRangeFromPeriod,
} from '~/components/analytics/analytics-date-filter';
import { AnalyticsPage } from '~/components/analytics/analytics-page';
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics';
export function meta() {
return appMetaTags('Personal Analytics');
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request);
const url = new URL(request.url);
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
const [docStats, recipientStats] = await Promise.all([
getUserDocumentStats({ userId: session.user.id, dateFrom, dateTo }),
getUserRecipientsStats({ userId: session.user.id, dateFrom, dateTo }),
]);
return {
docStats,
recipientStats,
period,
};
}
export default function PersonalAnalyticsPage({ loaderData }: Route.ComponentProps) {
const { handlePeriodChange } = useAnalyticsFilter();
const { docStats, recipientStats, period } = loaderData;
return (
<AnalyticsPage
title="Personal Analytics"
subtitle="Your personal document signing analytics and insights"
data={{
docStats,
recipientStats,
period,
}}
showCharts={false}
onPeriodChange={handlePeriodChange}
containerClassName=""
/>
);
}

View File

@ -0,0 +1,88 @@
import { redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import {
getTeamDocumentStats,
getTeamMonthlyActiveUsers,
getTeamRecipientsStats,
} from '@documenso/lib/server-only/analytics';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import {
type DateFilterPeriod,
getDateRangeFromPeriod,
} from '~/components/analytics/analytics-date-filter';
import { AnalyticsPage } from '~/components/analytics/analytics-page';
import { useAnalyticsFilter } from '~/hooks/use-analytics-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics';
export function meta() {
return appMetaTags('Team Analytics');
}
export async function loader({ request, params }: Route.LoaderArgs) {
try {
const session = await getSession(request);
const url = new URL(request.url);
const period = (url.searchParams.get('period') as DateFilterPeriod) || 'all';
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(`/t/${params.teamUrl}`);
}
const { dateFrom, dateTo } = getDateRangeFromPeriod(period);
const [docStats, recipientStats, monthlyActiveUsers] = await Promise.all([
getTeamDocumentStats({ teamId: team.id, dateFrom, dateTo }),
getTeamRecipientsStats({ teamId: team.id, dateFrom, dateTo }),
getTeamMonthlyActiveUsers(team.id),
]);
return {
docStats,
recipientStats,
monthlyActiveUsers,
period,
};
} catch (error) {
console.error('Failed to load team analytics:', error);
// If it's a redirect, re-throw it
if (error instanceof Response) {
throw error;
}
throw new Response('Failed to load team analytics data', { status: 500 });
}
}
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
const team = useCurrentTeam();
const { handlePeriodChange } = useAnalyticsFilter();
const { docStats, recipientStats, monthlyActiveUsers, period } = loaderData;
return (
<AnalyticsPage
title="Team Analytics"
subtitle={`Analytics and insights for ${team.name}`}
data={{
docStats,
recipientStats,
monthlyData: monthlyActiveUsers,
period,
}}
showCharts={true}
onPeriodChange={handlePeriodChange}
/>
);
}

View File

@ -136,7 +136,6 @@ export const useEditorFields = ({
const field: TLocalField = { const field: TLocalField = {
...fieldData, ...fieldData,
formId: nanoid(12), formId: nanoid(12),
...restrictFieldPosValues(fieldData),
}; };
append(field); append(field);
@ -166,15 +165,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId); const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) { if (index !== -1) {
const updatedField = { update(index, { ...localFields[index], ...updates });
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate(); triggerFieldsUpdate();
} }
}, },
@ -288,14 +279,3 @@ export const useEditorFields = ({
setSelectedRecipient, 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)),
};
};

View File

@ -1,105 +0,0 @@
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);
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
const scaledViewport = useMemo(
() => page.getViewport({ scale, 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;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: scaledViewport,
};
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,
};
}

View File

@ -135,12 +135,7 @@ export const EnvelopeEditorProvider = ({
}); });
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({ const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ recipients }) => { onSuccess: () => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (error) => {
@ -220,15 +215,14 @@ export const EnvelopeEditorProvider = ({
const getRecipientColorKey = useCallback( const getRecipientColorKey = useCallback(
(recipientId: number) => { (recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex( const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId, (recipient) => recipient.id === recipientId,
); );
return AVAILABLE_RECIPIENT_COLORS[ return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
}, },
[envelope.recipients], [envelope.recipients], // Todo: Envelopes - Local recipients
); );
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery( const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(

View File

@ -0,0 +1,68 @@
import type { DocumentStatus, Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type {
AnalyticsFilters,
DocumentStats,
TeamDocumentStatsFilters,
UserDocumentStatsFilters,
} from './types';
export const getDocumentStats = async (filters: AnalyticsFilters): Promise<DocumentStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.EnvelopeWhereInput = {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
};
const counts = await prisma.envelope.groupBy({
by: ['status'],
where,
_count: {
_all: true,
},
});
const stats: DocumentStats = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat: { status: DocumentStatus; _count: { _all: number } }) => {
stats[stat.status as DocumentStatus] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamDocumentStats = async (filters: TeamDocumentStatsFilters) => {
return getDocumentStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserDocumentStats = async (filters: UserDocumentStatsFilters) => {
return getDocumentStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};

View File

@ -0,0 +1,80 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { MonthlyStats } from './types';
type MonthlyDocumentGrowthQueryResult = Array<{
month: Date;
count: bigint;
signed_count: bigint;
}>;
type MonthlyActiveUsersQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getTeamMonthlyDocumentGrowth = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getUserMonthlyDocumentGrowth = async (userId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyDocumentGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."id") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."id" END) as "signed_count"
FROM "Document"
WHERE "Document"."userId" = ${userId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};
export const getTeamMonthlyActiveUsers = async (teamId: number): Promise<MonthlyStats> => {
const result = await prisma.$queryRaw<MonthlyActiveUsersQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
SUM(COUNT(DISTINCT "Document"."userId")) OVER (ORDER BY DATE_TRUNC('month', "Document"."createdAt")) as "cume_count"
FROM "Document"
WHERE "Document"."teamId" = ${teamId}
AND "Document"."deletedAt" IS NULL
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -0,0 +1,75 @@
import { type Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type {
AnalyticsFilters,
RecipientStats,
TeamRecipientsStatsFilters,
UserRecipientsStatsFilters,
} from './types';
export const getRecipientStats = async (filters: AnalyticsFilters): Promise<RecipientStats> => {
const { dateFrom, dateTo, ...entityFilter } = filters;
const where: Prisma.RecipientWhereInput = {
envelope: {
...entityFilter,
deletedAt: null,
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
}
: {}),
},
};
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
where,
_count: true,
});
const stats: RecipientStats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};
// Legacy wrapper functions for backwards compatibility
export const getTeamRecipientsStats = async (filters: TeamRecipientsStatsFilters) => {
return getRecipientStats({
teamId: filters.teamId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};
export const getUserRecipientsStats = async (filters: UserRecipientsStatsFilters) => {
return getRecipientStats({
userId: filters.userId,
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
});
};

View File

@ -0,0 +1,4 @@
export * from './types';
export * from './get-document-stats';
export * from './get-recipient-stats';
export * from './get-monthly-stats';

View File

@ -0,0 +1,63 @@
import type { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type EntityFilter = { teamId: number } | { userId: number };
export type AnalyticsDateFilter = {
dateFrom?: Date;
dateTo?: Date;
};
export type AnalyticsFilters = EntityFilter & AnalyticsDateFilter;
export type DocumentStats = Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number>;
export type RecipientStats = {
TOTAL_RECIPIENTS: number;
[ReadStatus.OPENED]: number;
[ReadStatus.NOT_OPENED]: number;
[SigningStatus.SIGNED]: number;
[SigningStatus.NOT_SIGNED]: number;
[SigningStatus.REJECTED]: number;
[SendStatus.SENT]: number;
[SendStatus.NOT_SENT]: number;
};
export type MonthlyStats = Array<{
month: string;
count: number;
signed_count?: number;
cume_count?: number;
}>;
export type UserMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyDocumentGrowth = MonthlyStats;
export type TeamMonthlyActiveUsers = MonthlyStats;
// Legacy type exports for backwards compatibility
export type TeamDocumentStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserDocumentStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type TeamRecipientsStatsFilters = {
teamId: number;
dateFrom?: Date;
dateTo?: Date;
};
export type UserRecipientsStatsFilters = {
userId: number;
dateFrom?: Date;
dateTo?: Date;
};

View File

@ -87,6 +87,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return { return {
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(file), content: Buffer.from(file),
contentType: 'application/pdf',
}; };
}), }),
); );

View File

@ -56,7 +56,6 @@ export const sendDocument = async ({
recipients: { recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
}, },
fields: true,
documentMeta: true, documentMeta: true,
envelopeItems: { envelopeItems: {
select: { select: {
@ -166,16 +165,6 @@ export const sendDocument = async ({
}); });
} }
const fieldsToAutoInsert = [];
// Todo: Envelopes - Handle auto-signing
if (envelope.internalVersion === 2) {
// fieldsToAutoInsert = envelope.fields.filter((field) => !field.inserted);
// if (fieldsToAutoInsert.length > 0) {
// //
// }
}
const updatedEnvelope = await prisma.$transaction(async (tx) => { const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) { if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({

View File

@ -156,11 +156,9 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) { if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField( const errors = validateNumberField(
String(numberFieldParsedMeta.value), String(numberFieldParsedMeta.value),
numberFieldParsedMeta, numberFieldParsedMeta,
false,
); );
if (errors.length > 0) { if (errors.length > 0) {

View File

@ -3,6 +3,7 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva'; import Konva from 'konva';
import 'konva/skia-backend'; import 'konva/skia-backend';
import fs from 'node:fs';
import type { Canvas } from 'skia-canvas'; import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -85,7 +86,6 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
// Will render onto the layer. // Will render onto the layer.
renderField({ renderField({
scale: 1,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
...field, ...field,
@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
const renderedField = await canvas.toBuffer('svg'); const renderedField = await canvas.toBuffer('svg');
// fs.writeFileSync( fs.writeFileSync(
// `rendered-field-${field.envelopeId}--${field.id}.svg`, `rendered-field-${field.envelopeId}--${field.id}.svg`,
// renderedField.toString('utf-8'), renderedField.toString('utf-8'),
// ); );
// Embed the SVG into the PDF // Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8')); const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));

View File

@ -81,7 +81,6 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}), }),
) )
.optional(), .optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
}); });
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>; export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@ -279,7 +278,6 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
values: [{ id: 1, checked: false, value: '' }], values: [{ id: 1, checked: false, value: '' }],
required: false, required: false,
readOnly: false, readOnly: false,
direction: 'vertical',
}; };
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = { export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {

View File

@ -12,7 +12,7 @@ export const upsertFieldGroup = (
field: FieldToRender, field: FieldToRender,
options: RenderFieldElementOptions, options: RenderFieldElementOptions,
): Konva.Group => { ): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable, scale } = options; const { pageWidth, pageHeight, pageLayer, editable } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition( const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field, field,
@ -27,9 +27,6 @@ export const upsertFieldGroup = (
name: 'field-group', name: 'field-group',
}); });
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({ fieldGroup.setAttrs({
scaleX: 1, scaleX: 1,
scaleY: 1, scaleY: 1,
@ -37,9 +34,8 @@ export const upsertFieldGroup = (
y: fieldY, y: fieldY,
draggable: editable, draggable: editable,
dragBoundFunc: (pos) => { dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(maxXPosition, pos.x)); const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y)); const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
return { x: newX, y: newY }; return { x: newX, y: newY };
}, },
} satisfies Partial<Konva.GroupConfig>); } satisfies Partial<Konva.GroupConfig>);

View File

@ -26,9 +26,8 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer; pageLayer: Konva.Layer;
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
mode: 'edit' | 'sign' | 'export'; mode?: 'edit' | 'sign' | 'export';
editable?: boolean; editable?: boolean;
scale: number;
color?: TRecipientColor; color?: TRecipientColor;
}; };
@ -108,11 +107,6 @@ type CalculateMultiItemPositionOptions = {
*/ */
fieldPadding: number; fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio'; type: 'checkbox' | 'radio';
}; };
@ -128,7 +122,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize, itemSize,
spacingBetweenItemAndText, spacingBetweenItemAndText,
fieldPadding, fieldPadding,
direction,
type, type,
} = options; } = options;
@ -137,39 +130,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = fieldPadding; const innerFieldX = fieldPadding;
const innerFieldY = 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 itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY; const y = itemIndex * itemHeight + innerFieldY;
@ -177,7 +137,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2; let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX; let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') { if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2; itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2; itemInputY = y + itemHeight / 2;

View File

@ -1,5 +1,4 @@
import Konva from 'konva'; import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta'; import type { TCheckboxFieldMeta } from '../../types/field-meta';
@ -22,112 +21,104 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // Clear previous children to re-render fresh
fieldGroup.removeChildren(); fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options)); 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 checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null; const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || []; const checkboxValues = checkboxMeta?.values || [];
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,
direction: checkboxMeta?.direction || 'vertical',
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 { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
checkboxValues.forEach(({ id, value, checked }, index) => { checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({ calculateMultiItemPosition({
fieldWidth, fieldWidth,
@ -137,7 +128,6 @@ export const renderCheckboxFieldElement = (
itemSize: checkboxSize, itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText, spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding, fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox', type: 'checkbox',
}); });
@ -166,7 +156,7 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2, strokeWidth: 2,
stroke: '#111827', stroke: '#111827',
points: [3, 8, 7, 12, 13, 4], points: [3, 8, 7, 12, 13, 4],
visible: isCheckboxChecked, visible: checked,
}); });
const text = new Konva.Text({ const text = new Konva.Text({

View File

@ -47,7 +47,6 @@ type RenderFieldOptions = {
*/ */
mode: 'edit' | 'sign' | 'export'; mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean; editable?: boolean;
}; };
@ -57,7 +56,6 @@ export const renderField = ({
pageWidth, pageWidth,
pageHeight, pageHeight,
mode, mode,
scale,
editable, editable,
color, color,
}: RenderFieldOptions) => { }: RenderFieldOptions) => {
@ -68,7 +66,6 @@ export const renderField = ({
mode, mode,
color, color,
editable, editable,
scale,
}; };
return match(field.type) return match(field.type)

View File

@ -1,5 +1,4 @@
import Konva from 'konva'; import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta'; import type { TRadioFieldMeta } from '../../types/field-meta';
@ -27,99 +26,90 @@ export const renderRadioFieldElement = (
fieldGroup.add(upsertFieldRect(field, options)); fieldGroup.add(upsertFieldRect(field, options));
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
if (isFirstRender) { if (isFirstRender) {
pageLayer.add(fieldGroup); pageLayer.add(fieldGroup);
}
fieldGroup.off('transform'); // Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Handle rescaling items during transforms. const fieldRect = fieldGroup.findOne('.field-rect');
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect'); if (!fieldRect) {
return;
}
if (!fieldRect) { const rectWidth = fieldRect.width() * groupScaleX;
return; const rectHeight = fieldRect.height() * groupScaleY;
}
const rectWidth = fieldRect.width() * groupScaleX; const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const rectHeight = fieldRect.height() * groupScaleY; 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 circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id())); const groupedItems = circles.map((circle, i) => ({
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id())); circleElement: circle,
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id())); checkmarkElement: checkmarks[i],
textElement: text[i],
}));
const groupedItems = circles.map((circle, i) => ({ groupedItems.forEach((item, i) => {
circleElement: circle, const { circleElement, checkmarkElement, textElement } = item;
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => { const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
const { circleElement, checkmarkElement, textElement } = item; calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = circleElement.setAttrs({
calculateMultiItemPosition({ x: itemInputX,
fieldWidth: rectWidth, y: itemInputY,
fieldHeight: rectHeight, scaleX: 1,
itemCount: radioValues.length, scaleY: 1,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
}); });
circleElement.setAttrs({ checkmarkElement.setAttrs({
x: itemInputX, x: itemInputX,
y: itemInputY, y: itemInputY,
scaleX: 1, scaleX: 1,
scaleY: 1, scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
}); });
checkmarkElement.setAttrs({ fieldRect.width(rectWidth);
x: itemInputX, fieldRect.height(rectHeight);
y: itemInputY,
scaleX: 1, fieldGroup.scale({
scaleY: 1, x: 1,
y: 1,
}); });
textElement.setAttrs({ pageLayer.batchDraw();
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
}); });
}
fieldRect.width(rectWidth); const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
fieldRect.height(rectHeight); const radioValues = radioMeta?.values || [];
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => { radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({ calculateMultiItemPosition({
fieldWidth, fieldWidth,
@ -130,7 +120,6 @@ export const renderRadioFieldElement = (
spacingBetweenItemAndText: spacingBetweenRadioAndText, spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding, fieldPadding: radioFieldPadding,
type: 'radio', type: 'radio',
direction: radioMeta?.direction || 'vertical',
}); });
// Circle which represents the radio button. // Circle which represents the radio button.
@ -155,7 +144,9 @@ export const renderRadioFieldElement = (
y: itemInputY, y: itemInputY,
radius: radioSize / 4, radius: radioSize / 4,
fill: '#111827', fill: '#111827',
visible: isRadioValueChecked, // Todo: Envelopes
visible: value === field.customText,
// visible: checked,
}); });
const text = new Konva.Text({ const text = new Konva.Text({

View File

@ -96,80 +96,77 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // ABOVE IS GENERIC, EXTRACT IT.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// 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. // Render the field background and text.
const fieldRect = upsertFieldRect(field, options); const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options); const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect); // Assign elements to group and any listeners that should only be run on initialization.
fieldGroup.add(fieldText); if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size // 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. // when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX(); const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY(); const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized. // Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX); fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY); fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX; const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY; const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping // // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) { // fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight); // ctx.rect(0, 0, rectWidth, rectHeight);
// }); // });
// Update text dimensions // Update text dimensions
fieldText.width(rectWidth); // Account for padding fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight); fieldText.height(rectHeight);
console.log({ console.log({
rectWidth, rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
}); });
// Force Konva to recalculate text layout // Reset the text after transform has ended.
// textInsideField.getTextHeight(); // This forces recalculation fieldGroup.on('transformend', () => {
fieldText.height(); // This forces recalculation fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw(); const rectWidth = fieldRect.width();
fieldGroup.getLayer()?.batchDraw(); const rectHeight = fieldRect.height();
});
// Reset the text after transform has ended. // // Update text group position and clipping
fieldGroup.on('transformend', () => { // fieldGroup.clipFunc(function (ctx) {
fieldText.scaleX(1); // ctx.rect(0, 0, rectWidth, rectHeight);
fieldText.scaleY(1); // });
const rectWidth = fieldRect.width(); // Update text dimensions
const rectHeight = fieldRect.height(); fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// // Update text group position and clipping // Force Konva to recalculate text layout
// fieldGroup.clipFunc(function (ctx) { // textInsideField.getTextHeight(); // This forces recalculation
// ctx.rect(0, 0, rectWidth, rectHeight); fieldText.height(); // This forces recalculation
// });
// Update text dimensions // fieldGroup.draw();
fieldText.width(rectWidth); // Account for padding fieldGroup.getLayer()?.batchDraw();
fieldText.height(rectHeight); });
}
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode. // Handle export mode.
if (mode === 'export') { if (mode === 'export') {

View File

@ -121,80 +121,77 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // ABOVE IS GENERIC, EXTRACT IT.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// 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. // Render the field background and text.
const fieldRect = upsertFieldRect(field, options); const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options); const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect); // Assign elements to group and any listeners that should only be run on initialization.
fieldGroup.add(fieldText); if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size // 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. // when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX(); const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY(); const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized. // Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX); fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY); fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX; const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY; const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping // // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) { // fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight); // ctx.rect(0, 0, rectWidth, rectHeight);
// }); // });
// Update text dimensions // Update text dimensions
fieldText.width(rectWidth); // Account for padding fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight); fieldText.height(rectHeight);
console.log({ console.log({
rectWidth, rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
}); });
// Force Konva to recalculate text layout // Reset the text after transform has ended.
// textInsideField.getTextHeight(); // This forces recalculation fieldGroup.on('transformend', () => {
fieldText.height(); // This forces recalculation fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw(); const rectWidth = fieldRect.width();
fieldGroup.getLayer()?.batchDraw(); const rectHeight = fieldRect.height();
});
// Reset the text after transform has ended. // // Update text group position and clipping
fieldGroup.on('transformend', () => { // fieldGroup.clipFunc(function (ctx) {
fieldText.scaleX(1); // ctx.rect(0, 0, rectWidth, rectHeight);
fieldText.scaleY(1); // });
const rectWidth = fieldRect.width(); // Update text dimensions
const rectHeight = fieldRect.height(); fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// // Update text group position and clipping // Force Konva to recalculate text layout
// fieldGroup.clipFunc(function (ctx) { // textInsideField.getTextHeight(); // This forces recalculation
// ctx.rect(0, 0, rectWidth, rectHeight); fieldText.height(); // This forces recalculation
// });
// Update text dimensions // fieldGroup.draw();
fieldText.width(rectWidth); // Account for padding fieldGroup.getLayer()?.batchDraw();
fieldText.height(rectHeight); });
}
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode. // Handle export mode.
if (mode === 'export') { if (mode === 'export') {

View File

@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
}, []); }, []);
return ( return (
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}> <div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
{envelopeItemFile && Konva ? ( {envelopeItemFile && Konva ? (
<PDFDocument <PDFDocument
file={envelopeItemFile} file={envelopeItemFile}
className={cn('w-full rounded', { className={cn('w-full overflow-hidden rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0, 'h-[80vh] max-h-[60rem]': numPages === 0,
})} })}
onLoadSuccess={(d) => onDocumentLoaded(d)} onLoadSuccess={(d) => onDocumentLoaded(d)}
@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
.fill(null) .fill(null)
.map((_, i) => ( .map((_, i) => (
<div key={i} className="last:-mb-2"> <div key={i} className="last:-mb-2">
<div className="border-border rounded border will-change-transform"> <div className="border-border overflow-hidden rounded border will-change-transform">
<PDFPage <PDFPage
pageNumber={i + 1} pageNumber={i + 1}
width={width} width={width}

View File

@ -9,7 +9,6 @@ export type RecipientColorStyles = {
base: string; base: string;
baseRing: string; baseRing: string;
baseRingHover: string; baseRingHover: string;
fieldButton: string;
fieldItem: string; fieldItem: string;
fieldItemInitials: string; fieldItemInitials: string;
comboxBoxTrigger: string; comboxBoxTrigger: string;
@ -24,7 +23,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-neutral-400', base: 'ring-neutral-400',
baseRing: 'rgba(176, 176, 176, 1)', baseRing: 'rgba(176, 176, 176, 1)',
baseRingHover: 'rgba(176, 176, 176, 1)', baseRingHover: 'rgba(176, 176, 176, 1)',
fieldButton: 'border-neutral-400 hover:border-neutral-400',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '', fieldItemInitials: '',
comboxBoxTrigger: comboxBoxTrigger:
@ -36,7 +34,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-green hover:bg-recipient-green/30', base: 'ring-recipient-green hover:bg-recipient-green/30',
baseRing: 'rgba(122, 195, 85, 1)', baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)', baseRingHover: 'rgba(122, 195, 85, 0.3)',
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green', fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger: comboxBoxTrigger:
@ -48,7 +45,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-blue hover:bg-recipient-blue/30', base: 'ring-recipient-blue hover:bg-recipient-blue/30',
baseRing: 'rgba(56, 123, 199, 1)', baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)', baseRingHover: 'rgba(56, 123, 199, 0.3)',
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue', fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger: comboxBoxTrigger:
@ -60,7 +56,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-purple hover:bg-recipient-purple/30', base: 'ring-recipient-purple hover:bg-recipient-purple/30',
baseRing: 'rgba(151, 71, 255, 1)', baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)', baseRingHover: 'rgba(151, 71, 255, 0.3)',
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple', fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger: comboxBoxTrigger:
@ -72,7 +67,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-orange hover:bg-recipient-orange/30', base: 'ring-recipient-orange hover:bg-recipient-orange/30',
baseRing: 'rgba(246, 159, 30, 1)', baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)', baseRingHover: 'rgba(246, 159, 30, 0.3)',
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange', fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger: comboxBoxTrigger:
@ -84,7 +78,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30', base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
baseRing: 'rgba(219, 186, 0, 1)', baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)', baseRingHover: 'rgba(219, 186, 0, 0.3)',
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow', fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger: comboxBoxTrigger:
@ -96,7 +89,6 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-pink hover:bg-recipient-pink/30', base: 'ring-recipient-pink hover:bg-recipient-pink/30',
baseRing: 'rgba(217, 74, 186, 1)', baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)', baseRingHover: 'rgba(217, 74, 186, 0.3)',
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]', fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink', fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger: comboxBoxTrigger: