mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
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.
This commit is contained in:
+17
-16
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TLocalField | null>(null);
|
||||
const [copiedFields, setCopiedFields] = useState<TLocalField[]>([]);
|
||||
|
||||
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)));
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -692,6 +692,56 @@ const assertCopyPasteFieldPersistedInDatabase = async ({
|
||||
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 = {
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user