Compare commits

...
Author SHA1 Message Date
ephraimduncan abb75e0465 feat(editor): copy and paste all selected fields
Ctrl+C copies all the fields in the selection. Before this change, it
copied a field only when one field was selected.

The editor keeps the ids of the selected fields. When the author selects
fields on one page, the editor clears the selection on the other pages.
2026-09-24 11:20:29 +00:00
ephraimduncan a18a04d911 fix(editor): add copy and paste shortcuts for fields
The envelope editor had no handler for Ctrl+C and Ctrl+V. As a result,
the documented shortcuts did nothing in the document and template editors.
2026-09-24 11:09:08 +00:00
4 changed files with 230 additions and 34 deletions
@@ -538,15 +538,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
// resize handles immediately -- no second click needed. Runs after the render loop
// above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
const isAlreadySelected =
liveSelectedFieldGroups.length === 1 && liveSelectedFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
@@ -555,8 +553,16 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
} else if (
liveSelectedFieldGroups.some(
(fieldGroup) => !editorFields.selectedFields.some((field) => field.formId === fieldGroup.id()),
)
) {
// The selection moved to another page, or the editor cleared it (for example, when
// the author starts to place another field). Clear this page only, so the new
// selection stays and the floating toolbar cannot intercept the next placement click.
interactiveTransformer.current?.nodes([]);
setSelectedKonvaFieldGroups([]);
}
// Rerender the transformer
@@ -569,6 +575,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
overlappingFieldFormIds,
isFieldChanging,
editorFields.selectedField?.formId,
editorFields.selectedFields,
]);
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
@@ -584,16 +591,10 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
interactiveTransformer.current?.nodes(fieldGroups);
setSelectedKonvaFieldGroups(fieldGroups);
if (fieldGroups.length === 0 || fieldGroups.length > 1) {
editorFields.setSelectedField(null);
}
editorFields.setSelectedFields(fieldGroups.map((fieldGroup) => fieldGroup.id()));
// Handle single field selection.
if (fieldGroups.length === 1) {
const fieldGroup = fieldGroups[0];
editorFields.setSelectedField(fieldGroup.id());
fieldGroup.moveToTop();
fieldGroups[0].moveToTop();
}
};
@@ -1,4 +1,5 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
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 { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -25,13 +26,15 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { msg, plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -84,6 +87,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const { toast } = useToast();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
@@ -96,6 +100,40 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
const [copiedFields, setCopiedFields] = useState<TLocalField[]>([]);
useHotkeys(['ctrl+c', 'meta+c'], (event) => {
// Keep the native copy when the user has selected text.
if (editorFields.selectedFields.length === 0 || window.getSelection()?.toString()) {
return;
}
event.preventDefault();
setCopiedFields(structuredClone(editorFields.selectedFields));
toast({
title: _(
msg({
message: plural(editorFields.selectedFields.length, {
one: 'Copied field',
other: 'Copied # fields',
}),
}),
),
});
});
useHotkeys(['ctrl+v', 'meta+v'], (event) => {
if (copiedFields.length === 0) {
return;
}
event.preventDefault();
// Paste the next copies offset from the last ones, so repeated pastes do not stack.
setCopiedFields(copiedFields.map((field) => editorFields.duplicateField(field)));
});
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
@@ -633,6 +633,115 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
};
// --- Copy and paste fields flow ---
type TCopyPasteFlowResult = {
externalId: string;
};
const runCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TCopyPasteFlowResult> => {
const externalId = `e2e-copy-paste-${nanoid()}`;
const root = surface.root;
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await root.keyboard.press('ControlOrMeta+c');
await root.keyboard.press('ControlOrMeta+v');
await root.keyboard.press('ControlOrMeta+v');
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
// Navigate away and back to persist changes.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
return { externalId };
};
const assertCopyPasteFieldPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
expect(envelope.fields).toHaveLength(3);
expect(envelope.fields.every((field) => field.type === FieldType.SIGNATURE)).toBe(true);
// Each paste is offset from the previous one, so no two fields share a position.
const positions = new Set(envelope.fields.map((field) => `${field.positionX}:${field.positionY}`));
expect(positions.size).toBe(3);
};
const runMultiCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TCopyPasteFlowResult> => {
const externalId = `e2e-multi-copy-paste-${nanoid()}`;
const root = surface.root;
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION);
await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION);
await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 });
await expect.poll(async () => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
await root.keyboard.press('ControlOrMeta+c');
await root.keyboard.press('ControlOrMeta+v');
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(4);
// Navigate away and back to persist changes.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
await expect.poll(async () => getKonvaElementCountForPage(root, 1, '.field-group')).toBe(4);
return { externalId };
};
const assertMultiCopyPasteFieldPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
const types = envelope.fields.map((field) => field.type).sort();
expect(types).toEqual([FieldType.NAME, FieldType.NAME, FieldType.SIGNATURE, FieldType.SIGNATURE]);
};
// --- Change field type flow ---
type TChangeFieldTypeFlowResult = {
@@ -902,6 +1011,26 @@ test.describe('document editor', () => {
});
});
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runCopyPasteFieldFlow(surface);
await assertCopyPasteFieldPersistedInDatabase({
surface,
...result,
});
});
test('copy and paste multiple selected fields with keyboard shortcuts', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMultiCopyPasteFieldFlow(surface);
await assertMultiCopyPasteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
@@ -964,6 +1093,26 @@ test.describe('template editor', () => {
});
});
test('copy and paste fields with keyboard shortcuts', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runCopyPasteFieldFlow(surface);
await assertCopyPasteFieldPersistedInDatabase({
surface,
...result,
});
});
test('copy and paste multiple selected fields with keyboard shortcuts', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMultiCopyPasteFieldFlow(surface);
await assertMultiCopyPasteFieldPersistedInDatabase({
surface,
...result,
});
});
test('place and configure all 10 field types', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAllFieldTypesFlow(surface);
@@ -5,7 +5,7 @@ import { nanoid } from '@documenso/lib/universal/id';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -42,9 +42,11 @@ type EditorFieldsProps = {
type UseEditorFieldsResponse = {
localFields: TLocalField[];
// Selected field
// Selected fields. `selectedField` is set only when exactly one field is selected.
selectedFields: TLocalField[];
selectedField: TLocalField | undefined;
setSelectedField: (formId: string | null) => void;
setSelectedFields: (formIds: string[]) => void;
// Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
@@ -66,7 +68,7 @@ type UseEditorFieldsResponse = {
};
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedFieldFormIds, setSelectedFieldFormIds] = useState<string[]>([]);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const generateDefaultValues = (fields?: Field[]) => {
@@ -113,7 +115,7 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
const setSelectedField = (formId: string | null, bypassCheck = false) => {
if (!formId) {
setSelectedFieldFormId(null);
setSelectedFieldFormIds([]);
return;
}
@@ -125,11 +127,20 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
}
if (bypassCheck) {
setSelectedFieldFormId(formId);
setSelectedFieldFormIds([formId]);
return;
}
setSelectedFieldFormId(foundField?.formId ?? null);
setSelectedFieldFormIds(foundField ? [foundField.formId] : []);
};
const setSelectedFields = (formIds: string[]) => {
if (formIds.length === 1) {
setSelectedField(formIds[0]);
return;
}
setSelectedFieldFormIds(formIds);
};
const addField = useCallback(
@@ -262,17 +273,12 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
}, [selectedRecipientId, envelope.recipients]);
const selectedField = useMemo(() => {
return localFields.find((field) => field.formId === selectedFieldFormId);
}, [selectedFieldFormId, localFields]);
const selectedFields = useMemo(
() => localFields.filter((field) => selectedFieldFormIds.includes(field.formId)),
[selectedFieldFormIds, localFields],
);
/**
* Keep the selected field form ID in sync with the local fields.
*/
useEffect(() => {
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
setSelectedFieldFormId(foundField?.formId ?? null);
}, [selectedFieldFormId, localFields]);
const selectedField = selectedFieldFormIds.length === 1 ? selectedFields[0] : undefined;
const setSelectedRecipient = (recipientId: number | null) => {
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
@@ -300,9 +306,11 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
getFieldByFormId,
getFieldsByRecipient,
// Selected field
// Selected fields
selectedFields,
selectedField,
setSelectedField,
setSelectedFields,
// Selected recipient
selectedRecipient,