mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
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:
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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} />
|
||||
) : (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user