mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 17:51:49 +10:00
feat: add envelope editor
This commit is contained in:
@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckSquareIcon,
|
||||
ContactIcon,
|
||||
DiscIcon,
|
||||
HashIcon,
|
||||
ListIcon,
|
||||
MailIcon,
|
||||
TextIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
|
||||
const MIN_HEIGHT_PX = 12;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
|
||||
const DEFAULT_HEIGHT_PX = MIN_HEIGHT_PX * 2.5;
|
||||
const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
||||
|
||||
export const fieldButtonList = [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
icon: SignatureIcon,
|
||||
name: msg`Signature`,
|
||||
className: 'font-signature text-lg',
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
icon: MailIcon,
|
||||
name: msg`Email`,
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
icon: UserIcon,
|
||||
name: msg`Name`,
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
icon: ContactIcon,
|
||||
name: msg`Initials`,
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
icon: CalendarIcon,
|
||||
name: msg`Date`,
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
icon: TextIcon,
|
||||
name: msg`Text`,
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
icon: HashIcon,
|
||||
name: msg`Number`,
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
icon: DiscIcon,
|
||||
name: msg`Radio`,
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
icon: CheckSquareIcon,
|
||||
name: msg`Checkbox`,
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
icon: ListIcon,
|
||||
name: msg`Dropdown`,
|
||||
},
|
||||
];
|
||||
|
||||
type EnvelopeEditorFieldDragDropProps = {
|
||||
selectedRecipientId: number | null;
|
||||
selectedEnvelopeItemId: string | null;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const isFieldsDisabled = useMemo(() => {
|
||||
const selectedSigner = envelope.recipients.find(
|
||||
(recipient) => recipient.id === selectedRecipientId,
|
||||
);
|
||||
const fields = envelope.fields;
|
||||
|
||||
if (!selectedSigner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow fields to be modified for templates regardless of anything.
|
||||
if (isTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !canRecipientFieldsBeModified(selectedSigner, fields);
|
||||
}, [selectedRecipientId, envelope.recipients, envelope.fields]);
|
||||
|
||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const fieldBounds = useRef({
|
||||
height: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
setIsFieldWithinBounds(
|
||||
isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
),
|
||||
);
|
||||
|
||||
setCoords({
|
||||
x: event.clientX - fieldBounds.current.width / 2,
|
||||
y: event.clientY - fieldBounds.current.height / 2,
|
||||
});
|
||||
},
|
||||
[isWithinPageBounds],
|
||||
);
|
||||
|
||||
const onMouseClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!selectedField || !selectedRecipientId || !selectedEnvelopeItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (
|
||||
!$page ||
|
||||
!isWithinPageBounds(
|
||||
event,
|
||||
PDF_VIEWER_PAGE_SELECTOR,
|
||||
fieldBounds.current.width,
|
||||
fieldBounds.current.height,
|
||||
)
|
||||
) {
|
||||
setSelectedField(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, height, width } = getBoundingClientRect($page);
|
||||
|
||||
console.log({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
width,
|
||||
rawPageX: event.pageX,
|
||||
rawPageY: event.pageY,
|
||||
});
|
||||
|
||||
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
|
||||
|
||||
// Calculate x and y as a percentage of the page width and height
|
||||
let pageX = ((event.pageX - left) / width) * 100;
|
||||
let pageY = ((event.pageY - top) / height) * 100;
|
||||
|
||||
// Get the bounds as a percentage of the page width and height
|
||||
const fieldPageWidth = (fieldBounds.current.width / width) * 100;
|
||||
const fieldPageHeight = (fieldBounds.current.height / height) * 100;
|
||||
|
||||
// And center it based on the bounds
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
const field = {
|
||||
formId: nanoid(12),
|
||||
envelopeItemId: selectedEnvelopeItemId,
|
||||
type: selectedField,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: fieldPageWidth,
|
||||
height: fieldPageHeight,
|
||||
recipientId: selectedRecipientId,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[selectedField]),
|
||||
};
|
||||
|
||||
editorFields.addField(field);
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[
|
||||
isWithinPageBounds,
|
||||
selectedField,
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
getPage,
|
||||
editorFields,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
if (!$page) {
|
||||
return;
|
||||
}
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(DEFAULT_HEIGHT_PX),
|
||||
width: Math.max(DEFAULT_WIDTH_PX),
|
||||
};
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||
{fieldButtonList.map((field) => (
|
||||
<button
|
||||
disabled={isFieldsDisabled}
|
||||
key={field.type}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||
{t(field.name)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedField && (
|
||||
<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
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||
'dark:text-black/60': isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
|
||||
{t(FRIENDLY_FIELD_TYPE[selectedField])}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user