diff --git a/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx b/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx index 8a9c5be2d..b781bbb51 100644 --- a/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx +++ b/apps/remix/app/components/forms/editor/editor-content-image-settings.tsx @@ -113,6 +113,12 @@ export const EditorContentImageSettings = ({ formId, dataContentId }: EditorCont onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); + + // The dropzone runs its own handler after this one unless the + // event is stopped, and that would open its own picker as well + // as the dialog's. + event.stopPropagation(); + void ContentImageUploadDialog.call({ formId }); } }, diff --git a/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-marquee.ts b/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-marquee.ts index 1e683d740..439eab0e7 100644 --- a/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-marquee.ts +++ b/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-marquee.ts @@ -146,9 +146,20 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope window.removeEventListener('touchcancel', onWindowPointerUp); }; + /** + * Whether the current press started on an empty area of the stage. + * + * Releasing a resize handle without dragging it reaches the stage as a + * click on the stage itself. Only a press which started on the empty page + * makes an empty click, otherwise that release would deselect the item. + */ + let isPressOnEmptyStage = false; + stage.on('mousedown.marquee touchstart.marquee', (e) => { + isPressOnEmptyStage = e.target === stage; + // Do nothing if the pointer is down on a shape. - if (e.target !== stage) { + if (!isPressOnEmptyStage) { return; } @@ -178,7 +189,7 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope return; } - if (e.target === stage) { + if (e.target === stage && isPressOnEmptyStage) { onEmptyClickRef.current(); } }); diff --git a/assets/content-alignment.pdf b/assets/content-alignment.pdf index 26b6b82b3..ebb847dcb 100644 Binary files a/assets/content-alignment.pdf and b/assets/content-alignment.pdf differ diff --git a/packages/app-tests/constants/content-alignment-pdf.ts b/packages/app-tests/constants/content-alignment-pdf.ts index 34c0cc0b2..0c04aba05 100644 --- a/packages/app-tests/constants/content-alignment-pdf.ts +++ b/packages/app-tests/constants/content-alignment-pdf.ts @@ -46,9 +46,18 @@ export const CONTENT_ALIGNMENT_PAGES = { } as const; /** - * The page is A4, so a square in page units needs the width scaled by this. + * The size of the PDF's pages in points, the unit contents are drawn in. + * + * Positions are percentages of the page's width and height, which are not the + * same length, so anything which has to keep its shape (a square, a rotation) + * is worked out in points. */ -const A4_ASPECT = 210 / 297; +const PAGE_SIZE = { width: 595.92, height: 842.88 }; + +const toPointsX = (percent: number) => (percent / 100) * PAGE_SIZE.width; +const toPointsY = (percent: number) => (percent / 100) * PAGE_SIZE.height; +const toPercentX = (points: number) => (points / PAGE_SIZE.width) * 100; +const toPercentY = (points: number) => (points / PAGE_SIZE.height) * 100; /** * The grid printed on every page of the PDF, as percentages of the page. @@ -59,7 +68,8 @@ export const CONTENT_ALIGNMENT_GRID = { columnWidth: 28, columnGap: 1, rowHeight: 7, - rowGap: 1, + // Holds each box's label, which the PDF prints just above the box. + rowGap: 2, }; const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => { @@ -73,33 +83,78 @@ const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: n }; }; +/** + * The box of a grid cell as printed on the PDF, for anything else placed on + * it, e.g. the seed's signature field. + */ +export const getContentAlignmentCell = cell; + type Box = ReturnType; /** - * A box half the size of a cell, placed at the cell's center. + * The box of a content which can be rotated, i.e. a shape or an image. + */ +type RotatableBox = Box & { rotation?: number }; + +/** + * A box half the size of a cell, rotated by `rotation` and centered in the + * cell. * * Contents rotate about their top left corner (see `ZContentRotationSchema`), - * so a rotated box placed at a cell's top left would sweep outside the cell. - * Starting from the center keeps every rotation within the cell. + * so that corner is placed wherever the rotation needs it for the box's center + * to land on the cell's center. Throws if the rotated box would not fit in the + * cell, since it would then reach into the neighbouring cells. */ -const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => { +const rotatedHalfCell = ( + row: number, + column: number, + rotation: number, + options: { rows?: number; columns?: number } = {}, +): RotatableBox => { const box = cell(row, column, options); + const width = box.width / 2; + const height = box.height / 2; + + // Half the box's width and height, in points. + const halfWidth = toPointsX(width) / 2; + const halfHeight = toPointsY(height) / 2; + + const radians = (rotation * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + // Where the box's center ends up, relative to its top left corner. + const centerX = halfWidth * cos - halfHeight * sin; + const centerY = halfWidth * sin + halfHeight * cos; + + // How far the rotated box reaches from its center along each axis. + const reachX = Math.abs(halfWidth * cos) + Math.abs(halfHeight * sin); + const reachY = Math.abs(halfWidth * sin) + Math.abs(halfHeight * cos); + + const cellWidth = toPointsX(box.width); + const cellHeight = toPointsY(box.height); + + if (reachX * 2 > cellWidth || reachY * 2 > cellHeight) { + throw new Error(`A half size box rotated by ${rotation} degrees does not fit in cell ${row}, ${column}`); + } + return { - positionX: box.positionX + box.width / 2, - positionY: box.positionY + box.height / 2, - width: box.width / 2, - height: box.height / 2, + positionX: box.positionX + toPercentX(cellWidth / 2 - centerX), + positionY: box.positionY + toPercentY(cellHeight / 2 - centerY), + width, + height, + rotation, }; }; /** - * A square box in page units, as tall as the given number of rows. + * A square box as tall as the given number of rows. */ -const square = (row: number, column: number, rows: number) => { +const square = (row: number, column: number, rows: number): Box => { const box = cell(row, column, { rows }); - return { ...box, width: box.height * A4_ASPECT }; + return { ...box, width: toPercentX(toPointsY(box.height)) }; }; // --- Builders --- @@ -108,22 +163,14 @@ type Overrides = Partial>; const text = (box: Box, meta: Overrides & { text: string }): ContentTestData => ({ page: CONTENT_ALIGNMENT_PAGES.text, - contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, rotation: 0, ...box, ...meta }, + contentMeta: { type: EnvelopeContentType.TEXT, page: CONTENT_ALIGNMENT_PAGES.text, ...box, ...meta }, }); type LinePoints = { x1: number; y1: number; x2: number; y2: number }; -const horizontal = (box: Box): LinePoints => ({ - x1: box.positionX, - x2: box.positionX + box.width, - y1: box.positionY + box.height / 2, - y2: box.positionY + box.height / 2, -}); +type LineMeta = { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' }; -const line = ( - points: LinePoints, - meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' }, -): ContentTestData => ({ +const line = (points: LinePoints, meta: LineMeta): ContentTestData => ({ page: CONTENT_ALIGNMENT_PAGES.line, contentMeta: { type: EnvelopeContentType.LINE, @@ -135,7 +182,21 @@ const line = ( }, }); -const rectangle = (box: Box, meta: Overrides = {}): ContentTestData => ({ +/** + * A horizontal line across the middle of a box. + * + * Round line ends reach past the line's points by half its width, so the + * points are pulled in by that much to keep the whole line within its box. + * Otherwise thick lines in neighbouring columns join across the gap. + */ +const horizontalLine = (box: Box, meta: LineMeta): ContentTestData => { + const inset = toPercentX(meta.strokeWidth / 2); + const y = box.positionY + box.height / 2; + + return line({ x1: box.positionX + inset, y1: y, x2: box.positionX + box.width - inset, y2: y }, meta); +}; + +const rectangle = (box: RotatableBox, meta: Overrides = {}): ContentTestData => ({ page: CONTENT_ALIGNMENT_PAGES.shape, contentMeta: { type: EnvelopeContentType.SHAPE, @@ -155,7 +216,6 @@ const highlight = (box: Box, meta: Overrides = {}): Conte contentMeta: { type: EnvelopeContentType.HIGHLIGHT, page: CONTENT_ALIGNMENT_PAGES.highlight, - rotation: 0, color: '#ffe600', fillOpacity: 0.4, ...box, @@ -163,7 +223,7 @@ const highlight = (box: Box, meta: Overrides = {}): Conte }, }); -const image = (box: Box, meta: Overrides = {}): ContentTestData => ({ +const image = (box: RotatableBox, meta: Overrides = {}): ContentTestData => ({ page: CONTENT_ALIGNMENT_PAGES.image, image: 'logo', contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta }, @@ -184,30 +244,26 @@ const TEXT_CONTENTS: ContentTestData[] = [ text(cell(3, 1), { text: 'Size 14', fontSize: 14, color: '#d00000' }), text(cell(3, 2), { text: 'Size 24 cropped to the box', fontSize: 24 }), - text(cell(4, 0), { text: 'Two lines of text wrap here', lineHeight: 1.6 }), + // Long enough to wrap, so the line height shows in the gap between the lines. + text(cell(4, 0), { text: 'Two lines of text, wrapped and spaced apart by the line height', lineHeight: 1.6 }), text(cell(4, 1), { text: 'Spaced', letterSpacing: 3 }), text(cell(4, 2, { rows: 2 }), { text: 'A longer paragraph of text which wraps onto several lines within its box, centered both ways.', textAlign: 'center', verticalAlign: 'middle', }), - - // Rotation pivots on the box's top left corner, so a 90 degree rotation - // hangs down the left edge of its cell. Inset it so it stays inside. - text(cell(6, 0, { rows: 2 }), { text: 'Rotated 15', rotation: 15 }), - text({ ...cell(6, 1, { rows: 2 }), positionX: cell(6, 1).positionX + 8 }, { text: 'Rotated 90', rotation: 90 }), ]; // --- Page 2: Lines --- const LINE_CONTENTS: ContentTestData[] = [ - line(horizontal(cell(0, 0)), { strokeWidth: 1, strokeStyle: 'solid' }), - line(horizontal(cell(0, 1)), { strokeWidth: 2, strokeStyle: 'dashed' }), - line(horizontal(cell(0, 2)), { strokeWidth: 3, strokeStyle: 'dotted' }), + horizontalLine(cell(0, 0), { strokeWidth: 1, strokeStyle: 'solid' }), + horizontalLine(cell(0, 1), { strokeWidth: 2, strokeStyle: 'dashed' }), + horizontalLine(cell(0, 2), { strokeWidth: 3, strokeStyle: 'dotted' }), - line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }), - line(horizontal(cell(1, 1)), { strokeWidth: 5 }), - line(horizontal(cell(1, 2)), { strokeWidth: 10 }), + horizontalLine(cell(1, 0), { strokeWidth: 0.5 }), + horizontalLine(cell(1, 1), { strokeWidth: 5 }), + horizontalLine(cell(1, 2), { strokeWidth: 10 }), // Diagonals across the full width, corner to corner. ...(() => { @@ -313,11 +369,10 @@ const SHAPE_CONTENTS: ContentTestData[] = [ rectangle(square(2, 0, 2), { strokeWidth: 2 }), rectangle(cell(2, 1, { columns: 2 }), { strokeWidth: 1, fillColor: '#e5e7eb', fillOpacity: 1 }), - // Rotation pivots on the box's top left corner. Half sized boxes placed - // towards the right of their cells keep the swept shape within the cell. - rectangle(halfCell(4, 0, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000', rotation: 15 }), - rectangle(halfCell(4, 1, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55', rotation: 45 }), - rectangle(halfCell(4, 2, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa', rotation: 90 }), + // Each rotated shape is centered in its cell, clear of the stacking row. + rectangle(rotatedHalfCell(4, 0, 15, { rows: 2 }), { strokeWidth: 2, strokeColor: '#d00000' }), + rectangle(rotatedHalfCell(4, 1, 45, { rows: 2 }), { strokeWidth: 2, strokeColor: '#00aa55' }), + rectangle(rotatedHalfCell(4, 2, 90, { rows: 2 }), { strokeWidth: 2, strokeColor: '#0055aa' }), // Stacking. Each cell holds two overlapping filled rectangles: the first // (top left) and the second (offset to the bottom right). The rectangles @@ -342,9 +397,6 @@ const HIGHLIGHT_CONTENTS: ContentTestData[] = [ highlight(cell(1, 2), { fillOpacity: 1 }), highlight(cell(2, 0, { columns: 3 })), - - highlight(halfCell(3, 0, { rows: 2 }), { rotation: 15 }), - highlight(halfCell(3, 1, { rows: 2 }), { rotation: 45 }), ]; // --- Page 5: Images --- @@ -357,14 +409,13 @@ const IMAGE_CONTENTS: ContentTestData[] = [ image(cell(3, 0, { columns: 3, rows: 2 })), - // Rotation pivots on the box's top left corner. Half sized boxes placed - // towards the right of their cells keep the swept image within the cell. - image(halfCell(5, 0, { rows: 2 }), { rotation: 15 }), - image(halfCell(5, 1, { rows: 2 }), { rotation: 45 }), - image(halfCell(5, 2, { rows: 2 }), { rotation: 90 }), + // Each rotated image is centered in its cell, clear of its neighbours. + image(rotatedHalfCell(5, 0, 15, { rows: 2 })), + image(rotatedHalfCell(5, 1, 45, { rows: 2 })), + image(rotatedHalfCell(5, 2, 90, { rows: 2 })), - image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }), - image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }), + image(rotatedHalfCell(7, 0, 180, { rows: 2 })), + image(rotatedHalfCell(7, 1, 270, { rows: 2 })), ]; export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [ diff --git a/packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts b/packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts index 0e0056b9b..f37b2aa0b 100644 --- a/packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts +++ b/packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts @@ -25,10 +25,11 @@ test.describe.configure({ * transform. */ -const textMeta = (overrides: Partial = {}): TEnvelopeContentMetaInput => ({ +type TextMetaInput = Extract; + +const textMeta = (overrides: Partial> = {}): TEnvelopeContentMetaInput => ({ type: EnvelopeContentType.TEXT, page: 1, - rotation: 0, positionX: 10, positionY: 10, width: 20, @@ -67,7 +68,7 @@ const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'docume const envelopeRecord = type === 'document' ? await seedBlankDocument(user, team.id, { internalVersion: 2 }) - : await seedBlankTemplate(user, team.id, { internalVersion: 2 }); + : await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } }); await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` }); diff --git a/packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts b/packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts index ff97f0426..bf2a56c91 100644 --- a/packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts +++ b/packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts @@ -29,7 +29,6 @@ test.describe.configure({ const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({ type: EnvelopeContentType.TEXT, page: 1, - rotation: 0, positionX: 10, positionY: 10, width: 20, diff --git a/packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts b/packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts index 3bdf58b01..6812c58b0 100644 --- a/packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts @@ -179,7 +179,6 @@ const buildAndDistributeEnvelopeWithContents = async ({ : { type: EnvelopeContentType.TEXT, page: 1, - rotation: 0, zIndex: 0, positionX: 10, positionY: 10, diff --git a/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts b/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts index ce7c1a81a..d35977800 100644 --- a/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts +++ b/packages/app-tests/e2e/api/v2/envelope-create-contents.spec.ts @@ -16,7 +16,7 @@ import type { } from '@documenso/trpc/server/envelope-router/create-envelope.types'; import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; import { type APIRequestContext, expect, test } from '@playwright/test'; -import { EnvelopeType, type Team } from '@prisma/client'; +import { EnvelopeType, FieldType, RecipientRole, type Team } from '@prisma/client'; import { createGifLabelledAsPng, createImageFile, type TestImageFile } from '../../fixtures/contents'; @@ -378,6 +378,62 @@ test.describe('Create envelope with contents', () => { }); } + // A file is picked by its position counted from 0, so any other number is + // refused rather than read as some other file (-1 used to mean the last). + for (const { identifier, description } of [ + { identifier: -1, description: 'a negative file index' }, + { identifier: 0.5, description: 'a fractional file index' }, + ]) { + test(`rejects a content on ${description}`, async ({ request }) => { + const externalId = `e2e-contents-bad-index-${nanoid()}`; + + const res = await createEnvelopeWithContents({ + request, + token, + payload: { externalId }, + contents: [{ identifier, contentMeta: textMeta('Bad index') }], + }); + + expect(res.status()).toBe(400); + expect(await prisma.envelope.count({ where: { externalId } })).toBe(0); + }); + + test(`rejects a field on ${description}`, async ({ request }) => { + const externalId = `e2e-fields-bad-index-${nanoid()}`; + + const res = await createEnvelopeWithContents({ + request, + token, + payload: { + externalId, + recipients: [ + { + email: `signer-${nanoid()}@test.documenso.com`, + name: 'Signer', + role: RecipientRole.SIGNER, + fields: [ + { + type: FieldType.SIGNATURE, + identifier, + page: 1, + positionX: 10, + positionY: 10, + width: 10, + height: 5, + fieldMeta: { type: 'signature', overflow: 'crop' }, + }, + ], + }, + ], + }, + contents: [], + }); + + expect(res.status()).toBe(400); + expect(await prisma.envelope.count({ where: { externalId } })).toBe(0); + }); + } + test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => { const externalId = `e2e-contents-format-${nanoid()}`; const image = createGifLabelledAsPng(`format-${nanoid()}.png`); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-handle-click.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-handle-click.spec.ts new file mode 100644 index 000000000..aef499dcf --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-handle-click.spec.ts @@ -0,0 +1,107 @@ +import { expect, type Page, test } from '@playwright/test'; +import type Konva from 'konva'; + +import { + getPageCanvas, + interactWithCanvasPastActionBar, + placeContentOnPdf, + selectEditorTab, +} from '../fixtures/contents'; +import { clickAddMyselfButton, clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor'; +import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva'; + +/** + * A selected item's resize handles reach a little into the item, so they are + * easy to grab. Pressing and releasing one without dragging used to count as + * a click on the empty page, which deselected the item. + */ + +const getCanvasBox = async (page: Page) => { + const box = await getPageCanvas(page).boundingBox(); + + if (!box) { + throw new Error('The page canvas is not rendered'); + } + + return box; +}; + +/** + * Press and release the selection's bottom middle resize handle without + * moving, just inside the selected item. + */ +const clickResizeHandle = async (page: Page) => { + const handle = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva; + + const anchor = konva.stages.find((stage) => stage.attrs.id === 'page-1')?.findOne('.bottom-center'); + + if (!anchor) { + throw new Error('The selection has no bottom middle resize handle'); + } + + const rect = anchor.getClientRect(); + + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }); + + const canvas = await getCanvasBox(page); + + await interactWithCanvasPastActionBar(page, async () => { + await page.mouse.move(canvas.x + handle.x + 10, canvas.y + handle.y - 5); + await page.mouse.down(); + await page.mouse.up(); + }); +}; + +const expectSelectionKept = async (page: Page) => { + // Nothing should change, so give anything which would deselect a moment. + await page.waitForTimeout(500); + + expect(await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1); +}; + +/** + * An empty area of the page still deselects. + */ +const expectEmptyClickDeselects = async (page: Page) => { + const canvas = await getCanvasBox(page); + + await page.mouse.click(canvas.x + 400, canvas.y + 700); + + await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(0); +}; + +test('clicking a content resize handle without dragging keeps the content selected', async ({ page }) => { + await openDocumentEnvelopeEditor(page); + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(getPageCanvas(page)).toBeVisible(); + await selectEditorTab(page, 'Contents'); + + // A newly placed content is selected. + await placeContentOnPdf(page, 'Text', { x: 160, y: 160 }); + await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1); + + await clickResizeHandle(page); + await expectSelectionKept(page); + + await expectEmptyClickDeselects(page); +}); + +test('clicking a field resize handle without dragging keeps the field selected', async ({ page }) => { + await openDocumentEnvelopeEditor(page); + await clickAddMyselfButton(page); + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(getPageCanvas(page)).toBeVisible(); + + // A newly placed field is selected. + await page.getByRole('button', { name: 'Signature', exact: true }).click(); + await getPageCanvas(page).click({ position: { x: 160, y: 160 } }); + await expect.poll(async () => await getKonvaTransformerNodeCountForPage(page, 1)).toBe(1); + + await clickResizeHandle(page); + await expectSelectionKept(page); + + await expectEmptyClickDeselects(page); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts index 7053bb171..c03e29bd1 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts @@ -71,7 +71,9 @@ test('a selected content survives zooming', async ({ page }) => { await placeContentOnPdf(root, 'Text', { x: 160, y: 160 }); - await selectContentOnCanvas(root, { x: 170, y: 170 }); + // Clicked in the middle, where it was placed. Placing already selects it, + // and near its edges a click lands on the handles of that selection. + await selectContentOnCanvas(root, { x: 160, y: 160 }); await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1); await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible(); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts index c7f80df0e..5ad6cd465 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts @@ -2,7 +2,7 @@ import { APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/en import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { prisma } from '@documenso/prisma'; import { createCanvas } from '@napi-rs/canvas'; -import { expect, type Page, test } from '@playwright/test'; +import { expect, type FileChooser, type Page, test } from '@playwright/test'; import { getContentActionButton, @@ -52,6 +52,15 @@ const uploadThroughDialog = async (page: Page, buffer: Buffer) => { await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer }); }; +/** + * The upload dialog, including while it is hidden. + * + * While the native picker is open the dialog hides its content and shows only + * its overlay. Role locators skip hidden elements unless told otherwise, so + * without `includeHidden` an open dialog would look closed. + */ +const getUploadDialog = (page: Page) => page.getByRole('dialog', { includeHidden: true }); + test('a dropped file which cannot be used shows why', async ({ page }) => { await openContentsEditor(page); @@ -147,53 +156,106 @@ test('the upload lands on the content it was opened for', async ({ page }) => { }); /** - * Dismissing the native picker leaves the dialog open on its "Choose image" - * button. Pressing that must reopen the picker even though the dialog's - * state has not changed. + * Dismissing the native picker closes the dialog, so picking again means + * opening it again. That must start a fresh picker rather than reuse the + * dismissed dialog's state. */ -test('the picker can be reopened from the dialog after being dismissed', async ({ page }) => { +test('the picker can be reopened after being dismissed', async ({ page }) => { const surface = await openContentsEditor(page); await placeContentOnPdf(page, 'Image', { x: 200, y: 200 }); await waitForContentsAutosave(surface); - // Open the picker and leave it unanswered: the dialog stays on "picking". + const dialog = getUploadDialog(page); + + // Nothing matches before the dialog opens, so what matches below is the + // upload dialog rather than some other hidden dialog. + await expect(dialog).toHaveCount(0); + const firstChooser = page.waitForEvent('filechooser'); await getContentActionButton(page, 'Upload image').click(); await firstChooser; - await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible(); + await expect(dialog).toHaveCount(1); + + // Playwright cannot dismiss a native picker, but the browser reports a + // dismissal as a `cancel` event on the input. + await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel'); + await expect(dialog).toHaveCount(0); - // Pressing the dialog's button opens a fresh picker. const secondChooser = page.waitForEvent('filechooser'); - await page.getByRole('button', { name: 'Choose image' }).click(); + await getContentActionButton(page, 'Upload image').click(); await (await secondChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() }); await expect(getContentActionButton(page, 'Replace image')).toBeVisible(); }); +/** + * The drop area opens the upload dialog from the keyboard too. Only the + * dialog's picker may open: the dropzone used to open its own picker as well, + * so a keyboard user got two. + */ +for (const key of ['Enter', 'Space']) { + test(`pressing ${key} on the drop area opens a single file picker`, async ({ page }) => { + await openContentsEditor(page); + + await placeContentOnPdf(page, 'Image', { x: 200, y: 200 }); + + const choosers: FileChooser[] = []; + + page.on('filechooser', (chooser) => choosers.push(chooser)); + + await page.locator('div[role="button"]', { hasText: 'Click to upload or drag and drop' }).focus(); + await page.keyboard.press(key); + + // Give a second picker time to open, as it used to. + await expect.poll(() => choosers.length).toBeGreaterThan(0); + await page.waitForTimeout(1000); + + expect(choosers).toHaveLength(1); + + // The one picker is the upload dialog's, so the file goes through it. + expect(await choosers[0].element().getAttribute('id')).toMatch(/^content-image-input-/); + + await choosers[0].setFiles({ name: 'logo.png', mimeType: 'image/png', buffer: await createPng() }); + + await expect(getContentActionButton(page, 'Replace image')).toBeVisible(); + }); +} + test('cancelling the picker closes the dialog', async ({ page }) => { const surface = await openContentsEditor(page); await placeContentOnPdf(page, 'Image', { x: 200, y: 200 }); await waitForContentsAutosave(surface); + const dialog = getUploadDialog(page); + + // Nothing matches before the dialog opens, so what matches below is the + // upload dialog rather than some other hidden dialog. + await expect(dialog).toHaveCount(0); + const chooser = page.waitForEvent('filechooser'); await getContentActionButton(page, 'Upload image').click(); await chooser; - await expect(page.getByRole('dialog')).toBeVisible(); + // While the picker is open only the dialog's overlay shows, which still + // blocks the editor. A trial click checks that without clicking. + await expect(dialog).toHaveCount(1); + await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true, timeout: 1500 })).rejects.toThrow(); // Playwright cannot dismiss a native picker, but the browser reports a // dismissal as a `cancel` event on the input, which is what the dialog // listens for. await page.locator('input[id^="content-image-input-"]').dispatchEvent('cancel'); - await expect(page.getByRole('dialog')).toHaveCount(0); + // The dialog is gone and the editor can be used again. + await expect(dialog).toHaveCount(0); + await getPageCanvas(page).click({ position: { x: 50, y: 50 }, trial: true }); await expect(getContentActionButton(page, 'Upload image')).toBeVisible(); }); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts index 93db608e2..e826abf8a 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts @@ -127,7 +127,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { await uploadImage(root, wide); await waitForUploadToLand(root); - await expect(root.getByText('wide-logo.png')).toBeVisible(); + // The settings panel shows the image's size and dimensions. await expect(root.getByText('400 * 200')).toBeVisible(); const [withWide] = await getContentGroupsForPage(root); @@ -157,7 +157,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { await uploadImage(root, tall); await waitForUploadToLand(root); - await expect(root.getByText('tall-logo.jpg')).toBeVisible(); + await expect(root.getByText('100 * 300')).toBeVisible(); const [withTall] = await getContentGroupsForPage(root); @@ -224,7 +224,7 @@ const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => { await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer }); - await expect(root.getByText('huge-logo.png')).toBeVisible(); + await expect(root.getByText('2000 * 2000')).toBeVisible(); await expect(getContentActionButton(root, 'Replace image')).toBeVisible(); const pageSize = await getPageSize(root); @@ -333,7 +333,7 @@ const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => { await uploadImage(root, logo); await waitForUploadToLand(root); - await expect(root.getByText('logo.png')).toBeVisible(); + await expect(root.getByText('120 * 60')).toBeVisible(); await waitForContentsAutosave(surface); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts index 1741e0483..97e0fd48f 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts @@ -122,7 +122,12 @@ test('duplicate is hidden at the limit and existing contents stay removable', as const [content] = await getContentGroupsForPage(root); const { scale } = await getPageSize(root); - await selectContentOnCanvas(root, { x: (content.rect.x + 4) * scale, y: (content.rect.y + 4) * scale }); + // Clicked in the middle. Placing already selects it, and near its edges a + // click lands on the handles of that selection. + await selectContentOnCanvas(root, { + x: (content.rect.x + content.rect.width / 2) * scale, + y: (content.rect.y + content.rect.height / 2) * scale, + }); // Duplicating would add a content, so it is gone; removing is still offered. await expect(getContentActionButton(root, 'Duplicate')).toHaveCount(0); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts index 96886d0ea..487167a4b 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts @@ -31,7 +31,6 @@ const seedTiedContents = async (envelopeId: string) => { contentMeta: ZEnvelopeContentMetaSchema.parse({ type: EnvelopeContentType.TEXT, page: 1, - rotation: 0, positionX, positionY: 20, width: 10, diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts index 805985c14..86a8ab652 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts @@ -48,7 +48,6 @@ const seedContent = async (envelopeId: string, envelopeItemId: string, page: num contentMeta: ZEnvelopeContentMetaSchema.parse({ type: EnvelopeContentType.TEXT, page, - rotation: 0, positionX: 10, positionY: 10, width: 20, @@ -71,6 +70,10 @@ test('does not recreate contents removed by a pdf replacement', async ({ page }) const surface = await openDocumentEnvelopeEditor(page); const { root, envelopeId } = surface; + if (!envelopeId) { + throw new Error('Expected the document editor surface to have an envelopeId'); + } + // A three page PDF so a content can live on a page the replacement drops. await replacePdf(root, multiPagePdfBuffer, 'multi-page.pdf'); diff --git a/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts b/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts index 312748b9a..e99bc728c 100644 --- a/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts @@ -25,7 +25,6 @@ const seedStaticContents = async (envelopeId: string, envelopeItemId: string) => contentMeta: ZEnvelopeContentMetaSchema.parse({ type: EnvelopeContentType.TEXT, page: 1, - rotation: 0, zIndex: 0, positionX: 10, positionY: 10, diff --git a/packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts b/packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts index 7e4780658..d9219ff54 100644 --- a/packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts @@ -2,7 +2,7 @@ import { createDataContentImage } from '@documenso/lib/server-only/data-content/ import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta'; import { generateDatabaseId } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankDocument, seedPendingDocument } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; import { createCanvas } from '@napi-rs/canvas'; import { expect, test } from '@playwright/test'; @@ -13,6 +13,10 @@ import { apiSignin } from '../fixtures/authentication'; * A page and everything drawn on it appear together. The page image is held * back until the content images are loaded too, so a page is never shown in a * half drawn state. + * + * Contents are only drawn in the browser while the document is a draft. Once + * it is sent they are part of the PDF, so the signing page loads no content + * images at all. */ const IMAGE_LOAD_MS = 4000; @@ -20,16 +24,13 @@ const IMAGE_LOAD_MS = 4000; test('a page waiting on its content images shows a loader', async ({ page }) => { const { user, team } = await seedUser(); - const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 }); + const document = await seedBlankDocument(user, team.id, { internalVersion: 2 }); const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: document.id }, - include: { envelopeItems: true, recipients: true }, + include: { envelopeItems: true }, }); - // The seeded fields carry no `fieldMeta`, which the v2 signer rejects. - await prisma.field.deleteMany({ where: { envelopeId: envelope.id } }); - const png = await createCanvas(60, 30).encode('png'); const dataContent = await createDataContentImage({ @@ -61,7 +62,11 @@ test('a page waiting on its content images shows a loader', async ({ page }) => await route.continue(); }); - await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` }); + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${envelope.id}/edit?step=addFields`, + }); const pageLoader = page.getByTestId('page-loader').first(); const pageImage = page.locator('img[alt=""]').first(); diff --git a/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts b/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts index 80dcd9ff8..c7030cfa4 100644 --- a/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts @@ -21,7 +21,6 @@ const multiPagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/fi const textMetaOnPage = (page: number) => ({ type: EnvelopeContentType.TEXT, page, - rotation: 0, positionX: 10, positionY: 10, width: 20, @@ -95,43 +94,6 @@ test('replacing a PDF drops contents which fall beyond the new page count', asyn expect(contentMeta.page).toBe(1); }); -test('replacing a PDF keeps a content which has no page of its own', async () => { - const { user, team } = await seedUser(); - - const document = await seedBlankDocument(user, team.id, { internalVersion: 2 }); - - await replacePdf(document.id, multiPagePdf, 'multi-page.pdf'); - - const envelope = await prisma.envelope.findUniqueOrThrow({ - where: { id: document.id }, - include: { envelopeItems: true }, - }); - - // A content without a page belongs to the first page, so it always fits. - await prisma.envelopeContent.create({ - data: { - id: generateDatabaseId('envelope_content'), - envelopeId: envelope.id, - envelopeItemId: envelope.envelopeItems[0].id, - contentMeta: ZEnvelopeContentMetaSchema.parse({ - type: EnvelopeContentType.TEXT, - rotation: 0, - positionX: 10, - positionY: 10, - width: 20, - height: 6, - text: 'No page', - }), - }, - }); - - await replacePdf(document.id, singlePagePdf, 'single-page.pdf'); - - const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } }); - - expect(contents).toHaveLength(1); -}); - test('replacing a PDF keeps contents which still fit', async () => { const { user, team } = await seedUser(); diff --git a/packages/app-tests/e2e/fixtures/contents.ts b/packages/app-tests/e2e/fixtures/contents.ts index 718f8b7f8..574235152 100644 --- a/packages/app-tests/e2e/fixtures/contents.ts +++ b/packages/app-tests/e2e/fixtures/contents.ts @@ -251,10 +251,13 @@ export const uploadImage = async (root: Page, file: TestImageFile) => { /** * Wait for a successful upload's dialog to close. + * + * Hidden dialogs count too: while its picker is open the dialog hides its + * content, and a dialog stuck like that has not finished. */ export const waitForUploadToLand = async (root: Page) => { await expect(root.getByTestId('content-image-uploading')).toHaveCount(0); - await expect(root.getByRole('dialog')).toHaveCount(0); + await expect(root.getByRole('dialog', { includeHidden: true })).toHaveCount(0); }; /** diff --git a/packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts b/packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts index 99b0a1864..c059e0a4e 100644 --- a/packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts +++ b/packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts @@ -35,7 +35,7 @@ const createPng = async () => { const seedTemplateWithImageContent = async (page: Page) => { const { user, team } = await seedUser(); - const template = await seedBlankTemplate(user, team.id, { internalVersion: 2 }); + const template = await seedBlankTemplate(user, team.id, { createTemplateOptions: { internalVersion: 2 } }); const envelope = await prisma.envelope.findFirstOrThrow({ where: { id: template.id }, diff --git a/packages/app-tests/e2e/user/password.spec.ts b/packages/app-tests/e2e/user/password.spec.ts index 6bee82262..69a4f0eb1 100644 --- a/packages/app-tests/e2e/user/password.spec.ts +++ b/packages/app-tests/e2e/user/password.spec.ts @@ -1,9 +1,12 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, type Page, test } from '@playwright/test'; import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication'; +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + test.use({ storageState: { cookies: [], origins: [] } }); test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => { @@ -14,9 +17,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P password: oldPassword, }); - await page.goto('http://localhost:3000/signin'); + await page.goto(`${WEBAPP_BASE_URL}/signin`); await page.getByRole('link', { name: 'Forgot your password?' }).click(); - await expect(page).toHaveURL('http://localhost:3000/forgot-password'); + await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/forgot-password`); await page.getByRole('textbox', { name: 'Email' }).click(); await page.getByRole('textbox', { name: 'Email' }).fill(user.email); @@ -37,7 +40,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P }, }); - await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`); + await page.goto(`${WEBAPP_BASE_URL}/reset-password/${foundToken.token}`); // Assert that password cannot be same as old password. await page.getByLabel('Password', { exact: true }).fill(oldPassword); @@ -131,9 +134,9 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page: await page.context().clearCookies(); - await page.goto('http://localhost:3000/signin'); + await page.goto(`${WEBAPP_BASE_URL}/signin`); await page.getByRole('link', { name: 'Forgot your password?' }).click(); - await expect(page).toHaveURL('http://localhost:3000/forgot-password'); + await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/forgot-password`); await page.getByRole('textbox', { name: 'Email' }).fill(user.email); await page.getByRole('button', { name: 'Reset Password' }).click(); await expect(page.locator('body')).toContainText('Reset email sent', { @@ -144,7 +147,7 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page: where: { userId: user.id }, }); - await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`); + await page.goto(`${WEBAPP_BASE_URL}/reset-password/${foundToken.token}`); await page.getByLabel('Password', { exact: true }).fill(newPassword); await page.getByLabel('Repeat Password').fill(newPassword); await page.getByRole('button', { name: 'Reset Password' }).click(); @@ -152,8 +155,8 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page: await page.context().addCookies(initialCookies); - await page.goto('http://localhost:3000/settings/profile'); - await expect(page).toHaveURL('http://localhost:3000/signin'); + await page.goto(`${WEBAPP_BASE_URL}/settings/profile`); + await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/signin`); expect(await checkSessionValid(page)).toBe(false); @@ -197,7 +200,7 @@ test('[USER] password update invalidates other sessions but keeps current', asyn expect(await checkSessionValid(page)).toBe(true); - await page.goto('http://localhost:3000/settings/security'); + await page.goto(`${WEBAPP_BASE_URL}/settings/security`); await page.getByLabel('Current password').fill(oldPassword); await page.getByLabel('New password').fill(newPassword); await page.getByLabel('Repeat password').fill(newPassword); @@ -208,13 +211,13 @@ test('[USER] password update invalidates other sessions but keeps current', asyn await page.context().clearCookies(); await page.context().addCookies(initialCookies); - await page.goto('http://localhost:3000/settings/profile'); - await expect(page).toHaveURL('http://localhost:3000/signin'); + await page.goto(`${WEBAPP_BASE_URL}/settings/profile`); + await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/signin`); expect(await checkSessionValid(page)).toBe(false); await page.context().clearCookies(); await page.context().addCookies(finalCookies); - await page.goto('http://localhost:3000/settings/security'); - await expect(page).toHaveURL('http://localhost:3000/settings/security'); + await page.goto(`${WEBAPP_BASE_URL}/settings/security`); + await expect(page).toHaveURL(`${WEBAPP_BASE_URL}/settings/security`); expect(await checkSessionValid(page)).toBe(true); }); diff --git a/packages/app-tests/visual-regression/content-alignment-pdf-0.png b/packages/app-tests/visual-regression/content-alignment-pdf-0.png new file mode 100644 index 000000000..053bc5f4d Binary files /dev/null and b/packages/app-tests/visual-regression/content-alignment-pdf-0.png differ diff --git a/packages/app-tests/visual-regression/content-alignment-pdf-1.png b/packages/app-tests/visual-regression/content-alignment-pdf-1.png new file mode 100644 index 000000000..56a6dd5a2 Binary files /dev/null and b/packages/app-tests/visual-regression/content-alignment-pdf-1.png differ diff --git a/packages/app-tests/visual-regression/content-alignment-pdf-2.png b/packages/app-tests/visual-regression/content-alignment-pdf-2.png new file mode 100644 index 000000000..b50d3caee Binary files /dev/null and b/packages/app-tests/visual-regression/content-alignment-pdf-2.png differ diff --git a/packages/app-tests/visual-regression/content-alignment-pdf-3.png b/packages/app-tests/visual-regression/content-alignment-pdf-3.png new file mode 100644 index 000000000..9c8e5f0ec Binary files /dev/null and b/packages/app-tests/visual-regression/content-alignment-pdf-3.png differ diff --git a/packages/app-tests/visual-regression/content-alignment-pdf-4.png b/packages/app-tests/visual-regression/content-alignment-pdf-4.png new file mode 100644 index 000000000..3d40f6cfe Binary files /dev/null and b/packages/app-tests/visual-regression/content-alignment-pdf-4.png differ diff --git a/packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts b/packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts index 7b5f041ce..e285468bd 100644 --- a/packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts +++ b/packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts @@ -40,7 +40,7 @@ const rectangle = { describe('generatePartialSignedPdf', () => { it('produces a valid PDF when there is nothing to draw', async () => { - const output = await generatePartialSignedPdf({ pdfData: examplePdf }); + const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [] }); const pdf = await PDF.load(output); @@ -48,7 +48,7 @@ describe('generatePartialSignedPdf', () => { }); it('draws the contents onto the page', async () => { - const output = await generatePartialSignedPdf({ pdfData: examplePdf, contents: [rectangle] }); + const output = await generatePartialSignedPdf({ pdfData: examplePdf, fields: [], contents: [rectangle] }); // The overlay is added as a new XObject, so the file must grow and must // still be a valid single page PDF. diff --git a/packages/lib/server-only/pdf/insert-page-overlay.test.ts b/packages/lib/server-only/pdf/insert-page-overlay.test.ts index 64ea9910f..395a63246 100644 --- a/packages/lib/server-only/pdf/insert-page-overlay.test.ts +++ b/packages/lib/server-only/pdf/insert-page-overlay.test.ts @@ -8,6 +8,7 @@ import sharp from 'sharp'; import { beforeAll, describe, expect, it } from 'vitest'; import { EnvelopeContentShapeType, EnvelopeContentType } from '../../types/envelope-content-meta'; +import type { ContentImageSource } from '../../universal/content-renderer/content-renderer'; import { insertPageOverlays } from './insert-page-overlay'; /** @@ -38,6 +39,7 @@ const RED_RECTANGLE_OVERLAY: Overlay = { zIndex: 0, rotation: 0, ...RECT, + fillColor: null, fillOpacity: 0, strokeWidth: STROKE_WIDTH, strokeColor: '#ff0000', @@ -78,7 +80,9 @@ const createBlueImageOverlay = async (): Promise => { dataContentId: 'data_blue', }, ], - images: new Map([['data_blue', new SkiaImage(Buffer.from(png))]]), + // On the server a skia image stands in for a browser image, passed the + // same way `loadContentImages` passes it. + images: new Map([['data_blue', new SkiaImage(Buffer.from(png)) as unknown as ContentImageSource]]), }; }; diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index f3616fac8..115ee9baf 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -47,7 +47,6 @@ type DocumentToSeed = { export const seedDocuments = async (documents: DocumentToSeed[]) => { await Promise.all( - // eslint-disable-next-line @typescript-eslint/require-await documents.map(async (document, i) => match(document.type) .with(DocumentStatus.DRAFT, async () => @@ -67,7 +66,13 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => { key: i, createDocumentOptions: document.documentOptions, }), - ), + ) + // Ending the match returns the seeding promise for `Promise.all` to + // wait on. Without it the match object itself is returned, and the + // documents are still being written when this resolves. + .otherwise(() => { + throw new Error(`Seeding ${document.type} documents is not supported`); + }), ), ); }; diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index b7858ae88..a5e298a38 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { CONTENT_ALIGNMENT_LOGO_PNG, CONTENT_ALIGNMENT_TEST_CONTENTS, + getContentAlignmentCell, } from '@documenso/app-tests/constants/content-alignment-pdf'; import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf'; import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf'; @@ -551,16 +552,13 @@ export const seedContentAlignmentTestDocument = async ({ const recipientId = recipients[0].id; const envelopeItemId = envelopeItems[0].id; - // The one field, in the spare cell of the text page's rotation row. + // The one field, in the free row below the text page's grid. await prisma.field.create({ data: { type: 'SIGNATURE', fieldMeta: { type: 'signature', overflow: 'auto' }, page: 1, - positionX: 66, - positionY: 60, - width: 28, - height: 7, + ...getContentAlignmentCell(6, 0), recipientId, envelopeItemId, envelopeId: id, diff --git a/packages/trpc/server/envelope-router/create-envelope.types.ts b/packages/trpc/server/envelope-router/create-envelope.types.ts index 61d8274f4..d16919866 100644 --- a/packages/trpc/server/envelope-router/create-envelope.types.ts +++ b/packages/trpc/server/envelope-router/create-envelope.types.ts @@ -53,8 +53,10 @@ export const ZCreateEnvelopePayloadSchema = z.object({ fields: ZEnvelopeFieldAndMetaSchema.and( z.object({ identifier: z - .union([z.string(), z.number()]) - .describe('Either the filename or the index of the file that was uploaded to attach the field to.') + .union([z.string(), z.number().int().min(0)]) + .describe( + 'Either the filename or the zero-based index of the file that was uploaded to attach the field to.', + ) .optional(), page: ZFieldPageNumberSchema, positionX: ZClampedFieldPositionXSchema, @@ -71,8 +73,8 @@ export const ZCreateEnvelopePayloadSchema = z.object({ contents: z .object({ identifier: z - .union([z.string(), z.number()]) - .describe('Either the filename or the index of the file that was uploaded to attach the content to.') + .union([z.string(), z.number().int().min(0)]) + .describe('Either the filename or the zero-based index of the file that was uploaded to attach the content to.') .optional(), contentMeta: ZEnvelopeContentMetaSchema.describe('All the properties of the content being placed.'), imageIndex: z diff --git a/scripts/generate-content-alignment-test-pdf.mjs b/scripts/generate-content-alignment-test-pdf.mjs index 3ea8cdaa4..4192a75b8 100644 --- a/scripts/generate-content-alignment-test-pdf.mjs +++ b/scripts/generate-content-alignment-test-pdf.mjs @@ -22,7 +22,8 @@ const GRID = { columnWidth: 28, columnGap: 1, rowHeight: 7, - rowGap: 1, + // Holds each box's label, which sits just above the box. + rowGap: 2, }; const cellLeft = (column) => GRID.startX + column * (GRID.columnWidth + GRID.columnGap); @@ -34,8 +35,11 @@ function makeCell(row, column, label, { rows = 1, columns = 1 } = {}) { const width = columns * GRID.columnWidth + (columns - 1) * GRID.columnGap; const height = rows * GRID.rowHeight + (rows - 1) * GRID.rowGap; + // The label is anchored to the top of its box and lifted by its own height + // (see `.label`), so it sits in the gap above the box rather than reaching + // into the row above. return ` -
${label}
+
${label}
`; } @@ -72,10 +76,6 @@ function makeTextPage() { content += makeCell(4, 1, 'LETTER_SPACING_3'); content += makeCell(4, 2, 'WRAPPED', { rows: 2 }); - content += makeRowLabel(6, 'ROTATION'); - content += makeCell(6, 0, 'ROTATED_15', { rows: 2 }); - content += makeCell(6, 1, 'ROTATED_90', { rows: 2, columns: 1 }); - return content; } @@ -161,10 +161,6 @@ function makeHighlightPage() { content += makeCell(2, 0, 'HIGHLIGHT OVER THIS PRINTED TEXT', { columns: 3 }); content += `
The quick brown fox jumps over the lazy dog. A highlight is drawn over this line.
`; - content += makeRowLabel(3, 'ROTATION'); - content += makeCell(3, 0, 'ROTATED_15', { rows: 2 }); - content += makeCell(3, 1, 'ROTATED_45', { rows: 2 }); - return content; } @@ -210,7 +206,7 @@ const html = ` .row-label { position: absolute; font-size: 7px; font-weight: bold; color: #666; width: 6%; } .body-text { position: absolute; font-size: 11px; color: #333; } .box { position: absolute; border: 1px solid #999; box-sizing: border-box; } - .label { position: absolute; font-size: 7px; color: #c00; font-weight: bold; z-index: 9999; } + .label { position: absolute; font-size: 7px; line-height: 1; white-space: nowrap; color: #c00; font-weight: bold; z-index: 9999; transform: translateY(calc(-100% - 4px)); }