mirror of
https://github.com/documenso/documenso.git
synced 2025-11-27 06:54:01 +10:00
Compare commits
12 Commits
cb9bf407f7
...
feat/envel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0d2d1a82 | ||
|
|
a26a740fe5 | ||
|
|
f48813bb3c | ||
|
|
304c519c30 | ||
|
|
0eef4cd7e6 | ||
|
|
bddaa5ec66 | ||
|
|
3be0d84786 | ||
|
|
50572435ad | ||
|
|
6f70548bb5 | ||
|
|
0da8e7dbc6 | ||
|
|
bf89bc781b | ||
|
|
eec2307634 |
@@ -13,10 +13,6 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
# Find documentation on setting up Microsoft OAuth here:
|
||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||
|
||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
|
||||
@@ -27,33 +27,3 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||
|
||||
## Microsoft OAuth (Azure AD)
|
||||
|
||||
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||
|
||||
### Create and configure a new Azure AD application
|
||||
|
||||
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||
3. In the left sidebar, click **App registrations**
|
||||
4. Click **New registration**
|
||||
5. Enter a name for your application (e.g., "Documenso")
|
||||
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||
8. Click **Register**
|
||||
|
||||
### Configure the application
|
||||
|
||||
1. After registration, you'll be taken to the app's overview page
|
||||
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||
3. In the left sidebar, click **Certificates & secrets**
|
||||
4. Under **Client secrets**, click **New client secret**
|
||||
5. Add a description and select an expiration period
|
||||
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||
7. In the Documenso environment variables, set the following:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
|
||||
@@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const everySignerHasSignature = useMemo(
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
envelope.recipients
|
||||
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||
.every((recipient) =>
|
||||
envelope.fields.some(
|
||||
envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
[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>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{everySignerHasSignature ? (
|
||||
{recipientsMissingSignatureFields.length === 0 ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -350,6 +350,8 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
</div>
|
||||
) : (
|
||||
<ul className="text-muted-foreground divide-y">
|
||||
{/* Todo: Envelopes - I don't think this section shows up */}
|
||||
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
@@ -427,10 +429,13 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some signers have not been assigned a signature field. Please assign at least 1
|
||||
signature field to each signer before proceeding.
|
||||
</Trans>
|
||||
<Trans>The following signers are missing signature fields:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>{recipient.email}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
folderId: folder.id,
|
||||
id: folder.id,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||
|
||||
const form = useForm<TMoveFolderFormSchema>({
|
||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||
@@ -63,16 +63,12 @@ export const FolderMoveDialog = ({
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
if (!folder) return;
|
||||
|
||||
try {
|
||||
await moveFolder({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
parentId: targetFolderId || null,
|
||||
},
|
||||
id: folder.id,
|
||||
parentId: targetFolderId || null,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -61,6 +61,8 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const isTeamContext = !!team;
|
||||
|
||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||
defaultValues: {
|
||||
@@ -85,11 +87,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
|
||||
try {
|
||||
await updateFolder({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
visibility: data.visibility,
|
||||
},
|
||||
id: folder.id,
|
||||
name: data.name,
|
||||
visibility: isTeamContext
|
||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
||||
: DocumentVisibility.EVERYONE,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -138,36 +140,38 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isTeamContext && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Visibility</Trans>
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select visibility`} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||
<Trans>Everyone</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||
<Trans>Managers and above</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||
<Trans>Admins only</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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>;
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
|
||||
export type SignFieldDropdownDialogProps = {
|
||||
fieldMeta: TDropdownFieldMeta;
|
||||
@@ -46,72 +21,20 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
|
||||
|
||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||
|
||||
const form = useForm<TSignFieldDropdownFormSchema>({
|
||||
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
||||
defaultValues: {
|
||||
dropdown: fieldMeta.defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Dropdown Field</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Select a value to sign into the field</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<CommandInput placeholder={t`Select an option`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading={t`Options`}>
|
||||
{values.map((value, i) => (
|
||||
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
|
||||
@@ -45,26 +45,32 @@ const ZDropdownFieldFormSchema = z
|
||||
.min(1, {
|
||||
message: msg`Dropdown must have at least one option`.id,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Todo: Envelopes - This doesn't work.
|
||||
console.log({
|
||||
data,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const values = data.map((item) => item.value);
|
||||
return new Set(values).size === values.length;
|
||||
.superRefine((values, ctx) => {
|
||||
const seen = new Map<string, number[]>(); // value → indices
|
||||
values.forEach((item, index) => {
|
||||
const key = item.value;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, []);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Duplicate values are not allowed',
|
||||
},
|
||||
),
|
||||
seen.get(key)!.push(index);
|
||||
});
|
||||
|
||||
for (const [key, indices] of seen) {
|
||||
if (indices.length > 1 && key.trim() !== '') {
|
||||
for (const i of indices) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Duplicate values are not allowed`.id,
|
||||
path: [i, 'value'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
// Todo: Envelopes - This doesn't work
|
||||
.refine(
|
||||
(data) => {
|
||||
// Default value must be one of the available options
|
||||
@@ -111,7 +117,20 @@ export const EditorFieldDropdownForm = ({
|
||||
|
||||
const addValue = () => {
|
||||
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);
|
||||
};
|
||||
@@ -127,6 +146,10 @@ export const EditorFieldDropdownForm = ({
|
||||
newValues.splice(index, 1);
|
||||
|
||||
form.setValue('values', newValues);
|
||||
|
||||
if (form.getValues('defaultValue') === newValues[index].value) {
|
||||
form.setValue('defaultValue', undefined);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,20 +186,26 @@ export const EditorFieldDropdownForm = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === undefined ? null : value)}
|
||||
>
|
||||
{/* Todo: Envelopes - THis is cooked */}
|
||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||
<SelectValue placeholder={t`Default Value`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{(formValues.values || []).map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(formValues.values || [])
|
||||
.filter((item) => item.value)
|
||||
.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value || ''}>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>None</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
type TRadioFieldMeta as RadioFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
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 {
|
||||
@@ -17,31 +34,26 @@ import {
|
||||
EditorGenericRequiredField,
|
||||
} from './editor-field-generic-field-forms';
|
||||
|
||||
const ZRadioFieldFormSchema = z
|
||||
.object({
|
||||
label: z.string().optional(),
|
||||
values: z
|
||||
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
||||
.array()
|
||||
.min(1)
|
||||
.optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
||||
label: true,
|
||||
direction: true,
|
||||
values: true,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
}).refine(
|
||||
(data) => {
|
||||
// There cannot be more than one checked option
|
||||
if (data.values) {
|
||||
const checkedValues = data.values.filter((option) => option.checked);
|
||||
return checkedValues.length <= 1;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'There cannot be more than one checked option',
|
||||
path: ['values'],
|
||||
},
|
||||
);
|
||||
|
||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||
|
||||
@@ -53,9 +65,12 @@ export type EditorFieldRadioFormProps = {
|
||||
export const EditorFieldRadioForm = ({
|
||||
value = {
|
||||
type: 'radio',
|
||||
direction: 'vertical',
|
||||
},
|
||||
onValueChange,
|
||||
}: EditorFieldRadioFormProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const form = useForm<TRadioFieldFormSchema>({
|
||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||
mode: 'onChange',
|
||||
@@ -64,6 +79,7 @@ export const EditorFieldRadioForm = ({
|
||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||
required: value.required || false,
|
||||
readOnly: value.readOnly || false,
|
||||
direction: value.direction || 'vertical',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +123,35 @@ export const EditorFieldRadioForm = ({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<fieldset className="flex flex-col gap-2 pb-2">
|
||||
<fieldset className="flex flex-col gap-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} />
|
||||
|
||||
<EditorGenericReadOnlyField formControl={form.control} />
|
||||
|
||||
@@ -70,7 +70,6 @@ export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
returnTo?: string;
|
||||
@@ -80,7 +79,6 @@ export const SignInForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
@@ -97,8 +95,6 @@ export const SignInForm = ({
|
||||
'totp' | 'backup'
|
||||
>('totp');
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
@@ -275,22 +271,6 @@ export const SignInForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn({
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn({
|
||||
@@ -383,7 +363,7 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
@@ -407,20 +387,6 @@ export const SignInForm = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -66,7 +66,6 @@ export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -74,7 +73,6 @@ export const SignUpForm = ({
|
||||
className,
|
||||
initialEmail,
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
@@ -86,8 +84,6 @@ export const SignUpForm = ({
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
@@ -152,20 +148,6 @@ export const SignUpForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithMicrosoftClick = async () => {
|
||||
try {
|
||||
await authClient.microsoft.signIn();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSignUpWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn();
|
||||
@@ -245,7 +227,7 @@ export const SignUpForm = ({
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
@@ -320,7 +302,7 @@ export const SignUpForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
@@ -348,26 +330,6 @@ export const SignUpForm = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant={'outline'}
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignUpWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
<Trans>Sign Up with Microsoft</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -262,6 +262,10 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
const selectedRecipientColor = useMemo(() => {
|
||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
||||
}, [selectedRecipientId, getRecipientColorKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||
@@ -273,12 +277,23 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
onClick={() => setSelectedField(field.type)}
|
||||
onMouseDown={() => setSelectedField(field.type)}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
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"
|
||||
className={cn(
|
||||
'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
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
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" />}
|
||||
@@ -292,8 +307,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
<div
|
||||
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]',
|
||||
// selectedSignerStyles?.base,
|
||||
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
'dark:text-black/60': isFieldWithinBounds,
|
||||
|
||||
@@ -3,15 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||
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 { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
@@ -26,27 +23,10 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
|
||||
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 { envelope, 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 interactiveTransformer = useRef<Transformer | null>(null);
|
||||
|
||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||
@@ -54,10 +34,17 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||
|
||||
const viewport = useMemo(
|
||||
() => page.getViewport({ scale, rotation: rotate }),
|
||||
[page, rotate, scale],
|
||||
);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
@@ -68,44 +55,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
[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>) => {
|
||||
console.log('Field resized or moved');
|
||||
|
||||
@@ -120,6 +69,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const fieldGroup = event.target as Konva.Group;
|
||||
const fieldFormId = fieldGroup.id();
|
||||
|
||||
// Note: This values are scaled.
|
||||
const {
|
||||
width: fieldPixelWidth,
|
||||
height: fieldPixelHeight,
|
||||
@@ -130,7 +80,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
skipShadow: true,
|
||||
});
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
||||
const pageHeight = scaledViewport.height;
|
||||
const pageWidth = scaledViewport.width;
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||
@@ -165,7 +116,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current || !interactiveTransformer.current) {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +125,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const isFieldEditable =
|
||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||
|
||||
const { fieldGroup, isFirstRender } = renderField({
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.formId,
|
||||
@@ -183,8 +135,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: isFieldEditable,
|
||||
mode: 'edit',
|
||||
@@ -210,24 +162,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Initialize snap guides layer
|
||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||
|
||||
// Add transformer for resizing and rotating.
|
||||
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
||||
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
@@ -235,12 +177,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
// Handle stage click to deselect.
|
||||
stage.current?.on('click', (e) => {
|
||||
currentStage.on('click', (e) => {
|
||||
removePendingField();
|
||||
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
pageLayer.current?.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -267,12 +209,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
setSelectedFields([e.target]);
|
||||
};
|
||||
|
||||
stage.current?.on('dragstart', onDragStartOrEnd);
|
||||
stage.current?.on('dragend', onDragStartOrEnd);
|
||||
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
||||
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
||||
currentStage.on('dragstart', onDragStartOrEnd);
|
||||
currentStage.on('dragend', onDragStartOrEnd);
|
||||
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -284,7 +226,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
* - Selecting multiple fields
|
||||
* - Selecting empty area to create fields
|
||||
*/
|
||||
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
||||
const createInteractiveTransformer = (
|
||||
currentStage: Konva.Stage,
|
||||
currentPageLayer: Konva.Layer,
|
||||
) => {
|
||||
const transformer = new Konva.Transformer({
|
||||
rotateEnabled: false,
|
||||
keepRatio: false,
|
||||
@@ -301,36 +246,39 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
},
|
||||
});
|
||||
|
||||
layer.add(transformer);
|
||||
currentPageLayer.add(transformer);
|
||||
|
||||
// Add selection rectangle.
|
||||
const selectionRectangle = new Konva.Rect({
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
visible: false,
|
||||
});
|
||||
layer.add(selectionRectangle);
|
||||
currentPageLayer.add(selectionRectangle);
|
||||
|
||||
let x1: number;
|
||||
let y1: number;
|
||||
let x2: number;
|
||||
let y2: number;
|
||||
|
||||
stage.on('mousedown touchstart', (e) => {
|
||||
currentStage.on('mousedown touchstart', (e) => {
|
||||
// do nothing if we mousedown on any shape
|
||||
if (e.target !== stage) {
|
||||
if (e.target !== currentStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
const pointerPosition = currentStage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x1 = pointerPosition.x;
|
||||
y1 = pointerPosition.y;
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
console.log(`pointerPosition.x: ${pointerPosition.x}`);
|
||||
console.log(`pointerPosition.y: ${pointerPosition.y}`);
|
||||
|
||||
x1 = pointerPosition.x / scale;
|
||||
y1 = pointerPosition.y / scale;
|
||||
x2 = pointerPosition.x / scale;
|
||||
y2 = pointerPosition.y / scale;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: x1,
|
||||
@@ -341,7 +289,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mousemove touchmove', () => {
|
||||
currentStage.on('mousemove touchmove', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
@@ -349,14 +297,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
|
||||
selectionRectangle.moveToTop();
|
||||
|
||||
const pointerPosition = stage.getPointerPosition();
|
||||
const pointerPosition = currentStage.getPointerPosition();
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
x2 = pointerPosition.x;
|
||||
y2 = pointerPosition.y;
|
||||
x2 = pointerPosition.x / scale;
|
||||
y2 = pointerPosition.y / scale;
|
||||
|
||||
selectionRectangle.setAttrs({
|
||||
x: Math.min(x1, x2),
|
||||
@@ -366,7 +314,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
});
|
||||
|
||||
stage.on('mouseup touchend', () => {
|
||||
currentStage.on('mouseup touchend', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
@@ -377,38 +325,41 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
selectionRectangle.visible(false);
|
||||
});
|
||||
|
||||
const stageFieldGroups = stage.find('.field-group') || [];
|
||||
const stageFieldGroups = currentStage.find('.field-group') || [];
|
||||
const box = selectionRectangle.getClientRect();
|
||||
const selectedFieldGroups = stageFieldGroups.filter(
|
||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||
);
|
||||
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.
|
||||
if (
|
||||
selectedFieldGroups.length === 0 &&
|
||||
canvasElement.current &&
|
||||
box.width > MIN_FIELD_WIDTH_PX &&
|
||||
box.height > MIN_FIELD_HEIGHT_PX &&
|
||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
||||
editorFields.selectedRecipient &&
|
||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||
) {
|
||||
const pendingFieldCreation = new Konva.Rect({
|
||||
name: 'pending-field-creation',
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
x: box.x / scale,
|
||||
y: box.y / scale,
|
||||
width: unscaledBoxWidth,
|
||||
height: unscaledBoxHeight,
|
||||
fill: 'rgba(24, 160, 251, 0.3)',
|
||||
});
|
||||
|
||||
layer.add(pendingFieldCreation);
|
||||
currentPageLayer.add(pendingFieldCreation);
|
||||
setPendingFieldCreation(pendingFieldCreation);
|
||||
}
|
||||
});
|
||||
|
||||
// Clicks should select/deselect shapes
|
||||
stage.on('click tap', function (e) {
|
||||
currentStage.on('click tap', function (e) {
|
||||
// if we are selecting with rect, do nothing
|
||||
if (
|
||||
selectionRectangle.visible() &&
|
||||
@@ -419,7 +370,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
if (e.target === stage) {
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
@@ -555,15 +506,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||
width: pixelWidth,
|
||||
height: pixelHeight,
|
||||
positionX: pixelX,
|
||||
positionY: pixelY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
});
|
||||
|
||||
editorFields.addField({
|
||||
@@ -597,7 +546,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{selectedKonvaFieldGroups.length > 0 &&
|
||||
interactiveTransformer.current &&
|
||||
!isFieldChanging && (
|
||||
@@ -654,8 +606,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
||||
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
||||
top:
|
||||
pendingFieldCreation.y() * scale +
|
||||
pendingFieldCreation.getClientRect().height +
|
||||
5 +
|
||||
'px',
|
||||
left:
|
||||
pendingFieldCreation.x() * scale +
|
||||
pendingFieldCreation.getClientRect().width / 2 +
|
||||
'px',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 50,
|
||||
}}
|
||||
@@ -673,13 +632,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageFields = () => {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -109,7 +109,7 @@ export const EnvelopeEditorPageFields = () => {
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex justify-center">
|
||||
<div className="mt-4 flex justify-center p-4">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||
) : (
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeEditorPagePreviewRenderer = lazy(
|
||||
async () => import('./envelope-editor-page-preview-renderer'),
|
||||
);
|
||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||
|
||||
export const EnvelopeEditorPagePreview = () => {
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -51,7 +49,7 @@ export const EnvelopeEditorPagePreview = () => {
|
||||
</Alert>
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
@@ -41,7 +41,7 @@ type LocalFile = {
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorPageUpload = () => {
|
||||
export const EnvelopeEditorUploadPage = () => {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -224,8 +224,12 @@ export const EnvelopeEditorPageUpload = () => {
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||
<Card backdropBlur={false} className="border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>Add and configure multiple documents</CardDescription>
|
||||
<CardTitle>
|
||||
<Trans>Documents</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<Trans>Add and configure multiple documents</Trans>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
@@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
|
||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
|
||||
import EnvelopeEditorHeader from './envelope-editor-header';
|
||||
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
||||
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
||||
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
||||
|
||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||
|
||||
@@ -128,6 +128,18 @@ 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(() => {
|
||||
if (!isAutosaving) {
|
||||
setIsStepLoading(false);
|
||||
@@ -151,7 +163,9 @@ export default function EnvelopeEditor() {
|
||||
{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">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
@@ -340,13 +354,12 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Main Content - Changes based on current step */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
||||
<AnimateGenericFadeInOut key={currentStep}>
|
||||
{match({ currentStep, isStepLoading })
|
||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
||||
.exhaustive()}
|
||||
</AnimateGenericFadeInOut>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,31 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
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 canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
});
|
||||
|
||||
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 { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
@@ -46,44 +36,6 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
[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]) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
@@ -91,6 +43,7 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
@@ -103,8 +56,8 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
// color: getRecipientColorKey(field.recipientId),
|
||||
color: 'purple', // Todo
|
||||
editable: false,
|
||||
@@ -113,25 +66,15 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,14 +110,19 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||
<div
|
||||
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>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import Konva from 'konva';
|
||||
import type { Layer } from 'konva/lib/Layer';
|
||||
import { type Field, FieldType, type Signature } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||
import { usePageContext } from 'react-pdf';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||
@@ -28,18 +26,6 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
const pageContext = usePageContext();
|
||||
|
||||
if (!pageContext) {
|
||||
throw new Error('Unable to find Page context.');
|
||||
}
|
||||
|
||||
const { _className, page, rotate, scale } = pageContext;
|
||||
|
||||
if (!page) {
|
||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||
}
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
@@ -58,21 +44,20 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
setSignature,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
console.log({ fullName });
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
canvasElement,
|
||||
konvaContainer,
|
||||
pageContext,
|
||||
scaledViewport,
|
||||
unscaledViewport,
|
||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const { envelope } = envelopeData;
|
||||
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const 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(
|
||||
() =>
|
||||
recipientFields.filter(
|
||||
@@ -82,45 +67,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
[recipientFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
// Custom renderer from Konva examples.
|
||||
useEffect(
|
||||
function drawPageOnCanvas() {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current: canvas } = canvasElement;
|
||||
const { current: container } = konvaContainer;
|
||||
|
||||
if (!canvas || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderContext: RenderParameters = {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||
viewport,
|
||||
};
|
||||
|
||||
const cancellable = page.render(renderContext);
|
||||
const runningTask = cancellable;
|
||||
|
||||
cancellable.promise.catch(() => {
|
||||
// Intentionally empty
|
||||
});
|
||||
|
||||
void cancellable.promise.then(() => {
|
||||
createPageCanvas(container);
|
||||
});
|
||||
|
||||
return () => {
|
||||
runningTask.cancel();
|
||||
};
|
||||
},
|
||||
[page, viewport],
|
||||
);
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field) => {
|
||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
@@ -137,6 +84,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
const { fieldGroup } = renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: fieldToRender.id.toString(),
|
||||
@@ -145,9 +93,10 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
height: Number(fieldToRender.height),
|
||||
positionX: Number(fieldToRender.positionX),
|
||||
positionY: Number(fieldToRender.positionY),
|
||||
signature: unparsedField.signature,
|
||||
},
|
||||
pageWidth: viewport.width,
|
||||
pageHeight: viewport.height,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
color,
|
||||
mode: 'sign',
|
||||
});
|
||||
@@ -357,29 +306,19 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (container: HTMLDivElement) => {
|
||||
stage.current = new Konva.Stage({
|
||||
container,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
|
||||
// Create the main layer for interactive elements.
|
||||
pageLayer.current = new Konva.Layer();
|
||||
stage.current?.add(pageLayer.current);
|
||||
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
console.log({
|
||||
localPageFields,
|
||||
});
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
}
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
currentPageLayer.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -392,7 +331,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field changed/inserted, rendering on canvas');
|
||||
renderFieldOnLayer(field);
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
@@ -403,14 +342,19 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||
<div
|
||||
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>
|
||||
|
||||
{/* Canvas the PDF will be rendered on. */}
|
||||
<canvas
|
||||
className={`${_className}__canvas z-0`}
|
||||
height={viewport.height}
|
||||
ref={canvasElement}
|
||||
width={viewport.width}
|
||||
height={scaledViewport.height}
|
||||
width={scaledViewport.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -29,15 +28,22 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
export type FolderCardProps = {
|
||||
folder: TFolderWithSubfolders;
|
||||
onMove: (folder: TFolderWithSubfolders) => void;
|
||||
onPin: (folderId: string) => void;
|
||||
onUnpin: (folderId: string) => void;
|
||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||
};
|
||||
|
||||
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
|
||||
export const FolderCard = ({
|
||||
folder,
|
||||
onMove,
|
||||
onPin,
|
||||
onUnpin,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: FolderCardProps) => {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
|
||||
|
||||
const formatPath = () => {
|
||||
const rootPath =
|
||||
folder.type === FolderType.DOCUMENT
|
||||
@@ -47,15 +53,6 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
|
||||
return `${rootPath}/f/${folder.id}`;
|
||||
};
|
||||
|
||||
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
|
||||
await updateFolderMutation({
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
pinned,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
@@ -115,7 +112,9 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
|
||||
<Trans>Move</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
||||
>
|
||||
<PinIcon className="mr-2 h-4 w-4" />
|
||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
|
||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
||||
|
||||
export type FolderGridProps = {
|
||||
type: FolderType;
|
||||
parentId: string | null;
|
||||
@@ -34,6 +36,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||
type,
|
||||
parentId,
|
||||
@@ -95,7 +100,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
{/* Todo: Envelopes - Feature flag */}
|
||||
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadButton />
|
||||
@@ -152,6 +157,8 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -175,6 +182,8 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -42,6 +42,9 @@ export default function DocumentsFoldersPage() {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
@@ -110,6 +113,8 @@ export default function DocumentsFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -142,6 +147,8 @@ export default function DocumentsFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -42,6 +42,9 @@ export default function TemplatesFoldersPage() {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
@@ -110,6 +113,8 @@ export default function TemplatesFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
@@ -142,6 +147,8 @@ export default function TemplatesFoldersPage() {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
}}
|
||||
onPin={(folderId) => void pinFolder({ folderId })}
|
||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
||||
onSettings={(folder) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Link, redirect } from 'react-router';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
@@ -24,7 +23,6 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
// SSR env variables.
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||
|
||||
@@ -34,15 +32,13 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||
loaderData;
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
@@ -58,7 +54,6 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<SignInForm
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
@@ -21,7 +17,6 @@ export function loader() {
|
||||
|
||||
// SSR env variables.
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
|
||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
@@ -30,19 +25,17 @@ export function loader() {
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
||||
|
||||
return (
|
||||
<SignUpForm
|
||||
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
||||
|
||||
export const ZBaseEmbedAuthoringSchema = z
|
||||
.object({
|
||||
token: z.string(),
|
||||
externalId: z.string().optional(),
|
||||
features: z
|
||||
.object({
|
||||
|
||||
@@ -103,5 +103,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.13.0"
|
||||
"version": "1.12.10"
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1,7 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from 'hono/cors';
|
||||
import { requestId } from 'hono/request-id';
|
||||
import type { RequestIdVariables } from 'hono/request-id';
|
||||
import type { Logger } from 'pino';
|
||||
@@ -84,14 +83,12 @@ app.route('/api/auth', auth);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||
|
||||
export default app;
|
||||
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.13.0",
|
||||
"version": "1.12.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.13.0",
|
||||
"version": "1.12.10",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -19,7 +19,6 @@
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
"pdf2json": "^4.0.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "3.24.1"
|
||||
@@ -90,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.13.0",
|
||||
"version": "1.12.10",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.3.2",
|
||||
"@documenso/api": "*",
|
||||
@@ -27199,18 +27198,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf2json": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
|
||||
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"pdf2json": "bin/pdf2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "3.11.174",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.13.0",
|
||||
"version": "1.12.10",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
@@ -74,7 +74,6 @@
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
"pdf2json": "^4.0.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "3.24.1"
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
seedDraftDocument,
|
||||
seedPendingDocument,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -327,6 +326,11 @@ test.describe('Document API V2', () => {
|
||||
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
||||
});
|
||||
|
||||
const asdf = await res.json();
|
||||
console.log({
|
||||
asdf,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
@@ -403,6 +407,11 @@ test.describe('Document API V2', () => {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const asdf = await res.json();
|
||||
console.log({
|
||||
asdf,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
@@ -2706,154 +2715,4 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder list endpoint', () => {
|
||||
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const { data } = await res.json();
|
||||
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
|
||||
expect(data.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder list endpoint', async ({ request }) => {
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
// Other team folders should not be visible.
|
||||
await seedBlankFolder(userA, teamB.id);
|
||||
await seedBlankFolder(userA, teamB.id);
|
||||
|
||||
// Other team and user folders should not be visible.
|
||||
await seedBlankFolder(userB, teamB.id);
|
||||
await seedBlankFolder(userB, teamB.id);
|
||||
|
||||
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const { data } = await res.json();
|
||||
|
||||
expect(data.length).toBe(2);
|
||||
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder create endpoint', () => {
|
||||
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
|
||||
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
parentId: unauthorizedFolder.id,
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder create endpoint', async ({ request }) => {
|
||||
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
parentId: authorizedFolder.id,
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
name: 'Test Folder',
|
||||
type: 'DOCUMENT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(noParentRes.ok()).toBeTruthy();
|
||||
expect(noParentRes.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder update endpoint', () => {
|
||||
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: {
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: 'Updated Folder Name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder update endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
folderId: folder.id,
|
||||
data: {
|
||||
name: 'Updated Folder Name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Folder delete endpoint', () => {
|
||||
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
|
||||
const folder = await seedBlankFolder(userA, teamA.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: { folderId: folder.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,22 +222,6 @@ export class AuthClient {
|
||||
},
|
||||
};
|
||||
|
||||
public microsoft = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.microsoft.$post({
|
||||
json: { redirectPath },
|
||||
});
|
||||
|
||||
await this.handleError(response);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.redirectUrl) {
|
||||
window.location.href = data.redirectUrl;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public oidc = {
|
||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
||||
|
||||
@@ -26,16 +26,6 @@ export const GoogleAuthOptions: OAuthClientOptions = {
|
||||
bypassEmailVerification: false,
|
||||
};
|
||||
|
||||
export const MicrosoftAuthOptions: OAuthClientOptions = {
|
||||
id: 'microsoft',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
|
||||
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
|
||||
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
|
||||
bypassEmailVerification: false,
|
||||
};
|
||||
|
||||
export const OidcAuthOptions: OAuthClientOptions = {
|
||||
id: 'oidc',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
||||
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
@@ -45,11 +45,4 @@ export const callbackRoute = new Hono<HonoAuthContext>()
|
||||
/**
|
||||
* Google callback verification.
|
||||
*/
|
||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
|
||||
|
||||
/**
|
||||
* Microsoft callback verification.
|
||||
*/
|
||||
.get('/microsoft', async (c) =>
|
||||
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
|
||||
);
|
||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||
import type { HonoAuthContext } from '../types/context';
|
||||
@@ -24,20 +24,6 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* Microsoft authorize endpoint.
|
||||
*/
|
||||
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||
const { redirectPath } = c.req.valid('json');
|
||||
|
||||
return handleOAuthAuthorizeUrl({
|
||||
c,
|
||||
clientOptions: MicrosoftAuthOptions,
|
||||
redirectPath,
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* OIDC authorize endpoint.
|
||||
*/
|
||||
|
||||
@@ -136,6 +136,7 @@ export const useEditorFields = ({
|
||||
const field: TLocalField = {
|
||||
...fieldData,
|
||||
formId: nanoid(12),
|
||||
...restrictFieldPosValues(fieldData),
|
||||
};
|
||||
|
||||
append(field);
|
||||
@@ -165,7 +166,15 @@ export const useEditorFields = ({
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, { ...localFields[index], ...updates });
|
||||
const updatedField = {
|
||||
...localFields[index],
|
||||
...updates,
|
||||
};
|
||||
|
||||
update(index, {
|
||||
...updatedField,
|
||||
...restrictFieldPosValues(updatedField),
|
||||
});
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
@@ -279,3 +288,14 @@ export const useEditorFields = ({
|
||||
setSelectedRecipient,
|
||||
};
|
||||
};
|
||||
|
||||
const restrictFieldPosValues = (
|
||||
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
|
||||
) => {
|
||||
return {
|
||||
positionX: Math.max(0, Math.min(100, field.positionX)),
|
||||
positionY: Math.max(0, Math.min(100, field.positionY)),
|
||||
width: Math.max(0, Math.min(100, field.width)),
|
||||
height: Math.max(0, Math.min(100, field.height)),
|
||||
};
|
||||
};
|
||||
|
||||
105
packages/lib/client-only/hooks/use-page-renderer.ts
Normal file
105
packages/lib/client-only/hooks/use-page-renderer.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -135,7 +135,12 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ recipients }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -215,14 +220,15 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === recipientId,
|
||||
);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
|
||||
return AVAILABLE_RECIPIENT_COLORS[
|
||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
||||
];
|
||||
},
|
||||
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||
|
||||
@@ -6,7 +6,6 @@ export const SALT_ROUNDS = 12;
|
||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||
DOCUMENSO: 'Documenso',
|
||||
GOOGLE: 'Google',
|
||||
MICROSOFT: 'Microsoft',
|
||||
OIDC: 'OIDC',
|
||||
};
|
||||
|
||||
@@ -14,10 +13,6 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
|
||||
);
|
||||
|
||||
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
|
||||
);
|
||||
|
||||
export const IS_OIDC_SSO_ENABLED = Boolean(
|
||||
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
|
||||
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
|
||||
|
||||
@@ -81,17 +81,12 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const completedDocumentEmailAttachments = await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
const file = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
// Use the envelope title for version 1, and the envelope item title for version 2.
|
||||
const fileNameToUse =
|
||||
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
|
||||
envelope.envelopeItems.map(async (document) => {
|
||||
const file = await getFileServerSide(document.documentData);
|
||||
|
||||
return {
|
||||
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
|
||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(file),
|
||||
contentType: 'application/pdf',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ export const sendDocument = async ({
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
@@ -165,6 +166,16 @@ 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) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
@@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
false,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export interface CreateFolderOptions {
|
||||
@@ -24,27 +22,6 @@ export const createFolder = async ({
|
||||
// This indirectly verifies whether the user has access to the team.
|
||||
const settings = await getTeamSettings({ userId, teamId });
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentFolder.type !== type) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Parent folder type does not match the folder type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
@@ -21,9 +20,6 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,7 +39,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
||||
|
||||
return await prisma.folder.delete({
|
||||
where: {
|
||||
id: folder.id,
|
||||
id: folderId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface FindFoldersInternalOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const findFoldersInternal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
}: FindFoldersInternalOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const visibilityFilters = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface FindFoldersOptions {
|
||||
@@ -13,48 +11,102 @@ export interface FindFoldersOptions {
|
||||
teamId: number;
|
||||
parentId?: string | null;
|
||||
type?: TFolderType;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const findFolders = async ({
|
||||
userId,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindFoldersOptions) => {
|
||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const whereClause: Prisma.FolderWhereInput = {
|
||||
parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
const visibilityFilters = {
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
const whereClause = {
|
||||
AND: [
|
||||
{ parentId },
|
||||
{
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
try {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
...whereClause,
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
const foldersWithDetails = await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
try {
|
||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||
prisma.folder.findMany({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
folderId: folder.id,
|
||||
},
|
||||
}),
|
||||
prisma.folder.count({
|
||||
where: {
|
||||
parentId: folder.id,
|
||||
teamId,
|
||||
...visibilityFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||
...subfolder,
|
||||
subfolders: [],
|
||||
_count: {
|
||||
documents: 0,
|
||||
templates: 0,
|
||||
subfolders: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: subfoldersWithEmptySubfolders,
|
||||
_count: {
|
||||
documents: documentCount,
|
||||
templates: templateCount,
|
||||
subfolders: subfolderCount,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing folder:', folder.id, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return foldersWithDetails;
|
||||
} catch (error) {
|
||||
console.error('Error in findFolders:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface GetFolderByIdOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId: string;
|
||||
folderId?: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type,
|
||||
const visibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => ({
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
in: [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
.with(TeamMemberRole.MANAGER, () => ({
|
||||
visibility: {
|
||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
||||
},
|
||||
}))
|
||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
||||
|
||||
const whereClause = {
|
||||
id: folderId,
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ teamId, ...visibilityFilters },
|
||||
{ userId, teamId },
|
||||
],
|
||||
};
|
||||
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
|
||||
89
packages/lib/server-only/folder/move-folder.ts
Normal file
89
packages/lib/server-only/folder/move-folder.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface MoveFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId?: string;
|
||||
parentId?: string | null;
|
||||
requestMetadata?: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const folder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await tx.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
userId,
|
||||
teamId,
|
||||
type: folder.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into itself',
|
||||
});
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.parentId;
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into its descendant',
|
||||
});
|
||||
}
|
||||
|
||||
const currentParent = await tx.folder.findUnique({
|
||||
where: {
|
||||
id: currentParentId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentParent) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentParentId = currentParent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
return await tx.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/pin-folder.ts
Normal file
40
packages/lib/server-only/folder/pin-folder.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface PinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
40
packages/lib/server-only/folder/unpin-folder.ts
Normal file
40
packages/lib/server-only/folder/unpin-folder.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface UnpinFolderOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
},
|
||||
data: {
|
||||
pinned: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,28 +1,28 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { TFolderType } from '../../types/folder-type';
|
||||
import { FolderType } from '../../types/folder-type';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export interface UpdateFolderOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
teamId?: number;
|
||||
folderId: string;
|
||||
data: {
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
pinned?: boolean;
|
||||
};
|
||||
name: string;
|
||||
visibility: DocumentVisibility;
|
||||
type?: TFolderType;
|
||||
}
|
||||
|
||||
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
|
||||
const { parentId, name, visibility, pinned } = data;
|
||||
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
export const updateFolder = async ({
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
name,
|
||||
visibility,
|
||||
type,
|
||||
}: UpdateFolderOptions) => {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
@@ -30,9 +30,7 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,66 +40,17 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parentFolder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: parentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
type: folder.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (parentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into itself',
|
||||
});
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.parentId;
|
||||
|
||||
while (currentParentId) {
|
||||
if (currentParentId === folderId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot move a folder into its descendant',
|
||||
});
|
||||
}
|
||||
|
||||
const currentParent = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: currentParentId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentParent) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentParentId = currentParent.parentId;
|
||||
}
|
||||
}
|
||||
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
|
||||
const effectiveVisibility =
|
||||
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
|
||||
|
||||
return await prisma.folder.update({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
visibility,
|
||||
parentId,
|
||||
pinned,
|
||||
visibility: effectiveVisibility,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
||||
import PDFParser from 'pdf2json';
|
||||
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
type TextPosition = {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
};
|
||||
|
||||
type CharIndexMapping = {
|
||||
textPosIndex: number;
|
||||
};
|
||||
|
||||
type PlaceholderInfo = {
|
||||
placeholder: string;
|
||||
fieldType: string;
|
||||
recipient: string;
|
||||
isRequired: string;
|
||||
page: number;
|
||||
// PDF2JSON coordinates (in page units - these are relative to page dimensions)
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// Page dimensions from PDF2JSON (in page units)
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
/*
|
||||
Questions for later:
|
||||
- Does it handle multi-page PDFs?
|
||||
- What happens with incorrect placeholders? E.g. those containing non-accepted properties.
|
||||
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing.
|
||||
*/
|
||||
|
||||
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parser = new PDFParser(null, true);
|
||||
|
||||
parser.on('pdfParser_dataError', (errData) => {
|
||||
reject(errData);
|
||||
});
|
||||
|
||||
parser.on('pdfParser_dataReady', (pdfData) => {
|
||||
const placeholders: PlaceholderInfo[] = [];
|
||||
|
||||
pdfData.Pages.forEach((page, pageIndex) => {
|
||||
/*
|
||||
pdf2json returns the PDF page content as an array of characters.
|
||||
We need to concatenate the characters to get the full text.
|
||||
We also need to get the position of the text so we can place the placeholders in the correct position.
|
||||
|
||||
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
|
||||
*/
|
||||
const pageWidth = page.Width;
|
||||
const pageHeight = page.Height;
|
||||
|
||||
let pageText = '';
|
||||
const textPositions: TextPosition[] = [];
|
||||
const charIndexToTextPos: CharIndexMapping[] = [];
|
||||
|
||||
page.Texts.forEach((text) => {
|
||||
/*
|
||||
R is an array that contains objects with each character.
|
||||
The decodedText contains only the character, without any other information.
|
||||
|
||||
textPositions stores each character and its position on the page.
|
||||
*/
|
||||
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
|
||||
|
||||
for (let i = 0; i < decodedText.length; i++) {
|
||||
charIndexToTextPos.push({
|
||||
textPosIndex: textPositions.length,
|
||||
});
|
||||
}
|
||||
|
||||
pageText += decodedText;
|
||||
|
||||
textPositions.push({
|
||||
text: decodedText,
|
||||
x: text.x,
|
||||
y: text.y,
|
||||
w: text.w || 0,
|
||||
});
|
||||
});
|
||||
|
||||
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
|
||||
|
||||
for (const match of placeholderMatches) {
|
||||
const placeholder = match[0];
|
||||
const placeholderData = match[1].split(',').map((part) => part.trim());
|
||||
|
||||
const [fieldType, recipient, isRequired] = placeholderData;
|
||||
|
||||
/*
|
||||
Find the position of where the placeholder starts in the text
|
||||
|
||||
Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder.
|
||||
*/
|
||||
const matchIndex = match.index;
|
||||
const placeholderLength = placeholder.length;
|
||||
const placeholderEndIndex = matchIndex + placeholderLength;
|
||||
|
||||
const startCharInfo = charIndexToTextPos[matchIndex];
|
||||
const endCharInfo = charIndexToTextPos[placeholderEndIndex - 1];
|
||||
|
||||
if (!startCharInfo || !endCharInfo) {
|
||||
console.error('Could not find text position for placeholder', placeholder);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const startTextPos = textPositions[startCharInfo.textPosIndex];
|
||||
const endTextPos = textPositions[endCharInfo.textPosIndex];
|
||||
|
||||
/*
|
||||
PDF2JSON coordinates - these are in "page units" (relative coordinates)
|
||||
Calculate width as the distance from start to end, plus a portion of the last character's width
|
||||
Use 10% of the last character width to avoid extending too far beyond the placeholder
|
||||
*/
|
||||
const x = startTextPos.x;
|
||||
const y = startTextPos.y;
|
||||
const width = endTextPos.x + endTextPos.w * 0.1 - startTextPos.x;
|
||||
|
||||
placeholders.push({
|
||||
placeholder,
|
||||
fieldType,
|
||||
recipient,
|
||||
isRequired,
|
||||
page: pageIndex + 1,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: 1,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resolve(placeholders);
|
||||
});
|
||||
|
||||
parser.parseBuffer(pdf);
|
||||
});
|
||||
};
|
||||
|
||||
export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> => {
|
||||
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||
|
||||
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
const pageIndex = placeholder.page - 1;
|
||||
const page = pages[pageIndex];
|
||||
|
||||
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
|
||||
|
||||
/*
|
||||
Convert PDF2JSON coordinates to pdf-lib coordinates:
|
||||
|
||||
PDF2JSON uses relative "page units":
|
||||
- x, y, width, height are in page units
|
||||
- Page dimensions (Width, Height) are also in page units
|
||||
|
||||
pdf-lib uses absolute points (1 point = 1/72 inch):
|
||||
- Need to convert from page units to points
|
||||
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
|
||||
- Y-axis in PDF2JSON is top-down (origin at top-left)
|
||||
|
||||
Conversion formulas:
|
||||
- x_points = (x / pageWidth) * pdfLibPageWidth
|
||||
- y_points = pdfLibPageHeight - ((y / pageHeight) * pdfLibPageHeight)
|
||||
- width_points = (width / pageWidth) * pdfLibPageWidth
|
||||
- height_points = (height / pageHeight) * pdfLibPageHeight
|
||||
*/
|
||||
|
||||
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
|
||||
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
|
||||
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
|
||||
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
|
||||
|
||||
page.drawRectangle({
|
||||
x: xPoints,
|
||||
y: yPoints - heightPoints, // Adjust for height since y is at baseline
|
||||
width: widthPoints,
|
||||
height: heightPoints,
|
||||
color: rgb(1, 1, 1),
|
||||
borderColor: rgb(1, 1, 1),
|
||||
borderWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const modifiedPdfBytes = await pdfDoc.save();
|
||||
|
||||
return Buffer.from(modifiedPdfBytes);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import Konva from 'konva';
|
||||
import 'konva/skia-backend';
|
||||
import fs from 'node:fs';
|
||||
import type { Canvas } from 'skia-canvas';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -86,6 +85,7 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
|
||||
// Will render onto the layer.
|
||||
renderField({
|
||||
scale: 1,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
@@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
||||
|
||||
const renderedField = await canvas.toBuffer('svg');
|
||||
|
||||
fs.writeFileSync(
|
||||
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||
renderedField.toString('utf-8'),
|
||||
);
|
||||
// fs.writeFileSync(
|
||||
// `rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||
// renderedField.toString('utf-8'),
|
||||
// );
|
||||
|
||||
// Embed the SVG into the PDF
|
||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope')
|
||||
.selectFrom('Document')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
||||
fn.count('id').as('count'),
|
||||
fn
|
||||
.sum(fn.count('id'))
|
||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||
.groupBy('month')
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().email().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
||||
@@ -81,6 +81,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
|
||||
});
|
||||
|
||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||
@@ -278,6 +279,7 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
required: false,
|
||||
readOnly: false,
|
||||
direction: 'vertical',
|
||||
};
|
||||
|
||||
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const upsertFieldGroup = (
|
||||
field: FieldToRender,
|
||||
options: RenderFieldElementOptions,
|
||||
): Konva.Group => {
|
||||
const { pageWidth, pageHeight, pageLayer, editable } = options;
|
||||
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
|
||||
|
||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
||||
field,
|
||||
@@ -27,6 +27,9 @@ export const upsertFieldGroup = (
|
||||
name: 'field-group',
|
||||
});
|
||||
|
||||
const maxXPosition = (pageWidth - fieldWidth) * scale;
|
||||
const maxYPosition = (pageHeight - fieldHeight) * scale;
|
||||
|
||||
fieldGroup.setAttrs({
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
@@ -34,8 +37,9 @@ export const upsertFieldGroup = (
|
||||
y: fieldY,
|
||||
draggable: editable,
|
||||
dragBoundFunc: (pos) => {
|
||||
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
|
||||
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
|
||||
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
||||
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
|
||||
|
||||
return { x: newX, y: newY };
|
||||
},
|
||||
} satisfies Partial<Konva.GroupConfig>);
|
||||
|
||||
@@ -26,8 +26,9 @@ export type RenderFieldElementOptions = {
|
||||
pageLayer: Konva.Layer;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
mode?: 'edit' | 'sign' | 'export';
|
||||
mode: 'edit' | 'sign' | 'export';
|
||||
editable?: boolean;
|
||||
scale: number;
|
||||
color?: TRecipientColor;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,11 @@ type CalculateMultiItemPositionOptions = {
|
||||
*/
|
||||
fieldPadding: number;
|
||||
|
||||
/**
|
||||
* The direction of the items.
|
||||
*/
|
||||
direction: 'horizontal' | 'vertical';
|
||||
|
||||
type: 'checkbox' | 'radio';
|
||||
};
|
||||
|
||||
@@ -122,6 +128,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
itemSize,
|
||||
spacingBetweenItemAndText,
|
||||
fieldPadding,
|
||||
direction,
|
||||
type,
|
||||
} = options;
|
||||
|
||||
@@ -130,6 +137,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
const innerFieldX = fieldPadding;
|
||||
const innerFieldY = fieldPadding;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
const itemHeight = innerFieldHeight;
|
||||
const itemWidth = innerFieldWidth / itemCount;
|
||||
|
||||
const y = innerFieldY;
|
||||
const x = itemIndex * itemWidth + innerFieldX;
|
||||
|
||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||
let itemInputX = x;
|
||||
|
||||
// We need a little different logic to center the radio circle icon.
|
||||
if (type === 'radio') {
|
||||
itemInputX = x + itemSize / 2;
|
||||
itemInputY = y + itemHeight / 2;
|
||||
}
|
||||
|
||||
const textX = x + itemSize + spacingBetweenItemAndText;
|
||||
const textY = y;
|
||||
|
||||
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
|
||||
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
|
||||
const textHeight = itemHeight;
|
||||
|
||||
return {
|
||||
itemInputX,
|
||||
itemInputY,
|
||||
textX,
|
||||
textY,
|
||||
textWidth,
|
||||
textHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const itemHeight = innerFieldHeight / itemCount;
|
||||
|
||||
const y = itemIndex * itemHeight + innerFieldY;
|
||||
@@ -137,6 +177,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||
let itemInputX = innerFieldX;
|
||||
|
||||
// We need a little different logic to center the radio circle icon.
|
||||
if (type === 'radio') {
|
||||
itemInputX = innerFieldX + itemSize / 2;
|
||||
itemInputY = y + itemHeight / 2;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Konva from 'konva';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
||||
@@ -21,104 +22,112 @@ export const renderCheckboxFieldElement = (
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// Clear previous children to re-render fresh
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
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 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);
|
||||
|
||||
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 } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
@@ -128,6 +137,7 @@ export const renderCheckboxFieldElement = (
|
||||
itemSize: checkboxSize,
|
||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||
fieldPadding: checkboxFieldPadding,
|
||||
direction: checkboxMeta?.direction || 'vertical',
|
||||
type: 'checkbox',
|
||||
});
|
||||
|
||||
@@ -156,7 +166,7 @@ export const renderCheckboxFieldElement = (
|
||||
strokeWidth: 2,
|
||||
stroke: '#111827',
|
||||
points: [3, 8, 7, 12, 13, 4],
|
||||
visible: checked,
|
||||
visible: isCheckboxChecked,
|
||||
});
|
||||
|
||||
const text = new Konva.Text({
|
||||
|
||||
@@ -47,6 +47,7 @@ type RenderFieldOptions = {
|
||||
*/
|
||||
mode: 'edit' | 'sign' | 'export';
|
||||
|
||||
scale: number;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
@@ -56,6 +57,7 @@ export const renderField = ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
mode,
|
||||
scale,
|
||||
editable,
|
||||
color,
|
||||
}: RenderFieldOptions) => {
|
||||
@@ -66,6 +68,7 @@ export const renderField = ({
|
||||
mode,
|
||||
color,
|
||||
editable,
|
||||
scale,
|
||||
};
|
||||
|
||||
return match(field.type)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Konva from 'konva';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||
import type { TRadioFieldMeta } from '../../types/field-meta';
|
||||
@@ -26,90 +27,99 @@ export const renderRadioFieldElement = (
|
||||
|
||||
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;
|
||||
|
||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = circles.map((circle, i) => ({
|
||||
circleElement: circle,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { circleElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: radioSize,
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
});
|
||||
|
||||
circleElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.width(rectWidth);
|
||||
fieldRect.height(rectHeight);
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
}
|
||||
|
||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||
const radioValues = radioMeta?.values || [];
|
||||
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
}
|
||||
|
||||
fieldGroup.off('transform');
|
||||
|
||||
// Handle rescaling items during transforms.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||
|
||||
const groupedItems = circles.map((circle, i) => ({
|
||||
circleElement: circle,
|
||||
checkmarkElement: checkmarks[i],
|
||||
textElement: text[i],
|
||||
}));
|
||||
|
||||
groupedItems.forEach((item, i) => {
|
||||
const { circleElement, checkmarkElement, textElement } = item;
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth: rectWidth,
|
||||
fieldHeight: rectHeight,
|
||||
itemCount: radioValues.length,
|
||||
itemIndex: i,
|
||||
itemSize: radioSize,
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
circleElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
checkmarkElement.setAttrs({
|
||||
x: itemInputX,
|
||||
y: itemInputY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
textElement.setAttrs({
|
||||
x: textX,
|
||||
y: textY,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
});
|
||||
});
|
||||
|
||||
fieldRect.width(rectWidth);
|
||||
fieldRect.height(rectHeight);
|
||||
|
||||
fieldGroup.scale({
|
||||
x: 1,
|
||||
y: 1,
|
||||
});
|
||||
|
||||
pageLayer.batchDraw();
|
||||
});
|
||||
|
||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||
|
||||
radioValues.forEach(({ value, checked }, index) => {
|
||||
const isRadioValueChecked = match(mode)
|
||||
.with('edit', () => checked)
|
||||
.with('sign', () => value === field.customText)
|
||||
.with('export', () => value === field.customText)
|
||||
.exhaustive();
|
||||
|
||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||
calculateMultiItemPosition({
|
||||
fieldWidth,
|
||||
@@ -120,6 +130,7 @@ export const renderRadioFieldElement = (
|
||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||
fieldPadding: radioFieldPadding,
|
||||
type: 'radio',
|
||||
direction: radioMeta?.direction || 'vertical',
|
||||
});
|
||||
|
||||
// Circle which represents the radio button.
|
||||
@@ -144,9 +155,7 @@ export const renderRadioFieldElement = (
|
||||
y: itemInputY,
|
||||
radius: radioSize / 4,
|
||||
fill: '#111827',
|
||||
// Todo: Envelopes
|
||||
visible: value === field.customText,
|
||||
// visible: checked,
|
||||
visible: isRadioValueChecked,
|
||||
});
|
||||
|
||||
const text = new Konva.Text({
|
||||
|
||||
@@ -96,77 +96,80 @@ export const renderSignatureFieldElement = (
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// ABOVE IS GENERIC, EXTRACT IT.
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
}
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldText = upsertFieldText(field, options);
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
pageLayer.add(fieldGroup);
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
}
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Handle export mode.
|
||||
if (mode === 'export') {
|
||||
|
||||
@@ -121,77 +121,80 @@ export const renderTextFieldElement = (
|
||||
|
||||
const fieldGroup = upsertFieldGroup(field, options);
|
||||
|
||||
// ABOVE IS GENERIC, EXTRACT IT.
|
||||
// Clear previous children and listeners to re-render fresh.
|
||||
fieldGroup.removeChildren();
|
||||
fieldGroup.off('transform');
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
pageLayer.add(fieldGroup);
|
||||
}
|
||||
|
||||
// Render the field background and text.
|
||||
const fieldRect = upsertFieldRect(field, options);
|
||||
const fieldText = upsertFieldText(field, options);
|
||||
|
||||
// Assign elements to group and any listeners that should only be run on initialization.
|
||||
if (isFirstRender) {
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
pageLayer.add(fieldGroup);
|
||||
fieldGroup.add(fieldRect);
|
||||
fieldGroup.add(fieldText);
|
||||
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
// This is to keep the text inside the field at the same size
|
||||
// when the field is resized. Without this the text would be stretched.
|
||||
fieldGroup.on('transform', () => {
|
||||
const groupScaleX = fieldGroup.scaleX();
|
||||
const groupScaleY = fieldGroup.scaleY();
|
||||
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
// Adjust text scale so it doesn't change while group is resized.
|
||||
fieldText.scaleX(1 / groupScaleX);
|
||||
fieldText.scaleY(1 / groupScaleY);
|
||||
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
const rectWidth = fieldRect.width() * groupScaleX;
|
||||
const rectHeight = fieldRect.height() * groupScaleY;
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
console.log({
|
||||
rectWidth,
|
||||
});
|
||||
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
// Reset the text after transform has ended.
|
||||
fieldGroup.on('transformend', () => {
|
||||
fieldText.scaleX(1);
|
||||
fieldText.scaleY(1);
|
||||
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
const rectWidth = fieldRect.width();
|
||||
const rectHeight = fieldRect.height();
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
// // Update text group position and clipping
|
||||
// fieldGroup.clipFunc(function (ctx) {
|
||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||
// });
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
}
|
||||
// Update text dimensions
|
||||
fieldText.width(rectWidth); // Account for padding
|
||||
fieldText.height(rectHeight);
|
||||
|
||||
// Force Konva to recalculate text layout
|
||||
// textInsideField.getTextHeight(); // This forces recalculation
|
||||
fieldText.height(); // This forces recalculation
|
||||
|
||||
// fieldGroup.draw();
|
||||
fieldGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Handle export mode.
|
||||
if (mode === 'export') {
|
||||
|
||||
@@ -86,10 +86,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings ?? undefined,
|
||||
},
|
||||
meta,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const distributeDocumentRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
emailSettings: meta.emailSettings,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
timezone: meta.timezone,
|
||||
redirectUrl: meta.redirectUrl,
|
||||
distributionMethod: meta.distributionMethod,
|
||||
emailSettings: meta.emailSettings ?? undefined,
|
||||
emailSettings: meta.emailSettings,
|
||||
language: meta.language,
|
||||
emailId: meta.emailId,
|
||||
emailReplyTo: meta.emailReplyTo,
|
||||
|
||||
@@ -2,26 +2,27 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
|
||||
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
|
||||
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
|
||||
import { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal';
|
||||
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
|
||||
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
|
||||
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
|
||||
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
|
||||
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
|
||||
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateFolderRequestSchema,
|
||||
ZCreateFolderResponseSchema,
|
||||
ZDeleteFolderRequestSchema,
|
||||
ZFindFoldersInternalRequestSchema,
|
||||
ZFindFoldersInternalResponseSchema,
|
||||
ZCreateFolderSchema,
|
||||
ZDeleteFolderSchema,
|
||||
ZFindFoldersRequestSchema,
|
||||
ZFindFoldersResponseSchema,
|
||||
ZGenericSuccessResponse,
|
||||
ZGetFoldersResponseSchema,
|
||||
ZGetFoldersSchema,
|
||||
ZMoveFolderSchema,
|
||||
ZPinFolderSchema,
|
||||
ZSuccessResponseSchema,
|
||||
ZUpdateFolderRequestSchema,
|
||||
ZUpdateFolderResponseSchema,
|
||||
ZUnpinFolderSchema,
|
||||
ZUpdateFolderSchema,
|
||||
} from './schema';
|
||||
|
||||
export const folderRouter = router({
|
||||
@@ -42,7 +43,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFoldersInternal({
|
||||
const folders = await findFolders({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@@ -65,48 +66,12 @@ export const folderRouter = router({
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
findFolders: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/folder',
|
||||
summary: 'Find folders',
|
||||
description: 'Find folders based on a search criteria',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZFindFoldersRequestSchema)
|
||||
.output(ZFindFoldersResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type, page, perPage } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
parentId,
|
||||
type,
|
||||
},
|
||||
});
|
||||
|
||||
return await findFolders({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
type,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
findFoldersInternal: authenticatedProcedure
|
||||
.input(ZFindFoldersInternalRequestSchema)
|
||||
.output(ZFindFoldersInternalResponseSchema)
|
||||
findFolders: authenticatedProcedure
|
||||
.input(ZFindFoldersRequestSchema)
|
||||
.output(ZFindFoldersResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { parentId, type } = input;
|
||||
@@ -118,7 +83,7 @@ export const folderRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
const folders = await findFoldersInternal({
|
||||
const folders = await findFolders({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
parentId,
|
||||
@@ -142,20 +107,10 @@ export const folderRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
createFolder: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/create',
|
||||
summary: 'Create new folder',
|
||||
description: 'Creates a new folder in your team',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZCreateFolderRequestSchema)
|
||||
.output(ZCreateFolderResponseSchema)
|
||||
.input(ZCreateFolderSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { name, parentId, type } = input;
|
||||
@@ -190,77 +145,181 @@ export const folderRouter = router({
|
||||
type,
|
||||
});
|
||||
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
updateFolder: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/update',
|
||||
summary: 'Update folder',
|
||||
description: 'Updates an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZUpdateFolderRequestSchema)
|
||||
.output(ZUpdateFolderResponseSchema)
|
||||
.input(ZUpdateFolderSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { folderId, data } = input;
|
||||
const { id, name, visibility } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
const result = await updateFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId,
|
||||
data,
|
||||
folderId: id,
|
||||
name,
|
||||
visibility,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @private
|
||||
*/
|
||||
deleteFolder: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/folder/delete',
|
||||
summary: 'Delete folder',
|
||||
description: 'Deletes an existing folder',
|
||||
tags: ['Folder'],
|
||||
},
|
||||
})
|
||||
.input(ZDeleteFolderRequestSchema)
|
||||
.input(ZDeleteFolderSchema)
|
||||
.output(ZSuccessResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { folderId } = input;
|
||||
const { id } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { id, parentId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
id,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
});
|
||||
|
||||
if (parentId !== null) {
|
||||
try {
|
||||
await getFolderById({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: parentId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Parent folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await moveFolder({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
folderId: id,
|
||||
parentId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await pinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
|
||||
const { folderId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentFolder = await getFolderById({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const result = await unpinFolder({
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
folderId,
|
||||
type: currentFolder.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
type: currentFolder.type,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
|
||||
/**
|
||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||
@@ -12,23 +11,24 @@ import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSche
|
||||
*/
|
||||
export const ZSuccessResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZGenericSuccessResponse = {
|
||||
success: true,
|
||||
} satisfies z.infer<typeof ZSuccessResponseSchema>;
|
||||
|
||||
export const ZFolderSchema = FolderSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
parentId: true,
|
||||
pinned: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
visibility: true,
|
||||
type: true,
|
||||
export const ZFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullable(),
|
||||
parentId: z.string().nullable(),
|
||||
pinned: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
visibility: z.nativeEnum(DocumentVisibility),
|
||||
type: ZFolderTypeSchema,
|
||||
});
|
||||
|
||||
export type TFolder = z.infer<typeof ZFolderSchema>;
|
||||
@@ -51,39 +51,40 @@ export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({
|
||||
|
||||
export type TFolderWithSubfolders = z.infer<typeof ZFolderWithSubfoldersSchema>;
|
||||
|
||||
const ZFolderParentIdSchema = z
|
||||
.string()
|
||||
.describe(
|
||||
'The folder ID to place this folder within. Leave empty to place folder at the root level.',
|
||||
);
|
||||
|
||||
export const ZCreateFolderRequestSchema = z.object({
|
||||
export const ZCreateFolderSchema = z.object({
|
||||
name: z.string(),
|
||||
parentId: ZFolderParentIdSchema.optional(),
|
||||
parentId: z.string().optional(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateFolderResponseSchema = ZFolderSchema;
|
||||
|
||||
export const ZUpdateFolderRequestSchema = z.object({
|
||||
folderId: z.string().describe('The ID of the folder to update'),
|
||||
data: z.object({
|
||||
name: z.string().optional().describe('The name of the folder'),
|
||||
parentId: ZFolderParentIdSchema.optional().nullable(),
|
||||
visibility: z
|
||||
.nativeEnum(DocumentVisibility)
|
||||
.optional()
|
||||
.describe('The visibility of the folder'),
|
||||
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
|
||||
}),
|
||||
export const ZUpdateFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
visibility: z.nativeEnum(DocumentVisibility),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export type TUpdateFolderRequestSchema = z.infer<typeof ZUpdateFolderRequestSchema>;
|
||||
export type TUpdateFolderSchema = z.infer<typeof ZUpdateFolderSchema>;
|
||||
|
||||
export const ZUpdateFolderResponseSchema = ZFolderSchema;
|
||||
export const ZDeleteFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZDeleteFolderRequestSchema = z.object({
|
||||
export const ZMoveFolderSchema = z.object({
|
||||
id: z.string(),
|
||||
parentId: z.string().nullable(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZPinFolderSchema = z.object({
|
||||
folderId: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZUnpinFolderSchema = z.object({
|
||||
folderId: z.string(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZGetFoldersSchema = z.object({
|
||||
@@ -100,20 +101,11 @@ export const ZGetFoldersResponseSchema = z.object({
|
||||
export type TGetFoldersResponse = z.infer<typeof ZGetFoldersResponseSchema>;
|
||||
|
||||
export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
parentId: z.string().optional().describe('Filter folders by the parent folder ID'),
|
||||
type: ZFolderTypeSchema.optional().describe('Filter folders by the folder type'),
|
||||
});
|
||||
|
||||
export const ZFindFoldersResponseSchema = ZFindResultResponse.extend({
|
||||
data: z.array(ZFolderSchema),
|
||||
});
|
||||
|
||||
export const ZFindFoldersInternalRequestSchema = ZFindSearchParamsSchema.extend({
|
||||
parentId: z.string().nullable().optional(),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZFindFoldersInternalResponseSchema = z.object({
|
||||
export const ZFindFoldersResponseSchema = z.object({
|
||||
data: z.array(ZFolderWithSubfoldersSchema),
|
||||
breadcrumbs: z.array(ZFolderSchema),
|
||||
type: ZFolderTypeSchema.optional(),
|
||||
|
||||
@@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
|
||||
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
|
||||
{envelopeItemFile && Konva ? (
|
||||
<PDFDocument
|
||||
file={envelopeItemFile}
|
||||
className={cn('w-full overflow-hidden rounded', {
|
||||
className={cn('w-full rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
@@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div key={i} className="last:-mb-2">
|
||||
<div className="border-border overflow-hidden rounded border will-change-transform">
|
||||
<div className="border-border rounded border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type RecipientColorStyles = {
|
||||
base: string;
|
||||
baseRing: string;
|
||||
baseRingHover: string;
|
||||
fieldButton: string;
|
||||
fieldItem: string;
|
||||
fieldItemInitials: string;
|
||||
comboxBoxTrigger: string;
|
||||
@@ -23,6 +24,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-neutral-400',
|
||||
baseRing: '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]',
|
||||
fieldItemInitials: '',
|
||||
comboxBoxTrigger:
|
||||
@@ -34,6 +36,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-green hover:bg-recipient-green/30',
|
||||
baseRing: 'rgba(122, 195, 85, 1)',
|
||||
baseRingHover: 'rgba(122, 195, 85, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
|
||||
comboxBoxTrigger:
|
||||
@@ -45,6 +48,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
|
||||
baseRing: 'rgba(56, 123, 199, 1)',
|
||||
baseRingHover: 'rgba(56, 123, 199, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
|
||||
comboxBoxTrigger:
|
||||
@@ -56,6 +60,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
|
||||
baseRing: 'rgba(151, 71, 255, 1)',
|
||||
baseRingHover: 'rgba(151, 71, 255, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
|
||||
comboxBoxTrigger:
|
||||
@@ -67,6 +72,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
|
||||
baseRing: 'rgba(246, 159, 30, 1)',
|
||||
baseRingHover: 'rgba(246, 159, 30, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
|
||||
comboxBoxTrigger:
|
||||
@@ -78,6 +84,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
|
||||
baseRing: 'rgba(219, 186, 0, 1)',
|
||||
baseRingHover: 'rgba(219, 186, 0, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
|
||||
comboxBoxTrigger:
|
||||
@@ -89,6 +96,7 @@ export const RECIPIENT_COLOR_STYLES = {
|
||||
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
|
||||
baseRing: 'rgba(217, 74, 186, 1)',
|
||||
baseRingHover: 'rgba(217, 74, 186, 0.3)',
|
||||
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
|
||||
fieldItem: 'group/field-item rounded-[2px]',
|
||||
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
|
||||
comboxBoxTrigger:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
||||
Reference in New Issue
Block a user