feat: copy and paste fields (#1193)

Adds keyboard shortcuts for copying and pasting fields, additionally adds the ability
to duplicate a field via the UI.
This commit is contained in:
Ephraim Duncan
2024-08-20 03:32:53 +00:00
committed by GitHub
parent 025e73e640
commit 06c0a50401
12 changed files with 158 additions and 65 deletions

View File

@ -19,6 +19,7 @@ import {
User,
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
@ -40,6 +41,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddFieldsFormSchema } from './add-fields.types';
import {
DocumentFlowFormContainerActions,
@ -103,6 +105,8 @@ export const AddFieldsFormPartial = ({
isDocumentPdfLoaded,
teamId,
}: AddFieldsFormProps) => {
const { toast } = useToast();
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
@ -136,7 +140,12 @@ export const AddFieldsFormPartial = ({
},
});
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt));
useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true }));
const onFormSubmit = handleSubmit(onSubmit);
const handleSavedFieldSettings = (fieldState: FieldMeta) => {
const initialValues = getValues();
@ -169,6 +178,12 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null,
);
const [fieldClipboard, setFieldClipboard] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null,
);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
const selectedSignerStyles = useSignerColors(
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
@ -281,7 +296,7 @@ export const AddFieldsFormPartial = ({
pageX -= fieldPageWidth / 2;
pageY -= fieldPageHeight / 2;
append({
const field = {
formId: nanoid(12),
type: selectedField,
pageNumber,
@ -291,7 +306,9 @@ export const AddFieldsFormPartial = ({
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
fieldMeta: undefined,
});
};
append(field);
setIsFieldWithinBounds(false);
setSelectedField(null);
@ -352,6 +369,57 @@ export const AddFieldsFormPartial = ({
[getFieldPosition, localFields, update],
);
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
const { duplicate = false } = options ?? {};
if (lastActiveField) {
event?.preventDefault();
if (!duplicate) {
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3,
pageY: lastActiveField.pageY + 3,
};
append(newField);
}
},
[append, lastActiveField, selectedSigner?.email, toast],
);
const onFieldPaste = useCallback(
(event: KeyboardEvent) => {
if (fieldClipboard) {
event.preventDefault();
const copiedField = structuredClone(fieldClipboard);
append({
...copiedField,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
pageX: copiedField.pageX + 3,
pageY: copiedField.pageY + 3,
});
}
},
[append, fieldClipboard, selectedSigner?.email],
);
useEffect(() => {
if (selectedField) {
window.addEventListener('mousemove', onMouseMove);
@ -464,6 +532,7 @@ export const AddFieldsFormPartial = ({
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
selectedField === FieldType.SIGNATURE && fontCaveat.className,
)}
style={{
top: coords.y,
@ -491,9 +560,12 @@ export const AddFieldsFormPartial = ({
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onAdvancedSettings={() => {
setCurrentField(field);
handleAdvancedSettings();

View File

@ -26,12 +26,12 @@ export const CheckboxField = ({ field }: CheckboxFieldProps) => {
}
return (
<div className='flex flex-col gap-y-2'>
<div className="flex flex-col gap-y-2">
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
) : (
parsedFieldMeta.values.map((item: { value: string; checked: boolean }, index: number) => (
<div key={index} className='flex items-center gap-x-1.5'>
<div key={index} className="flex items-center gap-x-1.5">
<Checkbox
className="h-4 w-4"
checkClassName="text-white"

View File

@ -26,7 +26,7 @@ export const RadioField = ({ field }: RadioFieldProps) => {
}
return (
<div className='flex flex-col gap-y-2'>
<div className="flex flex-col gap-y-2">
{!parsedFieldMeta?.values ? (
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
) : (

View File

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Settings2, Trash } from 'lucide-react';
import { CopyPlus, Settings2, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { match } from 'ts-pattern';
@ -38,7 +38,10 @@ export type FieldItemProps = {
onResize?: (_node: HTMLElement) => void;
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onDuplicate?: () => void;
onAdvancedSettings?: () => void;
onFocus?: () => void;
onBlur?: () => void;
recipientIndex?: number;
hideRecipients?: boolean;
};
@ -52,6 +55,9 @@ export const FieldItem = ({
onResize,
onMove,
onRemove,
onDuplicate,
onFocus,
onBlur,
onAdvancedSettings,
recipientIndex = 0,
hideRecipients = false,
@ -115,18 +121,29 @@ export const FieldItem = ({
};
}, [calculateCoords]);
const handleClickOutsideField = (event: MouseEvent) => {
if (settingsActive && $el.current && !event.composedPath().includes($el.current)) {
setSettingsActive(false);
}
};
useEffect(() => {
document.body.addEventListener('click', handleClickOutsideField);
return () => {
document.body.removeEventListener('click', handleClickOutsideField);
const onClickOutsideOfField = (event: MouseEvent) => {
const isOutsideOfField = $el.current && !event.composedPath().includes($el.current);
setSettingsActive((active) => {
if (active && isOutsideOfField) {
return false;
}
return active;
});
if (isOutsideOfField) {
onBlur?.();
}
};
}, [settingsActive]);
document.body.addEventListener('click', onClickOutsideOfField);
return () => {
document.body.removeEventListener('click', onClickOutsideOfField);
};
}, [onBlur]);
const hasFieldMetaValues = (
fieldType: string,
@ -189,6 +206,7 @@ export const FieldItem = ({
)}
onClick={() => {
setSettingsActive((prev) => !prev);
onFocus?.();
}}
ref={$el}
>
@ -224,7 +242,7 @@ export const FieldItem = ({
{!disabled && settingsActive && (
<div className="mt-1 flex justify-center">
<div className="dark:bg-background group flex items-center justify-evenly rounded-md border gap-x-1 bg-gray-900 p-0.5">
<div className="dark:bg-background group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && (
<button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
@ -234,6 +252,15 @@ export const FieldItem = ({
<Settings2 className="h-3 w-3" />
</button>
)}
<button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate}
onTouchEnd={onDuplicate}
>
<CopyPlus className="h-3 w-3" />
</button>
<button
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}

View File

@ -121,9 +121,10 @@ export function SinglePlayerModeSignatureField({
) : (
<button
onClick={() => onClick?.()}
className={
cn('group-hover:text-primary absolute inset-0 h-full w-full duration-200', fontCaveat.className)
}
className={cn(
'group-hover:text-primary absolute inset-0 h-full w-full duration-200',
fontCaveat.className,
)}
>
<span className="text-muted-foreground truncate text-3xl font-medium ">Signature</span>
</button>