Compare commits

..
Author SHA1 Message Date
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
8 changed files with 127 additions and 20 deletions
@@ -122,7 +122,7 @@ export const EnvelopeItemEditDialog = ({
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF or is password protected.`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
@@ -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<TLocalField | null>(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
+1 -1
View File
@@ -92,7 +92,7 @@ export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => {
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with('INVALID_DOCUMENT_FILE', () => ({
title: msg`Error`,
description: msg`The file is not a valid PDF or is password protected.`,
description: msg`You cannot upload encrypted PDFs.`,
}))
.with(AppErrorCode.LIMIT_EXCEEDED, () => ({
title: msg`Error`,
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.19.0"
"version": "2.18.0"
}
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.19.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -193,7 +193,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.19.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4480,9 +4480,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.5.1.tgz",
"integrity": "sha512-q+y4AEk9ngqyC1pdX/hNScnfh0ROr4vSiqX4C9CmuBzhtuVukbfTesXCaGvHZ3pksi8AJYDPGQ/0HzSeHZfi3A==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
+2 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.19.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -106,7 +106,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.5.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -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<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);
};
// --- 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);
@@ -9,18 +9,14 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean
console.error(`PDF normalization error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF or is password protected',
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
if (!pdfDoc.isAuthenticated) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is password protected',
});
}
pdfDoc.removeProtection({ ignorePermissions: true });
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
}
pdfDoc.flattenLayers();