diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index a6146156a..a0d04acc0 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -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,6 +26,7 @@ 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 { useLingui } from '@lingui/react'; @@ -32,6 +34,7 @@ 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,34 @@ export const EnvelopeEditorFieldsPage = () => { const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]); + const [copiedField, setCopiedField] = useState(null); + + useHotkeys(['ctrl+c', 'meta+c'], (event) => { + // Keep the native copy when the user has selected text. + if (!selectedField || window.getSelection()?.toString()) { + return; + } + + event.preventDefault(); + setCopiedField(selectedField); + + toast({ + title: _(msg`Copied field`), + description: _(msg`Copied field to clipboard`), + }); + }); + + useHotkeys(['ctrl+v', 'meta+v'], (event) => { + if (!copiedField) { + return; + } + + event.preventDefault(); + + // Paste the next copy offset from the last one, so repeated pastes do not stack. + setCopiedField(editorFields.duplicateField(copiedField)); + }); + /** * 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 diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts index 0a4c3ab73..f6703c120 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts @@ -633,6 +633,65 @@ 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 => { + 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); +}; + // --- Change field type flow --- type TChangeFieldTypeFlowResult = { @@ -902,6 +961,16 @@ 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('place and configure all 10 field types', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runAllFieldTypesFlow(surface); @@ -964,6 +1033,16 @@ 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('place and configure all 10 field types', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runAllFieldTypesFlow(surface);