diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index e82232311..fc7cf7117 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -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(); } }; 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 a0d04acc0..582ba3283 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 @@ -28,7 +28,7 @@ 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'; @@ -100,32 +100,38 @@ export const EnvelopeEditorFieldsPage = () => { const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]); - const [copiedField, setCopiedField] = useState(null); + const [copiedFields, setCopiedFields] = useState([]); useHotkeys(['ctrl+c', 'meta+c'], (event) => { // Keep the native copy when the user has selected text. - if (!selectedField || window.getSelection()?.toString()) { + if (editorFields.selectedFields.length === 0 || window.getSelection()?.toString()) { return; } event.preventDefault(); - setCopiedField(selectedField); + setCopiedFields(structuredClone(editorFields.selectedFields)); toast({ - title: _(msg`Copied field`), - description: _(msg`Copied field to clipboard`), + title: _( + msg({ + message: plural(editorFields.selectedFields.length, { + one: 'Copied field', + other: 'Copied # fields', + }), + }), + ), }); }); useHotkeys(['ctrl+v', 'meta+v'], (event) => { - if (!copiedField) { + if (copiedFields.length === 0) { return; } event.preventDefault(); - // Paste the next copy offset from the last one, so repeated pastes do not stack. - setCopiedField(editorFields.duplicateField(copiedField)); + // Paste the next copies offset from the last ones, so repeated pastes do not stack. + setCopiedFields(copiedFields.map((field) => editorFields.duplicateField(field))); }); /** 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 f6703c120..327830e94 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 @@ -692,6 +692,56 @@ const assertCopyPasteFieldPersistedInDatabase = async ({ expect(positions.size).toBe(3); }; +const runMultiCopyPasteFieldFlow = async (surface: TEnvelopeEditorSurface): Promise => { + 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 = { @@ -971,6 +1021,16 @@ test.describe('document editor', () => { }); }); + 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); @@ -1043,6 +1103,16 @@ test.describe('template editor', () => { }); }); + 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); diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts index 8f86f0a2c..7a4846b2a 100644 --- a/packages/lib/client-only/hooks/use-editor-fields.ts +++ b/packages/lib/client-only/hooks/use-editor-fields.ts @@ -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; @@ -66,7 +68,7 @@ type UseEditorFieldsResponse = { }; export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => { - const [selectedFieldFormId, setSelectedFieldFormId] = useState(null); + const [selectedFieldFormIds, setSelectedFieldFormIds] = useState([]); const [selectedRecipientId, setSelectedRecipientId] = useState(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,