@@ -247,6 +248,7 @@ export const EnvelopeEditorPreviewPage = () => {
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
+ toolbar={['zoom']}
/>
) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper.tsx
index b8b98dc42..8e2a7d9b6 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper.tsx
@@ -18,6 +18,7 @@ export const EnvelopeEditorRenderProviderWrapper = ({
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
+ contents={envelope.contents}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
index 60d970caf..55036df23 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
@@ -54,6 +54,7 @@ export const EnvelopeEditorUploadPage = () => {
envelope,
setLocalEnvelope,
editorFields,
+ editorContents,
editorConfig,
isEmbedded,
navigateToStep,
@@ -139,7 +140,7 @@ export const EnvelopeEditorUploadPage = () => {
);
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
- onSuccess: ({ data, fields }) => {
+ onSuccess: ({ data, fields, contents }) => {
// Update the envelope item with the new documentDataId.
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
@@ -153,6 +154,12 @@ export const EnvelopeEditorUploadPage = () => {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
+
+ // Same for contents.
+ if (contents) {
+ setLocalEnvelope({ contents });
+ editorContents.resetForm(contents);
+ }
},
});
@@ -273,12 +280,20 @@ export const EnvelopeEditorUploadPage = () => {
(field) => field.envelopeItemId !== envelopeItemId || field.page <= newPageCount,
);
+ // Contents are pruned by the same rule, matching what the server does
+ // for non embedded replacements.
+ const remainingContents = envelope.contents.filter(
+ (content) => content.envelopeItemId !== envelopeItemId || content.contentMeta.page <= newPageCount,
+ );
+
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) => (item.id === envelopeItemId ? { ...item, data } : item)),
fields: remainingFields,
+ contents: remainingContents,
});
editorFields.resetForm(remainingFields);
+ editorContents.resetForm(remainingContents);
return;
}
@@ -326,14 +341,17 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId);
+ const contentsWithoutDeletedItem = envelope.contents.filter((content) => content.envelopeItemId !== envelopeItemId);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
- fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
+ fields: fieldsWithoutDeletedItem,
+ contents: contentsWithoutDeletedItem,
});
- // Reset editor fields.
+ // Reset editor fields and contents.
editorFields.resetForm(fieldsWithoutDeletedItem);
+ editorContents.resetForm(contentsWithoutDeletedItem);
};
/**
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
index 015012a73..57c176b09 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx
@@ -5,7 +5,9 @@ import {
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
+import { renderStaticContents } from '@documenso/lib/universal/content-renderer/render-static-contents';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
+import { areContentsImprinted } from '@documenso/lib/utils/envelope';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { useLingui } from '@lingui/react/macro';
@@ -22,9 +24,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
const analytics = useAnalytics();
const {
+ version,
envelopeStatus,
currentEnvelopeItem,
fields,
+ contents,
+ contentImages,
signatures,
recipients,
getRecipientColorKey,
@@ -32,16 +37,40 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
overrideSettings,
} = useCurrentEnvelopeRender();
+ /**
+ * Once an envelope is sent its contents are inserted into the current PDF, so
+ * rendering them again would double them up.
+ */
+ const shouldRenderContents = !(version === 'current' && areContentsImprinted(envelopeStatus));
+
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
- const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(({ stage, pageLayer }) => {
+ const {
+ stage,
+ pageLayer,
+ konvaContainer,
+ unscaledViewport,
+ fieldsVisibility,
+ applyPageItemsVisibility,
+ hasFailedImage,
+ } = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const { scale, pageNumber } = pageData;
+ /**
+ * A content image which failed to load would render the page differently
+ * to how it was authored, so it is treated like any other render failure.
+ */
+ useEffect(() => {
+ if (shouldRenderContents && hasFailedImage) {
+ setRenderError(true);
+ }
+ }, [shouldRenderContents, hasFailedImage]);
+
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
@@ -105,7 +134,8 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
- color: getRecipientColorKey(field.recipientId),
+ // Muted fields are rendered with the read-only styling.
+ color: fieldsVisibility === 'muted' ? 'readOnly' : getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
});
@@ -131,6 +161,12 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
+ // Render the contents beneath the fields. Contents are static, so they
+ // only need to be rendered when the canvas is created.
+ if (shouldRenderContents) {
+ renderContents(currentPageLayer);
+ }
+
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
@@ -139,6 +175,28 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
currentPageLayer.batchDraw();
};
+ /**
+ * Render the authored contents of the page beneath the fields.
+ */
+ const renderContents = (currentPageLayer: Konva.Layer) => {
+ try {
+ renderStaticContents({
+ contents: contents.filter(
+ (content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
+ ),
+ pageLayer: currentPageLayer,
+ pageWidth: unscaledViewport.width,
+ pageHeight: unscaledViewport.height,
+ scale,
+ mode: overrideSettings?.mode ?? 'edit',
+ images: contentImages.images,
+ });
+ } catch (err) {
+ console.error(err);
+ setRenderError(true);
+ }
+ };
+
/**
* Render fields when they are added or removed
*/
@@ -159,8 +217,10 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
renderFieldOnLayer(field);
});
+ applyPageItemsVisibility();
+
pageLayer.current.batchDraw();
- }, [localPageFields, signaturesByFieldId]);
+ }, [localPageFields, signaturesByFieldId, fieldsVisibility]);
if (!currentEnvelopeItem) {
return null;
@@ -170,6 +230,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
+ fieldsVisibility === 'visible' &&
localPageFields.map((field) => (
{
const { t, i18n } = useLingui();
- const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
+ const { currentEnvelopeItem, contentImages, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -535,10 +537,39 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
}
};
+ /**
+ * Render the contents when required.
+ *
+ * Since contents are imprinted on sent, we still need to render them for direct templates.
+ */
+ const renderContents = () => {
+ if (!pageLayer.current || areContentsImprinted(envelope.status)) {
+ return;
+ }
+
+ try {
+ renderStaticContents({
+ contents: envelope.contents.filter(
+ (content) => content.contentMeta.page === pageNumber && content.envelopeItemId === currentEnvelopeItem?.id,
+ ),
+ pageLayer: pageLayer.current,
+ pageWidth: unscaledViewport.width,
+ pageHeight: unscaledViewport.height,
+ scale,
+ mode: 'sign',
+ images: contentImages.images,
+ });
+ } catch (err) {
+ console.error(err);
+ setRenderError(true);
+ }
+ };
+
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
+ renderContents();
renderFields();
currentPageLayer.batchDraw();
};
@@ -577,6 +608,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
pageLayer.current.destroyChildren();
cachedRenderFields.current.clear();
+ renderContents();
renderFields();
pageLayer.current.batchDraw();
diff --git a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx
index ac38c4272..a2b6b2dc6 100644
--- a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx
+++ b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx
@@ -8,20 +8,40 @@ import { useRef } from 'react';
import type { PDFViewerProps } from './pdf-viewer';
import PDFViewerLazy from './pdf-viewer-lazy';
+import type { EnvelopePdfViewerToolbarControl } from './pdf-viewer-toolbar';
+import { EnvelopePdfViewerToolbar } from './pdf-viewer-toolbar';
export type EnvelopePdfViewerProps = {
/**
* The error message to render when there is an error.
*/
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
+
+ /**
+ * The controls to display in the floating viewer toolbar.
+ *
+ * When omitted no toolbar is rendered.
+ */
+ toolbar?: EnvelopePdfViewerToolbarControl[];
+
+ /**
+ * Additional class names for the floating viewer toolbar.
+ */
+ toolbarClassName?: string;
} & Omit;
-export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
+export const EnvelopePdfViewer = ({
+ errorMessage,
+ toolbar,
+ toolbarClassName,
+ className,
+ ...props
+}: EnvelopePdfViewerProps) => {
const { t } = useLingui();
const $el = useRef(null);
- const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender();
+ const { currentEnvelopeItem, renderError, viewerControls } = useCurrentEnvelopeRender();
if (renderError || !currentEnvelopeItem) {
return (
@@ -45,12 +65,24 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
}
return (
-
+ <>
+
+
+ {toolbar && toolbar.length > 0 && (
+
+ )}
+ >
);
};
diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx
index 799ced27a..d0782dbdf 100644
--- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx
@@ -5,15 +5,26 @@ import { Trans } from '@lingui/react/macro';
type PdfViewerPageImageProps = {
imageLoadingState: ImageLoadingState;
+
+ /**
+ * Whether the page and everything drawn on it are ready to be shown.
+ */
+ isPageReady: boolean;
+
imageProps: React.ImgHTMLAttributes & Record & { alt: '' };
};
-export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => {
+export const PdfViewerPageImage = ({ imageLoadingState, isPageReady, imageProps }: PdfViewerPageImageProps) => {
+ const isLoading = !isPageReady && imageLoadingState !== 'error';
+
return (
<>
{/* Loading State */}
- {imageLoadingState === 'loading' && (
-
+ {isLoading && (
+
)}
@@ -28,7 +39,12 @@ export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerP
{/* The PDF image. */}
{imageProps.src && (
-
![]()
+
![]()
)}
>
);
diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-toolbar.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-toolbar.tsx
new file mode 100644
index 000000000..20de01b2c
--- /dev/null
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-toolbar.tsx
@@ -0,0 +1,222 @@
+import {
+ ENVELOPE_VIEWER_MAX_ZOOM,
+ ENVELOPE_VIEWER_MIN_ZOOM,
+ useCurrentEnvelopeRender,
+} from '@documenso/lib/client-only/providers/envelope-render-provider';
+import { cn } from '@documenso/ui/lib/utils';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { EyeIcon, EyeOffIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
+import { useLayoutEffect, useState } from 'react';
+
+import type { ScrollTarget } from '../virtual-list/use-virtual-list';
+
+/**
+ * The gap between the toolbar and the bottom of the visible viewer area.
+ */
+const TOOLBAR_BOTTOM_OFFSET = 24;
+
+export type EnvelopePdfViewerToolbarControl = 'zoom' | 'fields' | 'contents';
+
+export type EnvelopePdfViewerToolbarProps = {
+ /**
+ * The controls to display in the toolbar.
+ */
+ controls?: EnvelopePdfViewerToolbarControl[];
+
+ /**
+ * The scroll container the toolbar is aligned against.
+ *
+ * The toolbar is fixed to the viewport, horizontally centered over the
+ * scroll container and pinned to the bottom of its visible area.
+ */
+ scrollParentRef: ScrollTarget;
+
+ className?: string;
+};
+
+/**
+ * A floating toolbar for the envelope PDF viewer providing zoom and
+ * fields/contents visibility controls.
+ *
+ * Rendered with fixed positioning aligned to the viewer's scroll container,
+ * so it can be mounted anywhere within the viewer tree, including inside the
+ * scroll container itself.
+ */
+export const EnvelopePdfViewerToolbar = ({
+ controls = ['zoom'],
+ scrollParentRef,
+ className,
+}: EnvelopePdfViewerToolbarProps) => {
+ const { t } = useLingui();
+
+ const { viewerControls } = useCurrentEnvelopeRender();
+
+ const {
+ zoom,
+ setZoom,
+ zoomIn,
+ zoomOut,
+ fieldsVisibility,
+ setFieldsVisibility,
+ contentsVisibility,
+ setContentsVisibility,
+ } = viewerControls;
+
+ const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null);
+
+ /**
+ * Keep the toolbar aligned with the visible area of the scroll container.
+ */
+ useLayoutEffect(() => {
+ // The toolbar renders inside the scroll container, so the container's ref
+ // is not attached yet when this first runs. It is therefore observed as
+ // soon as measuring finds it, which also covers it being replaced.
+ let observedScrollEl: HTMLElement | null = null;
+
+ function measure() {
+ if (scrollParentRef === 'window') {
+ setPosition({
+ left: window.innerWidth / 2,
+ bottom: TOOLBAR_BOTTOM_OFFSET,
+ });
+
+ return;
+ }
+
+ const scrollEl = scrollParentRef.current;
+
+ if (!scrollEl) {
+ setPosition(null);
+ return;
+ }
+
+ if (observedScrollEl !== scrollEl) {
+ if (observedScrollEl) {
+ resizeObserver.unobserve(observedScrollEl);
+ }
+
+ resizeObserver.observe(scrollEl);
+ observedScrollEl = scrollEl;
+ }
+
+ const rect = scrollEl.getBoundingClientRect();
+
+ const visibleLeft = Math.max(0, rect.left);
+ const visibleRight = Math.min(window.innerWidth, rect.right);
+ const visibleBottom = Math.min(window.innerHeight, rect.bottom);
+
+ setPosition({
+ left: visibleLeft + (visibleRight - visibleLeft) / 2,
+ bottom: window.innerHeight - visibleBottom + TOOLBAR_BOTTOM_OFFSET,
+ });
+ }
+
+ const resizeObserver = new ResizeObserver(() => measure());
+
+ measure();
+
+ window.addEventListener('resize', measure);
+
+ // Covers viewport and page level layout changes, and delivers an initial
+ // notification which picks up the scroll container once it is attached.
+ resizeObserver.observe(document.body);
+
+ return () => {
+ window.removeEventListener('resize', measure);
+ resizeObserver.disconnect();
+ };
+ }, [scrollParentRef]);
+
+ const showZoom = controls.includes('zoom');
+ const showFieldsToggle = controls.includes('fields');
+ const showContentsToggle = controls.includes('contents');
+
+ const isFieldsHidden = fieldsVisibility === 'hidden';
+ const isContentsHidden = contentsVisibility === 'hidden';
+
+ if (!position) {
+ return null;
+ }
+
+ return (
+
+ {showZoom && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {showZoom && (showFieldsToggle || showContentsToggle) &&
}
+
+ {showFieldsToggle && (
+
+ )}
+
+ {showContentsToggle && (
+
+ )}
+
+ );
+};
diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
index 3fa39a232..ab139570f 100644
--- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
+++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx
@@ -8,7 +8,7 @@ import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
import type React from 'react';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { ScrollTarget } from '../virtual-list/use-virtual-list';
import { useVirtualList } from '../virtual-list/use-virtual-list';
@@ -29,6 +29,16 @@ const LOW_RENDER_RESOLUTION = 1;
const HIGH_RENDER_RESOLUTION = 2;
const IDLE_RENDER_DELAY = 200;
+/**
+ * The additional height of each virtual page item on top of the scaled page
+ * height.
+ *
+ * 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
+ * plus 2px so the page outline ring (drawn outside the page box) does not touch
+ * the neighbouring items.
+ */
+const PAGE_ITEM_EXTRA_HEIGHT = 34;
+
export type PDFViewerProps = {
className?: string;
@@ -52,6 +62,23 @@ export type PDFViewerProps = {
onDocumentLoad?: () => void;
+ /**
+ * The zoom factor to render the pages at.
+ *
+ * The rendered page width is `min(containerWidth, maxPageWidth) * zoom`,
+ * derived in the same render pass as the layout so zoom changes apply in a
+ * single paint without any intermediate layout shift.
+ *
+ * Values above 1 can overflow the container horizontally, so the scroll
+ * parent should allow horizontal scrolling.
+ */
+ zoom?: number;
+
+ /**
+ * The maximum base width of a page before the zoom is applied.
+ */
+ maxPageWidth?: number;
+
/**
* Additional component to render next to the image, such as a Konva canvas
* for rendering fields.
@@ -64,6 +91,8 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
+ zoom = 1,
+ maxPageWidth,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -213,6 +242,8 @@ export default function PDFViewer({
numPages={pages.length}
pages={pages}
pdf={pdfRef.current}
+ zoom={zoom}
+ maxPageWidth={maxPageWidth}
customPageRenderer={customPageRenderer}
/>
)}
@@ -226,6 +257,8 @@ type VirtualizedPageListProps = {
pages: PageMeta[];
numPages: number;
pdf: pdfjsLib.PDFDocumentProxy;
+ zoom: number;
+ maxPageWidth?: number;
customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>;
};
@@ -235,6 +268,8 @@ const VirtualizedPageList = ({
pages,
numPages,
pdf,
+ zoom,
+ maxPageWidth,
customPageRenderer,
}: VirtualizedPageListProps) => {
const contentRef = useRef
(null);
@@ -244,22 +279,81 @@ const VirtualizedPageList = ({
constraintRef,
contentRef,
itemCount: numPages,
- itemSize: (index, width) => {
- const pageMeta = pages[index];
-
- // Calculate height based on aspect ratio and available width
- const aspectRatio = pageMeta.height / pageMeta.width;
- const scaledHeight = width * aspectRatio;
-
- // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px)
- // Add additional 2px for the top and bottom borders.
- return scaledHeight + 32 + 2;
- },
+ itemSize: (index, width) => getPageItemSize(pages[index], getDisplayWidth(width, zoom, maxPageWidth)),
overscan: 5,
});
+ /**
+ * The width the pages are rendered at.
+ *
+ * Derived from the measured available width in the same render pass as the
+ * zoom, so zoom changes update the page layout and page sizes within a
+ * single commit, avoiding intermediate layout shifts.
+ */
+ const displayWidth = getDisplayWidth(constraintWidth, zoom, maxPageWidth);
+
useScrollToPage(contentRef, scrollToItem);
+ const previousDisplayWidthRef = useRef(displayWidth);
+
+ /**
+ * Anchor the scroll position to the bottom center when the rendered page width
+ * changes (zoom or container resize) so zooming feels centered on the middle of the visible
+ * area instead of the top left of the content.
+ */
+ useLayoutEffect(() => {
+ const previousDisplayWidth = previousDisplayWidthRef.current;
+ previousDisplayWidthRef.current = displayWidth;
+
+ if (previousDisplayWidth === displayWidth || previousDisplayWidth === 0 || displayWidth === 0) {
+ return;
+ }
+
+ const contentEl = contentRef.current;
+ const scrollEl = scrollParentRef === 'window' ? document.scrollingElement : scrollParentRef.current;
+
+ if (!contentEl || !scrollEl) {
+ return;
+ }
+
+ const viewportHeight = scrollParentRef === 'window' ? window.innerHeight : scrollEl.clientHeight;
+
+ // The offset of the content element from the top of the scrollable content.
+ // Only depends on the content above the page list, which is unaffected by zoom.
+ const contentOffsetTop =
+ contentEl.getBoundingClientRect().top -
+ (scrollParentRef === 'window' ? 0 : scrollEl.getBoundingClientRect().top) +
+ scrollEl.scrollTop;
+
+ const oldMetrics = computePageMetrics(pages, previousDisplayWidth);
+ const newMetrics = computePageMetrics(pages, displayWidth);
+
+ // The content-space Y coordinate currently at the vertical center of the viewport.
+ const oldCenterY = Math.min(
+ Math.max(0, scrollEl.scrollTop + viewportHeight / 2 - contentOffsetTop),
+ oldMetrics.totalSize,
+ );
+
+ // Locate which page the center point is on, and how far through it.
+ let pageIndex = 0;
+
+ for (let i = 0; i < pages.length; i += 1) {
+ if (oldMetrics.offsets[i] <= oldCenterY) {
+ pageIndex = i;
+ } else {
+ break;
+ }
+ }
+
+ const pageFraction =
+ oldMetrics.sizes[pageIndex] > 0 ? (oldCenterY - oldMetrics.offsets[pageIndex]) / oldMetrics.sizes[pageIndex] : 0;
+
+ const newCenterY = newMetrics.offsets[pageIndex] + pageFraction * newMetrics.sizes[pageIndex];
+
+ scrollEl.scrollTop = Math.max(0, newCenterY + contentOffsetTop - viewportHeight / 2);
+ scrollEl.scrollLeft = Math.max(0, (scrollEl.scrollWidth - scrollEl.clientWidth) / 2);
+ }, [displayWidth, pages, scrollParentRef]);
+
return (
0 ? `${displayWidth}px` : '100%',
+ // Center the pages when they fit within the container, while safely
+ // falling back to a start alignment when they overflow so the scroll
+ // container can reach all of the content.
+ margin: '0 auto',
position: 'relative',
}}
>
@@ -277,11 +375,11 @@ const VirtualizedPageList = ({
const pageMeta = pages[index];
const pageNumber = index + 1;
- // Calculate scale based on constraint width
- const scale = constraintWidth / pageMeta.width;
+ // Calculate scale based on the rendered page width.
+ const scale = displayWidth / pageMeta.width;
- const scaledWidth = Math.floor(pageMeta.width * scale);
- const scaledHeight = Math.floor(pageMeta.height * scale);
+ const scaledWidth = displayWidth;
+ const scaledHeight = Math.round(pageMeta.height * scale);
return (
-
+
Page {pageNumber} of {numPages}
@@ -318,6 +421,54 @@ const VirtualizedPageList = ({
);
};
+/**
+ * The width pages are rendered at for a given available width, zoom and
+ * maximum base page width.
+ */
+const getDisplayWidth = (constraintWidth: number, zoom: number, maxPageWidth?: number) => {
+ if (constraintWidth === 0) {
+ return 0;
+ }
+
+ const baseWidth = maxPageWidth !== undefined ? Math.min(constraintWidth, maxPageWidth) : constraintWidth;
+
+ return Math.floor(baseWidth * zoom);
+};
+
+/**
+ * The height of a single virtual list item for a page rendered at the given
+ * width: the page height scaled to that width plus the page number footer.
+ *
+ * Single source of truth for both the virtual list `itemSize` and
+ * `computePageMetrics`, so the two can never drift apart.
+ */
+const getPageItemSize = (pageMeta: PageMeta, displayWidth: number) => {
+ const aspectRatio = pageMeta.height / pageMeta.width;
+
+ return displayWidth * aspectRatio + PAGE_ITEM_EXTRA_HEIGHT;
+};
+
+/**
+ * Compute the virtual list offsets and sizes of every page for a given
+ * rendered page width.
+ */
+const computePageMetrics = (pages: PageMeta[], displayWidth: number) => {
+ const offsets: number[] = [];
+ const sizes: number[] = [];
+
+ let totalSize = 0;
+
+ for (const pageMeta of pages) {
+ const size = getPageItemSize(pageMeta, displayWidth);
+
+ offsets.push(totalSize);
+ sizes.push(size);
+ totalSize += size;
+ }
+
+ return { offsets, sizes, totalSize };
+};
+
type PdfViewerPageProps = {
pageNumber: number;
pdf: pdfjsLib.PDFDocumentProxy;
@@ -349,8 +500,19 @@ const PdfViewerPage = ({
scale,
});
+ /**
+ * A custom page renderer may have to load things of its own before the page
+ * can be drawn, which it can only begin once the page image is ready. The
+ * page image is held back until then so both appear at once.
+ */
+ const [isCustomRendererReady, setIsCustomRendererReady] = useState(false);
+
+ const isPageReady = imageLoadingState === 'loaded' && (!CustomPageRenderer || isCustomRendererReady);
+
return (
-
+ // Must use ring instead of border since borders take up space inside the box,
+ // which shrinks the page image (constrained to the box by `max-width: 100%`)
+
{CustomPageRenderer && imageLoadingState === 'loaded' && (
)}
-
+
);
};
@@ -502,8 +665,16 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
const imageProps = useMemo(
(): React.ImgHTMLAttributes
& Record & { alt: '' } => ({
className: PDF_VIEWER_PAGE_CLASSNAME,
- width: Math.floor(scaledWidth),
- height: Math.floor(scaledHeight),
+ width: scaledWidth,
+ height: scaledHeight,
+ // Pin the rendered size to the page size. Tailwind's preflight applies
+ // `max-width: 100%; height: auto` to images, which would otherwise let
+ // the container clamp the image out of alignment with the page overlay.
+ style: {
+ width: scaledWidth,
+ height: scaledHeight,
+ maxWidth: 'none',
+ },
alt: '',
onLoad: () => setImageLoadingState('loaded'),
onError: () => setImageLoadingState('error'),
diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx
index cc7754ffd..4b91c34ef 100644
--- a/apps/remix/app/components/tables/templates-table.tsx
+++ b/apps/remix/app/components/tables/templates-table.tsx
@@ -209,7 +209,6 @@ export const TemplatesTable = ({
)}
/>
+
+ (
+
+
+ Envelope Content Count
+
+
+ field.onChange(parseInt(e.target.value, 10) || 0)}
+ />
+
+
+ Maximum number of contents per envelope allowed. 0 = Unlimited
+
+
+
+ )}
+ />
+
+ (
+
+
+ Envelope Content Image Count
+
+
+ field.onChange(parseInt(e.target.value, 10) || 0)}
+ />
+
+
+ Maximum number of image contents per envelope allowed. 0 = Unlimited
+
+
+
+ )}
+ />
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
index 8b68fe82f..d412c8772 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
@@ -160,6 +160,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
+ contents={envelope.contents}
signatures={fieldSignatures}
recipients={envelope.recipients}
overrideSettings={{
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
index 45f6d7be9..3fcb9970e 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
@@ -176,6 +176,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
+ contents={envelope.contents}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
@@ -255,7 +256,6 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
- isTemplateMissingSignatures: true,
+ invalidReason: 'MISSING_SIGNATURE_FIELD',
} as const;
}
return {
isAccessAuthValid: true,
- isTemplateMissingSignatures: false,
+ invalidReason: null,
template: {
...template,
folder: null,
@@ -108,7 +111,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
- isTemplateMissingSignatures: false,
+ invalidReason: null,
envelopeForSigning,
} as const;
})
@@ -121,10 +124,18 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
- if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
+ // Reasons the template renders but cannot be submitted, reported up
+ // front rather than once the signer has filled everything in.
+ const invalidReason = match(error.code)
+ .returnType()
+ .with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => 'MISSING_SIGNATURE_FIELD')
+ .with('MISSING_CONTENT_IMAGE', () => 'MISSING_CONTENT_IMAGE')
+ .otherwise(() => null);
+
+ if (invalidReason) {
return {
isDocumentAccessValid: true,
- isTemplateMissingSignatures: true,
+ invalidReason,
} as const;
}
@@ -201,8 +212,8 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited;
}
- if (data.isTemplateMissingSignatures) {
- return ;
+ if (data.invalidReason) {
+ return ;
}
const { template, directTemplateRecipient } = data;
@@ -259,8 +270,8 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited;
}
- if (data.isTemplateMissingSignatures) {
- return ;
+ if (data.invalidReason) {
+ return ;
}
const { envelope, recipient } = data.envelopeForSigning;
@@ -283,6 +294,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
index d35313fd8..9e8f80544 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -577,6 +577,7 @@ const SigningPageV2 = ({ data }: { data: Awaited
diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
index f8e6d0630..575ae9c1d 100644
--- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
+++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
@@ -358,6 +358,7 @@ const EmbedDirectTemplatePageV2 = ({ data }: { data: Awaited
{
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
+ .with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
+ .with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
+ .with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`An error occurred while distributing the document.`,
@@ -36,6 +54,9 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
+ .with('MISSING_CONTENT_IMAGE', () => MISSING_CONTENT_IMAGE_ERROR_MESSAGE)
+ .with('ENVELOPE_CONTENT_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
+ .with('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED', () => ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -130,6 +151,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
+ .with('MISSING_CONTENT_IMAGE', () => ({
+ title: msg`Missing images`,
+ description: msg`The document could not be sent because one or more image contents have no image. Please edit the template and upload an image for each of them, or remove them.`,
+ }))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
diff --git a/apps/remix/server/api/files/files.helpers.ts b/apps/remix/server/api/files/files.helpers.ts
index 3b0dc1f05..52dedf3f8 100644
--- a/apps/remix/server/api/files/files.helpers.ts
+++ b/apps/remix/server/api/files/files.helpers.ts
@@ -234,9 +234,16 @@ const handlePendingFileRequest = async ({
return c.json({ error: 'File not found' }, 404);
}
+ const contents = await prisma.envelopeContent.findMany({
+ where: {
+ envelopeItemId,
+ },
+ });
+
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
+ contents,
});
c.get('logger').info({
diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts
index bbca38885..04588a7b2 100644
--- a/apps/remix/server/api/files/files.ts
+++ b/apps/remix/server/api/files/files.ts
@@ -18,6 +18,8 @@ import {
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZUploadPdfRequestSchema,
} from './files.types';
+import getDataContentImageRoute from './routes/get-data-content-image';
+import getDataContentImageByTokenRoute from './routes/get-data-content-image-by-token';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
@@ -342,3 +344,7 @@ export const filesRoute = new Hono()
// Is different to the other file endpoints since it uses documentDataId for hard caching.
filesRoute.route('/', getEnvelopeItemPdfRoute);
filesRoute.route('/', getEnvelopeItemPdfByTokenRoute);
+
+// Content image routes for both tokens and auth based, hard cached by dataContentId.
+filesRoute.route('/', getDataContentImageRoute);
+filesRoute.route('/', getDataContentImageByTokenRoute);
diff --git a/apps/remix/server/api/files/routes/get-data-content-image-by-token.ts b/apps/remix/server/api/files/routes/get-data-content-image-by-token.ts
new file mode 100644
index 000000000..916167add
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-data-content-image-by-token.ts
@@ -0,0 +1,74 @@
+import { prisma } from '@documenso/prisma';
+import { sValidator } from '@hono/standard-validator';
+import type { Prisma } from '@prisma/client';
+import { Hono } from 'hono';
+import { z } from 'zod';
+
+import type { HonoEnv } from '../../../router';
+import { handleDataContentImageRequest } from './get-data-content-image';
+
+const route = new Hono();
+
+const ZGetDataContentImageByTokenParamsSchema = z.object({
+ token: z.string().min(1),
+ envelopeId: z.string().min(1),
+ dataContentId: z.string().min(1),
+});
+
+/**
+ * Returns the image of a data content attached to a content of the envelope,
+ * using a recipient or QR token.
+ */
+route.get(
+ '/token/:token/envelope/:envelopeId/dataContent/:dataContentId/image',
+ sValidator('param', ZGetDataContentImageByTokenParamsSchema),
+ async (c) => {
+ const { token, envelopeId, dataContentId } = c.req.valid('param');
+
+ if (!token) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Recipient token based query.
+ let envelopeWhereQuery: Prisma.EnvelopeWhereInput = {
+ id: envelopeId,
+ recipients: {
+ some: {
+ token,
+ },
+ },
+ };
+
+ // QR token based query.
+ if (token.startsWith('qr_')) {
+ envelopeWhereQuery = {
+ id: envelopeId,
+ qrToken: token,
+ };
+ }
+
+ // Validate envelope access via a content the data content is attached to.
+ const envelopeContent = await prisma.envelopeContent.findFirst({
+ where: {
+ envelopeId,
+ dataContentId,
+ envelope: envelopeWhereQuery,
+ },
+ include: {
+ dataContent: true,
+ },
+ });
+
+ if (!envelopeContent?.dataContent) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleDataContentImageRequest({
+ c,
+ dataContent: envelopeContent.dataContent,
+ cacheStrategy: 'private',
+ });
+ },
+);
+
+export default route;
diff --git a/apps/remix/server/api/files/routes/get-data-content-image.ts b/apps/remix/server/api/files/routes/get-data-content-image.ts
new file mode 100644
index 000000000..af82cca8f
--- /dev/null
+++ b/apps/remix/server/api/files/routes/get-data-content-image.ts
@@ -0,0 +1,156 @@
+import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
+import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
+import { DataContentType } from '@documenso/lib/types/data-content-meta';
+import { sha256 } from '@documenso/lib/universal/crypto';
+import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
+import { prisma } from '@documenso/prisma';
+import { sValidator } from '@hono/standard-validator';
+import type { DataContent } from '@prisma/client';
+import { type Context, Hono } from 'hono';
+import { z } from 'zod';
+
+import type { HonoEnv } from '../../../router';
+import { checkEnvelopeFileAccess } from '../files.helpers';
+
+const route = new Hono();
+
+const ZGetDataContentImageRequestParamsSchema = z.object({
+ envelopeId: z.string().min(1),
+ dataContentId: z.string().min(1),
+});
+
+const ZGetDataContentImageRequestQuerySchema = z.object({
+ presignToken: z.string().optional(),
+});
+
+/**
+ * Returns the image of a data content attached to a content of the envelope.
+ */
+route.get(
+ '/envelope/:envelopeId/dataContent/:dataContentId/image',
+ sValidator('param', ZGetDataContentImageRequestParamsSchema),
+ sValidator('query', ZGetDataContentImageRequestQuerySchema),
+ async (c) => {
+ const { envelopeId, dataContentId } = c.req.valid('param');
+
+ const { presignToken } = c.req.valid('query');
+
+ const session = await getOptionalSession(c);
+
+ let userId = session.user?.id;
+
+ // Check presignToken if provided
+ if (presignToken) {
+ const verifiedToken = await verifyEmbeddingPresignToken({
+ token: presignToken,
+ }).catch(() => undefined);
+
+ userId = verifiedToken?.userId;
+ }
+
+ if (!userId) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ const envelopeContent = await prisma.envelopeContent.findFirst({
+ where: {
+ envelopeId,
+ dataContentId,
+ },
+ include: {
+ dataContent: true,
+ envelope: {
+ select: {
+ teamId: true,
+ type: true,
+ templateType: true,
+ },
+ },
+ },
+ });
+
+ if (!envelopeContent?.dataContent) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ const { dataContent, envelope } = envelopeContent;
+
+ // Check whether the user has access to this content via the envelope.
+ const hasAccess = await checkEnvelopeFileAccess({
+ userId,
+ teamId: envelope.teamId,
+ envelopeType: envelope.type,
+ templateType: envelope.templateType,
+ });
+
+ if (!hasAccess) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ return await handleDataContentImageRequest({
+ c,
+ dataContent,
+ cacheStrategy: 'private',
+ });
+ },
+);
+
+type HandleDataContentImageRequestOptions = {
+ c: Context;
+ dataContent: DataContent;
+
+ /**
+ * The type of cache strategy to use.
+ *
+ * For access via tokens, we can use a public cache to allow the CDN to cache it.
+ *
+ * For access via session, we must use a private cache.
+ */
+ cacheStrategy: 'private' | 'public';
+};
+
+/**
+ * Serve the bytes of an image data content.
+ *
+ * Data contents are immutable, so the response is hard cached like envelope
+ * item PDFs. The content type comes from the stored metadata and is never
+ * sniffed from the bytes.
+ */
+export const handleDataContentImageRequest = async ({
+ c,
+ dataContent,
+ cacheStrategy,
+}: HandleDataContentImageRequestOptions) => {
+ if (dataContent.metadata.type !== DataContentType.IMAGE) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ const etag = Buffer.from(sha256(dataContent.data)).toString('hex');
+
+ if (c.req.header('If-None-Match') === etag) {
+ return c.body(null, 304);
+ }
+
+ const file = await getFileServerSide({
+ type: dataContent.type,
+ data: dataContent.data,
+ }).catch((error) => {
+ console.error(error);
+
+ return null;
+ });
+
+ if (!file) {
+ return c.json({ error: 'Not found' }, 404);
+ }
+
+ // Note: Only set these headers on success.
+ c.header('Content-Type', dataContent.metadata.mimeType);
+ c.header('X-Content-Type-Options', 'nosniff');
+ c.header('ETag', etag);
+ c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
+
+ return c.body(file);
+};
+
+export default route;
diff --git a/assets/content-alignment.pdf b/assets/content-alignment.pdf
new file mode 100644
index 000000000..26b6b82b3
Binary files /dev/null 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
new file mode 100644
index 000000000..34c0cc0b2
--- /dev/null
+++ b/packages/app-tests/constants/content-alignment-pdf.ts
@@ -0,0 +1,376 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ EnvelopeContentShapeType,
+ EnvelopeContentType,
+ type TContentHighlightMeta,
+ type TContentImageMeta,
+ type TContentShapeMeta,
+ type TContentTextMeta,
+ type TEnvelopeContentMetaInput,
+} from '@documenso/lib/types/envelope-content-meta';
+
+/**
+ * A content to seed for the visual regression test, laid out on the labelled
+ * grid of `assets/content-alignment.pdf` so placement can be read against the
+ * printed boxes. Each content type has its own page.
+ *
+ * The PDF is generated by `scripts/generate-content-alignment-test-pdf.mjs`.
+ * Keep `CONTENT_ALIGNMENT_GRID` and `CONTENT_ALIGNMENT_PAGES` in sync with
+ * the grid and page order there.
+ */
+export type ContentTestData = {
+ page: number;
+ contentMeta: TEnvelopeContentMetaInput;
+
+ /**
+ * An image to attach, for image contents.
+ */
+ image?: 'logo';
+};
+
+export const CONTENT_ALIGNMENT_PDF = fs.readFileSync(path.join(__dirname, '../../../assets/content-alignment.pdf'));
+
+/**
+ * The Documenso wordmark, wide (about 6.8:1) so fitting, centering and
+ * rotation are all visible.
+ */
+export const CONTENT_ALIGNMENT_LOGO_PNG = fs.readFileSync(path.join(__dirname, '../../../packages/assets/logo.png'));
+
+export const CONTENT_ALIGNMENT_PAGES = {
+ text: 1,
+ line: 2,
+ shape: 3,
+ highlight: 4,
+ image: 5,
+} as const;
+
+/**
+ * The page is A4, so a square in page units needs the width scaled by this.
+ */
+const A4_ASPECT = 210 / 297;
+
+/**
+ * The grid printed on every page of the PDF, as percentages of the page.
+ */
+export const CONTENT_ALIGNMENT_GRID = {
+ startX: 8,
+ startY: 12,
+ columnWidth: 28,
+ columnGap: 1,
+ rowHeight: 7,
+ rowGap: 1,
+};
+
+const cell = (row: number, column: number, { rows = 1, columns = 1 }: { rows?: number; columns?: number } = {}) => {
+ const { startX, startY, columnWidth, columnGap, rowHeight, rowGap } = CONTENT_ALIGNMENT_GRID;
+
+ return {
+ positionX: startX + column * (columnWidth + columnGap),
+ positionY: startY + row * (rowHeight + rowGap),
+ width: columns * columnWidth + (columns - 1) * columnGap,
+ height: rows * rowHeight + (rows - 1) * rowGap,
+ };
+};
+
+type Box = ReturnType;
+
+/**
+ * A box half the size of a cell, placed at the cell's center.
+ *
+ * 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.
+ */
+const halfCell = (row: number, column: number, options: { rows?: number; columns?: number } = {}) => {
+ const box = cell(row, column, options);
+
+ return {
+ positionX: box.positionX + box.width / 2,
+ positionY: box.positionY + box.height / 2,
+ width: box.width / 2,
+ height: box.height / 2,
+ };
+};
+
+/**
+ * A square box in page units, as tall as the given number of rows.
+ */
+const square = (row: number, column: number, rows: number) => {
+ const box = cell(row, column, { rows });
+
+ return { ...box, width: box.height * A4_ASPECT };
+};
+
+// --- Builders ---
+
+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 },
+});
+
+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,
+});
+
+const line = (
+ points: LinePoints,
+ meta: { strokeWidth: number; strokeColor?: string; strokeStyle?: 'solid' | 'dashed' | 'dotted' },
+): ContentTestData => ({
+ page: CONTENT_ALIGNMENT_PAGES.line,
+ contentMeta: {
+ type: EnvelopeContentType.LINE,
+ page: CONTENT_ALIGNMENT_PAGES.line,
+ strokeColor: '#000000',
+ strokeStyle: 'solid',
+ ...points,
+ ...meta,
+ },
+});
+
+const rectangle = (box: Box, meta: Overrides = {}): ContentTestData => ({
+ page: CONTENT_ALIGNMENT_PAGES.shape,
+ contentMeta: {
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: CONTENT_ALIGNMENT_PAGES.shape,
+ rotation: 0,
+ strokeWidth: 1,
+ strokeColor: '#000000',
+ strokeStyle: 'solid',
+ ...box,
+ ...meta,
+ },
+});
+
+const highlight = (box: Box, meta: Overrides = {}): ContentTestData => ({
+ page: CONTENT_ALIGNMENT_PAGES.highlight,
+ contentMeta: {
+ type: EnvelopeContentType.HIGHLIGHT,
+ page: CONTENT_ALIGNMENT_PAGES.highlight,
+ rotation: 0,
+ color: '#ffe600',
+ fillOpacity: 0.4,
+ ...box,
+ ...meta,
+ },
+});
+
+const image = (box: Box, meta: Overrides = {}): ContentTestData => ({
+ page: CONTENT_ALIGNMENT_PAGES.image,
+ image: 'logo',
+ contentMeta: { type: EnvelopeContentType.IMAGE, page: CONTENT_ALIGNMENT_PAGES.image, rotation: 0, ...box, ...meta },
+});
+
+// --- Page 1: Text ---
+
+const TEXT_CONTENTS: ContentTestData[] = [
+ text(cell(0, 0), { text: 'Left aligned', textAlign: 'left' }),
+ text(cell(0, 1), { text: 'Center aligned', textAlign: 'center' }),
+ text(cell(0, 2), { text: 'Right aligned', textAlign: 'right' }),
+
+ text(cell(1, 0, { rows: 2 }), { text: 'Top', verticalAlign: 'top' }),
+ text(cell(1, 1, { rows: 2 }), { text: 'Middle', verticalAlign: 'middle' }),
+ text(cell(1, 2, { rows: 2 }), { text: 'Bottom', verticalAlign: 'bottom' }),
+
+ text(cell(3, 0), { text: 'Size 8', fontSize: 8 }),
+ 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 }),
+ 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' }),
+
+ line(horizontal(cell(1, 0)), { strokeWidth: 0.5 }),
+ line(horizontal(cell(1, 1)), { strokeWidth: 5 }),
+ line(horizontal(cell(1, 2)), { strokeWidth: 10 }),
+
+ // Diagonals across the full width, corner to corner.
+ ...(() => {
+ const down = cell(2, 0, { columns: 3, rows: 2 });
+ const up = cell(4, 0, { columns: 3, rows: 2 });
+
+ return [
+ line(
+ {
+ x1: down.positionX,
+ y1: down.positionY,
+ x2: down.positionX + down.width,
+ y2: down.positionY + down.height,
+ },
+ { strokeWidth: 2, strokeColor: '#0055aa' },
+ ),
+ line(
+ {
+ x1: up.positionX,
+ y1: up.positionY + up.height,
+ x2: up.positionX + up.width,
+ y2: up.positionY,
+ },
+ { strokeWidth: 2, strokeColor: '#0055aa', strokeStyle: 'dashed' },
+ ),
+ ];
+ })(),
+
+ // Vertical lines: through the center, along the left edge, and colored.
+ ...(() => {
+ const center = cell(6, 0, { rows: 2 });
+ const edge = cell(6, 1, { rows: 2 });
+ const red = cell(6, 2, { rows: 2 });
+
+ return [
+ line(
+ {
+ x1: center.positionX + center.width / 2,
+ y1: center.positionY,
+ x2: center.positionX + center.width / 2,
+ y2: center.positionY + center.height,
+ },
+ { strokeWidth: 2 },
+ ),
+ line(
+ {
+ x1: edge.positionX,
+ y1: edge.positionY,
+ x2: edge.positionX,
+ y2: edge.positionY + edge.height,
+ },
+ { strokeWidth: 4 },
+ ),
+ line(
+ {
+ x1: red.positionX + red.width / 2,
+ y1: red.positionY,
+ x2: red.positionX + red.width / 2,
+ y2: red.positionY + red.height,
+ },
+ { strokeWidth: 3, strokeColor: '#d00000', strokeStyle: 'dotted' },
+ ),
+ ];
+ })(),
+];
+
+/**
+ * Two overlapping filled rectangles within a cell, for stacking tests. The
+ * second is offset down and right of the first so both are partly visible
+ * and the overlap shows which is on top.
+ */
+const stackedPair = (
+ row: number,
+ column: number,
+ { first, second }: { first: { fillColor: string; zIndex: number }; second: { fillColor: string; zIndex: number } },
+): ContentTestData[] => {
+ const box = cell(row, column, { rows: 2 });
+ const size = { width: box.width * 0.6, height: box.height * 0.6 };
+
+ return [
+ rectangle(
+ { positionX: box.positionX, positionY: box.positionY, ...size },
+ { fillColor: first.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: first.zIndex },
+ ),
+ rectangle(
+ { positionX: box.positionX + box.width * 0.4, positionY: box.positionY + box.height * 0.4, ...size },
+ { fillColor: second.fillColor, fillOpacity: 1, strokeWidth: 1, zIndex: second.zIndex },
+ ),
+ ];
+};
+
+// --- Page 3: Shapes ---
+
+const SHAPE_CONTENTS: ContentTestData[] = [
+ rectangle(cell(0, 0), { strokeWidth: 2, strokeColor: '#d00000' }),
+ rectangle(cell(0, 1), { strokeWidth: 1, strokeStyle: 'dashed' }),
+ rectangle(cell(0, 2), { strokeWidth: 4, strokeStyle: 'dotted' }),
+
+ rectangle(cell(1, 0), { fillColor: '#ffcc00', fillOpacity: 1 }),
+ rectangle(cell(1, 1), { fillColor: '#00aa55', fillOpacity: 0.4 }),
+ rectangle(cell(1, 2), { fillColor: '#0055aa', fillOpacity: 1, strokeWidth: 0.5, strokeColor: '#0055aa' }),
+
+ 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 }),
+
+ // Stacking. Each cell holds two overlapping filled rectangles: the first
+ // (top left) and the second (offset to the bottom right). The rectangles
+ // are listed, and therefore created, in that order, and the seed hands out
+ // ids in that same order so equal zIndexes stack the second on top.
+ //
+ // Expected on top: red, blue, green.
+ ...stackedPair(6, 0, { first: { fillColor: '#d00000', zIndex: 2 }, second: { fillColor: '#ffcc00', zIndex: 1 } }),
+ ...stackedPair(6, 1, { first: { fillColor: '#ffcc00', zIndex: 0 }, second: { fillColor: '#0055aa', zIndex: 5 } }),
+ ...stackedPair(6, 2, { first: { fillColor: '#d00000', zIndex: 3 }, second: { fillColor: '#00aa55', zIndex: 3 } }),
+];
+
+// --- Page 4: Highlights ---
+
+const HIGHLIGHT_CONTENTS: ContentTestData[] = [
+ highlight(cell(0, 0), { color: '#ffe600' }),
+ highlight(cell(0, 1), { color: '#00aa55' }),
+ highlight(cell(0, 2), { color: '#ff66cc' }),
+
+ highlight(cell(1, 0), { fillOpacity: 0.1 }),
+ highlight(cell(1, 1), { fillOpacity: 0.6 }),
+ 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 ---
+
+const IMAGE_CONTENTS: ContentTestData[] = [
+ // Fit: the wide logo fills a wide box, letterboxes in a square, centers in a tall one.
+ image(cell(0, 0, { rows: 2 })),
+ image(square(0, 1, 2)),
+ image({ ...cell(0, 2, { rows: 3 }), width: cell(0, 2).width / 2 }),
+
+ 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 }),
+
+ image(halfCell(7, 0, { rows: 2 }), { rotation: 180 }),
+ image(halfCell(7, 1, { rows: 2 }), { rotation: 270 }),
+];
+
+export const CONTENT_ALIGNMENT_TEST_CONTENTS: ContentTestData[] = [
+ ...TEXT_CONTENTS,
+ ...LINE_CONTENTS,
+ ...SHAPE_CONTENTS,
+ ...HIGHLIGHT_CONTENTS,
+ ...IMAGE_CONTENTS,
+];
diff --git a/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts b/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts
index 8f543b4b0..485f95ecc 100644
--- a/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts
+++ b/packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts
@@ -48,6 +48,8 @@ const seedSubscriptionClaim = (name: string) =>
memberCount: 1,
envelopeItemCount: 10,
recipientCount: 10,
+ envelopeContentCount: 0,
+ envelopeContentImageCount: 0,
flags: {},
documentRateLimits: [],
emailRateLimits: [],
diff --git a/packages/app-tests/e2e/admin/organisations/update-organisation-content-claims.spec.ts b/packages/app-tests/e2e/admin/organisations/update-organisation-content-claims.spec.ts
new file mode 100644
index 000000000..96dc64c73
--- /dev/null
+++ b/packages/app-tests/e2e/admin/organisations/update-organisation-content-claims.spec.ts
@@ -0,0 +1,54 @@
+import { prisma } from '@documenso/prisma';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, test } from '@playwright/test';
+
+import { apiSignin } from '../../fixtures/authentication';
+import { expectToastTextToBeVisible } from '../../fixtures/generic';
+
+test('[ADMIN]: update envelope content claims for an organisation', async ({ page }) => {
+ const { user: adminUser } = await seedUser({
+ isAdmin: true,
+ });
+
+ const { organisation } = await seedUser({
+ isPersonalOrganisation: false,
+ });
+
+ await apiSignin({
+ page,
+ email: adminUser.email,
+ redirectPath: `/admin/organisations/${organisation.id}`,
+ });
+
+ await expect(page.getByText('Manage organisation')).toBeVisible();
+
+ const contentCountInput = page.getByLabel('Envelope Content Count', { exact: true });
+ const imageCountInput = page.getByLabel('Envelope Content Image Count', { exact: true });
+
+ // Everything is unlimited by default.
+ await expect(contentCountInput).toHaveValue('0');
+ await expect(imageCountInput).toHaveValue('0');
+
+ await contentCountInput.fill('25');
+ await imageCountInput.fill('5');
+
+ // "Update role" buttons also match a non-exact name.
+ await page.getByRole('button', { name: 'Update', exact: true }).last().click();
+
+ await expectToastTextToBeVisible(page, 'Organisation has been updated successfully');
+
+ await expect(async () => {
+ const claim = await prisma.organisationClaim.findFirstOrThrow({
+ where: { organisation: { id: organisation.id } },
+ });
+
+ expect(claim.envelopeContentCount).toBe(25);
+ expect(claim.envelopeContentImageCount).toBe(5);
+ }).toPass({ timeout: 10000 });
+
+ // The saved values are shown when the page is loaded again.
+ await page.reload();
+
+ await expect(page.getByLabel('Envelope Content Count', { exact: true })).toHaveValue('25');
+ await expect(page.getByLabel('Envelope Content Image Count', { exact: true })).toHaveValue('5');
+});
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
new file mode 100644
index 000000000..0e0056b9b
--- /dev/null
+++ b/packages/app-tests/e2e/api/trpc/set-envelope-contents-audit-logs.spec.ts
@@ -0,0 +1,296 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { EnvelopeContentType, type TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
+import { prefixedId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedBlankDocument } from '@documenso/prisma/seed/documents';
+import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { apiSignin } from '../../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+/**
+ * Contents are burned into the signed PDF, so authoring them is recorded the
+ * same way authoring a field is.
+ *
+ * The audit log type names are written out rather than imported because the
+ * module which declares them carries lingui macros the test runner cannot
+ * transform.
+ */
+
+const textMeta = (overrides: Partial = {}): TEnvelopeContentMetaInput => ({
+ type: EnvelopeContentType.TEXT,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 6,
+ text: 'Approved',
+ ...overrides,
+});
+
+const setContents = async (
+ page: Page,
+ teamId: number,
+ envelopeId: string,
+ contents: {
+ id?: string;
+ envelopeItemId: string;
+ contentMeta: TEnvelopeContentMetaInput;
+ dataContentId?: string | null;
+ }[],
+) =>
+ await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({
+ json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
+ }),
+ });
+
+const getContentAuditLogs = async (envelopeId: string) =>
+ await prisma.documentAuditLog.findMany({
+ where: { envelopeId, type: { startsWith: 'CONTENT_' } },
+ orderBy: { createdAt: 'asc' },
+ });
+
+const setupEnvelope = async (page: Page, type: 'document' | 'template' = 'document') => {
+ const { user, team } = await seedUser();
+
+ const envelopeRecord =
+ type === 'document'
+ ? await seedBlankDocument(user, team.id, { internalVersion: 2 })
+ : await seedBlankTemplate(user, team.id, { internalVersion: 2 });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: envelopeRecord.id },
+ include: { envelopeItems: true },
+ });
+
+ return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
+};
+
+test('logs a created content', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ expect(res.ok()).toBeTruthy();
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs).toHaveLength(1);
+ expect(logs[0].type).toBe('CONTENT_CREATED');
+ expect(logs[0].data).toMatchObject({
+ contentId: content.id,
+ contentType: EnvelopeContentType.TEXT,
+ envelopeItemId,
+ contentMeta: content.contentMeta,
+ dataContentId: null,
+ });
+});
+
+test('logs a moved content as updated, recording the position', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const res = await setContents(page, teamId, envelope.id, [
+ { id: content.id, envelopeItemId, contentMeta: textMeta({ positionX: 55, page: 1 }) },
+ ]);
+
+ expect(res.ok()).toBeTruthy();
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
+ expect(logs[1].data).toMatchObject({
+ contentId: content.id,
+ changes: [{ type: 'PROPERTY', key: 'positionX', from: 10, to: 55 }],
+ });
+});
+
+test('logs an edited text as updated', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const res = await setContents(page, teamId, envelope.id, [
+ { id: content.id, envelopeItemId, contentMeta: textMeta({ text: 'Rejected' }) },
+ ]);
+
+ expect(res.ok()).toBeTruthy();
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
+ expect(logs[1].data).toMatchObject({
+ contentId: content.id,
+ changes: [{ type: 'PROPERTY', key: 'text', from: 'Approved', to: 'Rejected' }],
+ });
+});
+
+test('logs a restyled content as updated', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta({ color: '#000000' }) }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ await setContents(page, teamId, envelope.id, [
+ { id: content.id, envelopeItemId, contentMeta: textMeta({ color: '#ff0000' }) },
+ ]);
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs[1].data).toMatchObject({
+ changes: [{ type: 'PROPERTY', key: 'color', from: '#000000', to: '#ff0000' }],
+ });
+});
+
+/**
+ * A content belongs to the item it was created on. The diff which feeds the
+ * audit log does not cover the item, so a move is refused outright rather
+ * than applied without a record.
+ */
+test('rejects moving a content to another envelope item', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ const firstItem = await prisma.envelopeItem.findFirstOrThrow({
+ where: { id: envelopeItemId },
+ include: { documentData: true },
+ });
+
+ const secondDocumentData = await prisma.documentData.create({
+ data: {
+ type: firstItem.documentData.type,
+ data: firstItem.documentData.data,
+ initialData: firstItem.documentData.initialData,
+ },
+ });
+
+ const secondItem = await prisma.envelopeItem.create({
+ data: {
+ id: prefixedId('envelope_item'),
+ title: `${firstItem.title} - 2`,
+ documentDataId: secondDocumentData.id,
+ order: 2,
+ envelopeId: envelope.id,
+ },
+ });
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const res = await setContents(page, teamId, envelope.id, [
+ { id: content.id, envelopeItemId: secondItem.id, contentMeta: textMeta() },
+ ]);
+
+ expect(res.status()).toBe(400);
+
+ const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
+
+ expect(reloaded.envelopeItemId).toBe(envelopeItemId);
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
+});
+
+test('does not log a save which changed nothing', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ await setContents(page, teamId, envelope.id, [{ id: content.id, envelopeItemId, contentMeta: textMeta() }]);
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED']);
+});
+
+/**
+ * Bringing a content to the front is an authoring action like any other
+ * property change, so it is recorded. Merely selecting a content no longer
+ * rewrites its order, so this is only logged when the order actually moves.
+ */
+test('logs a changed stacking order as updated', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const res = await setContents(page, teamId, envelope.id, [
+ { id: content.id, envelopeItemId, contentMeta: textMeta({ zIndex: content.contentMeta.zIndex + 5 }) },
+ ]);
+
+ expect(res.ok()).toBeTruthy();
+
+ const reloaded = await prisma.envelopeContent.findFirstOrThrow({ where: { id: content.id } });
+
+ expect(reloaded.contentMeta.zIndex).toBe(content.contentMeta.zIndex + 5);
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_UPDATED']);
+ expect(logs[1].data).toMatchObject({
+ contentId: content.id,
+ changes: [
+ { type: 'PROPERTY', key: 'zIndex', from: content.contentMeta.zIndex, to: content.contentMeta.zIndex + 5 },
+ ],
+ });
+});
+
+test('logs a removed content as deleted', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page);
+
+ await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ const res = await setContents(page, teamId, envelope.id, []);
+
+ expect(res.ok()).toBeTruthy();
+
+ const logs = await getContentAuditLogs(envelope.id);
+
+ expect(logs.map((log) => log.type)).toEqual(['CONTENT_CREATED', 'CONTENT_DELETED']);
+ expect(logs[1].data).toMatchObject({
+ contentId: content.id,
+ contentType: EnvelopeContentType.TEXT,
+ envelopeItemId,
+ });
+});
+
+/**
+ * Templates are not audit logged, matching `setFieldsForTemplate`.
+ */
+test('does not log contents authored on a template', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, 'template');
+
+ const res = await setContents(page, teamId, envelope.id, [{ envelopeItemId, contentMeta: textMeta() }]);
+
+ expect(res.ok()).toBeTruthy();
+ expect(await prisma.envelopeContent.count({ where: { envelopeId: envelope.id } })).toBe(1);
+
+ expect(await getContentAuditLogs(envelope.id)).toHaveLength(0);
+});
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
new file mode 100644
index 000000000..ff97f0426
--- /dev/null
+++ b/packages/app-tests/e2e/api/trpc/set-envelope-contents-limits.spec.ts
@@ -0,0 +1,219 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import {
+ CONTENT_MAX_Z_INDEX,
+ EnvelopeContentType,
+ type TEnvelopeContentMetaInput,
+ ZEnvelopeContentMetaSchema,
+} from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedBlankDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { apiSignin } from '../../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+/**
+ * The editor hides the palette once a limit is reached, so these cover the
+ * server side of that rule: the API is the only way to push an envelope past
+ * its organisation's allowance.
+ */
+
+const textMeta = (index: number, overrides: { zIndex?: number } = {}): TEnvelopeContentMetaInput => ({
+ type: EnvelopeContentType.TEXT,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 6,
+ text: `Content ${index}`,
+ ...overrides,
+});
+
+const imageMeta = (): TEnvelopeContentMetaInput => ({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 30,
+ width: 20,
+ height: 10,
+});
+
+const setOrganisationContentLimits = async (
+ teamId: number,
+ limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
+) => {
+ const team = await prisma.team.findFirstOrThrow({ where: { id: teamId } });
+
+ const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
+ where: { organisation: { id: team.organisationId } },
+ });
+
+ await prisma.organisationClaim.update({ where: { id: organisationClaim.id }, data: limits });
+};
+
+/**
+ * Seed contents straight onto the envelope, bypassing the limit check, to set
+ * up an envelope which is already over its allowance.
+ */
+const seedContents = async (envelopeId: string, envelopeItemId: string, count: number, images = 0) => {
+ await prisma.envelopeContent.createMany({
+ data: Array.from({ length: count }).map((_, index) => ({
+ id: generateDatabaseId('envelope_content'),
+ envelopeId,
+ envelopeItemId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse(index < images ? imageMeta() : textMeta(index)),
+ })),
+ });
+};
+
+const setContents = async (
+ page: Page,
+ teamId: number,
+ envelopeId: string,
+ contents: {
+ id?: string;
+ envelopeItemId: string;
+ contentMeta: TEnvelopeContentMetaInput;
+ dataContentId?: string | null;
+ }[],
+) =>
+ await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({
+ json: { envelopeId, contents: contents.map((content) => ({ dataContentId: null, ...content })) },
+ }),
+ });
+
+const getContentCount = async (envelopeId: string) => await prisma.envelopeContent.count({ where: { envelopeId } });
+
+const setupEnvelope = async (
+ page: Page,
+ limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
+) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
+
+ await setOrganisationContentLimits(team.id, limits);
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: true },
+ });
+
+ return { envelope, envelopeItemId: envelope.envelopeItems[0].id, teamId: team.id };
+};
+
+test('rejects a save which exceeds the content limit', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
+
+ const withinLimit = await setContents(
+ page,
+ teamId,
+ envelope.id,
+ Array.from({ length: 2 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
+ );
+
+ expect(withinLimit.ok()).toBeTruthy();
+ expect(await getContentCount(envelope.id)).toBe(2);
+
+ const overLimit = await setContents(
+ page,
+ teamId,
+ envelope.id,
+ Array.from({ length: 3 }).map((_, index) => ({ envelopeItemId, contentMeta: textMeta(index) })),
+ );
+
+ expect(overLimit.status()).toBe(400);
+ expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
+
+ // The rejected save left the envelope untouched.
+ expect(await getContentCount(envelope.id)).toBe(2);
+});
+
+test('rejects a save which exceeds the image content limit', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentImageCount: 1 });
+
+ const overLimit = await setContents(page, teamId, envelope.id, [
+ { envelopeItemId, contentMeta: imageMeta() },
+ { envelopeItemId, contentMeta: imageMeta() },
+ ]);
+
+ expect(overLimit.status()).toBe(400);
+ expect(await overLimit.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
+
+ expect(await getContentCount(envelope.id)).toBe(0);
+});
+
+/**
+ * An envelope can end up over its limit without being edited, e.g. after the
+ * organisation's plan is lowered, so removals must keep working.
+ */
+test('allows an over limit envelope to be trimmed back down', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, { envelopeContentCount: 2 });
+
+ await seedContents(envelope.id, envelopeItemId, 5);
+
+ const existing = await prisma.envelopeContent.findMany({
+ where: { envelopeId: envelope.id },
+ orderBy: { id: 'asc' },
+ });
+
+ // Removing one while still over the limit is allowed.
+ const trimmed = await setContents(
+ page,
+ teamId,
+ envelope.id,
+ existing.slice(0, 4).map((content) => ({
+ id: content.id,
+ envelopeItemId,
+ contentMeta: textMeta(0),
+ })),
+ );
+
+ expect(trimmed.ok()).toBeTruthy();
+ expect(await getContentCount(envelope.id)).toBe(4);
+
+ // Adding while over the limit is still refused.
+ const added = await setContents(page, teamId, envelope.id, [
+ ...existing.slice(0, 4).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
+ { envelopeItemId, contentMeta: textMeta(99) },
+ ]);
+
+ expect(added.status()).toBe(400);
+ expect(await getContentCount(envelope.id)).toBe(4);
+
+ // And once back within the allowance everything works normally again.
+ const backWithin = await setContents(
+ page,
+ teamId,
+ envelope.id,
+ existing.slice(0, 2).map((content) => ({ id: content.id, envelopeItemId, contentMeta: textMeta(0) })),
+ );
+
+ expect(backWithin.ok()).toBeTruthy();
+ expect(await getContentCount(envelope.id)).toBe(2);
+});
+
+test('rejects a stacking order above the authored ceiling', async ({ page }) => {
+ const { envelope, envelopeItemId, teamId } = await setupEnvelope(page, {});
+
+ const res = await setContents(page, teamId, envelope.id, [
+ { envelopeItemId, contentMeta: textMeta(0, { zIndex: CONTENT_MAX_Z_INDEX + 1 }) },
+ ]);
+
+ expect(res.ok()).toBeFalsy();
+ expect(await getContentCount(envelope.id)).toBe(0);
+});
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
new file mode 100644
index 000000000..fbc7b39b1
--- /dev/null
+++ b/packages/app-tests/e2e/api/v2/envelope-content-limit.spec.ts
@@ -0,0 +1,296 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
+import { DataContentType } from '@documenso/lib/types/data-content-meta';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type {
+ TCreateEnvelopePayload,
+ TCreateEnvelopeResponse,
+} from '@documenso/trpc/server/envelope-router/create-envelope.types';
+import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
+import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
+import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
+import type { Team, User } from '@prisma/client';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
+
+/**
+ * Set the content allowances on the organisation that owns the seeded team.
+ *
+ * A value of `0` means unlimited.
+ */
+const setOrganisationContentLimits = async (
+ team: Team,
+ limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
+) => {
+ const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
+ where: {
+ organisation: {
+ id: team.organisationId,
+ },
+ },
+ });
+
+ await prisma.organisationClaim.update({
+ where: {
+ id: organisationClaim.id,
+ },
+ data: limits,
+ });
+};
+
+const createEnvelope = async (request: APIRequestContext, authToken: string) => {
+ const payload: TCreateEnvelopePayload = {
+ type: EnvelopeType.DOCUMENT,
+ title: 'Envelope Content Limit Test',
+ };
+
+ const formData = new FormData();
+ formData.append('payload', JSON.stringify(payload));
+ formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
+
+ const res = await request.post(`${baseUrl}/envelope/create`, {
+ headers: { Authorization: `Bearer ${authToken}` },
+ multipart: formData,
+ });
+
+ expect(res.ok()).toBeTruthy();
+
+ return (await res.json()) as TCreateEnvelopeResponse;
+};
+
+/**
+ * Build a distributable envelope holding `contentCount` contents, of which
+ * `imageCount` are image contents, then attempt to distribute it.
+ *
+ * Contents are inserted directly since they are not part of the public API.
+ * Image contents are given an image so the send is not rejected for the
+ * unrelated reason of an image content having no image.
+ */
+const buildAndDistributeEnvelopeWithContents = async ({
+ request,
+ authToken,
+ contentCount,
+ imageCount = 0,
+}: {
+ request: APIRequestContext;
+ authToken: string;
+ contentCount: number;
+ imageCount?: number;
+}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
+ const envelope = await createEnvelope(request, authToken);
+
+ const envelopeRes = await request.get(`${baseUrl}/envelope/${envelope.id}`, {
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(envelopeRes.ok()).toBeTruthy();
+
+ const envelopeData = (await envelopeRes.json()) as TGetEnvelopeResponse;
+ const envelopeItemId = envelopeData.envelopeItems[0].id;
+
+ const recipientRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
+ data: {
+ envelopeId: envelope.id,
+ data: [
+ {
+ email: `recipient-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
+ name: 'Recipient',
+ role: RecipientRole.SIGNER,
+ accessAuth: [],
+ actionAuth: [],
+ },
+ ],
+ },
+ });
+
+ expect(recipientRes.ok()).toBeTruthy();
+
+ const [recipient] = (await recipientRes.json()).data;
+
+ const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
+ data: {
+ envelopeId: envelope.id,
+ data: [
+ {
+ recipientId: recipient.id,
+ envelopeItemId,
+ type: FieldType.SIGNATURE,
+ page: 1,
+ positionX: 10,
+ positionY: 60,
+ width: 30,
+ height: 8,
+ },
+ ],
+ },
+ });
+
+ expect(fieldsRes.ok()).toBeTruthy();
+
+ const dataContentIds = await Promise.all(
+ Array.from({ length: imageCount }).map(async () => {
+ const dataContent = await prisma.dataContent.create({
+ data: {
+ id: generateDatabaseId('data'),
+ type: 'BYTES_64',
+ data: 'aW1hZ2U=',
+ metadata: {
+ type: DataContentType.IMAGE,
+ width: 10,
+ height: 10,
+ mimeType: 'image/png',
+ fileName: 'image.png',
+ fileSize: 5,
+ },
+ },
+ });
+
+ return dataContent.id;
+ }),
+ );
+
+ await prisma.envelopeContent.createMany({
+ data: Array.from({ length: contentCount }).map((_, index) => {
+ const isImage = index < imageCount;
+
+ return {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId,
+ dataContentId: isImage ? dataContentIds[index] : null,
+ contentMeta: ZEnvelopeContentMetaSchema.parse(
+ isImage
+ ? {
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 10,
+ }
+ : {
+ type: EnvelopeContentType.TEXT,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 6,
+ text: `Content ${index}`,
+ },
+ ),
+ };
+ }),
+ });
+
+ const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
+ headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
+ data: {
+ envelopeId: envelope.id,
+ } satisfies TDistributeEnvelopeRequest,
+ });
+
+ return { envelopeId: envelope.id, distributeRes };
+};
+
+const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: envelopeId },
+ });
+
+ expect(envelope.status).toBe(status);
+};
+
+test.describe('Envelope content limits on distribute', () => {
+ let user: User;
+ let team: Team;
+ let token: string;
+
+ test.beforeEach(async () => {
+ ({ user, team } = await seedUser());
+ ({ token } = await createApiToken({
+ userId: user.id,
+ teamId: team.id,
+ tokenName: 'test-envelope-content-limit',
+ expiresIn: null,
+ }));
+ });
+
+ test('allows distribution when the content count is at the limit', async ({ request }) => {
+ await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
+
+ const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
+ request,
+ authToken: token,
+ contentCount: 3,
+ });
+
+ expect(distributeRes.status()).toBe(200);
+
+ await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
+ });
+
+ test('denies distribution when the content count is over the limit', async ({ request }) => {
+ await setOrganisationContentLimits(team, { envelopeContentCount: 3 });
+
+ const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
+ request,
+ authToken: token,
+ contentCount: 4,
+ });
+
+ expect(distributeRes.status()).toBe(400);
+ expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_LIMIT_EXCEEDED');
+
+ await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
+ });
+
+ test('denies distribution when the image content count is over the limit', async ({ request }) => {
+ await setOrganisationContentLimits(team, { envelopeContentImageCount: 1 });
+
+ const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
+ request,
+ authToken: token,
+ contentCount: 3,
+ imageCount: 2,
+ });
+
+ expect(distributeRes.status()).toBe(400);
+ expect(await distributeRes.text()).toContain('ENVELOPE_CONTENT_IMAGE_LIMIT_EXCEEDED');
+
+ await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
+ });
+
+ test('allows distribution when the limits are unlimited', async ({ request }) => {
+ await setOrganisationContentLimits(team, { envelopeContentCount: 0, envelopeContentImageCount: 0 });
+
+ const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithContents({
+ request,
+ authToken: token,
+ contentCount: 12,
+ imageCount: 6,
+ });
+
+ expect(distributeRes.status()).toBe(200);
+
+ await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
+ });
+});
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
new file mode 100644
index 000000000..809a6ccd9
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-canvas-zoom-selection.spec.ts
@@ -0,0 +1,86 @@
+import { nanoid } from '@documenso/lib/universal/id';
+import { expect, type Page, test } from '@playwright/test';
+
+import { getPageCanvas, placeContentOnPdf, selectContentOnCanvas, selectEditorTab } from '../fixtures/contents';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ getEnvelopeEditorSettingsTrigger,
+ openDocumentEnvelopeEditor,
+ type TEnvelopeEditorSurface,
+} from '../fixtures/envelope-editor';
+import { expectToastTextToBeVisible } from '../fixtures/generic';
+import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
+
+/**
+ * Zooming destroys and rebuilds the page's Konva stage. The selection is held
+ * as node references, so it has to be re-resolved against the rebuilt nodes,
+ * otherwise the handles and the settings panel are lost mid-edit.
+ */
+
+const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
+ const root = surface.root;
+
+ await getEnvelopeEditorSettingsTrigger(root).click();
+ await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
+ await root.locator('input[name="externalId"]').fill(`e2e-zoom-selection-${nanoid()}`);
+ await root.getByRole('button', { name: 'Update' }).click();
+ await expectToastTextToBeVisible(root, 'Envelope updated');
+ await root.getByTestId('toast-close').click();
+
+ await clickAddMyselfButton(root);
+
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+
+ await selectEditorTab(root, 'Contents');
+};
+
+const zoomIn = async (root: Page) => await root.locator('button[title="Zoom in"]').click();
+
+test('a selected field survives zooming', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const root = surface.root;
+
+ await clickAddMyselfButton(root);
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+
+ await root.getByRole('button', { name: 'Signature', exact: true }).click();
+ await getPageCanvas(root).click({ position: { x: 160, y: 160 } });
+
+ // A freshly placed field is auto selected, which deliberately suppresses
+ // the action bar, so click it to make it a real 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();
+
+ await zoomIn(root);
+
+ // The rebuilt stage must end up with the same field selected.
+ await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
+
+ // The action bar positions itself from the selected nodes, so it is only
+ // placed correctly while they are the live ones.
+ await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
+});
+
+test('a selected content survives zooming', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const root = surface.root;
+
+ await openContentsTab(surface);
+
+ await placeContentOnPdf(root, 'Text', { x: 160, y: 160 });
+
+ await selectContentOnCanvas(root, { x: 170, y: 170 });
+
+ await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
+ await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
+
+ await zoomIn(root);
+
+ 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-autosave-race.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-autosave-race.spec.ts
new file mode 100644
index 000000000..6fc0369a5
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-autosave-race.spec.ts
@@ -0,0 +1,267 @@
+import { nanoid } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+
+import {
+ getContentActionButton,
+ getContentGroupsForPage,
+ getPageCanvas,
+ getPageSize,
+ interactWithCanvasPastActionBar,
+ placeContentOnPdf,
+ selectEditorTab,
+ waitForContentsAutosave,
+} from '../fixtures/contents';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ getEnvelopeEditorSettingsTrigger,
+ openDocumentEnvelopeEditor,
+ type TEnvelopeEditorSurface,
+} from '../fixtures/envelope-editor';
+import { expectToastTextToBeVisible } from '../fixtures/generic';
+
+/**
+ * Editing a content while its first save is still on the wire must not make
+ * the server delete and recreate the row, which used to happen on every save
+ * from then on because the client kept the id of the deleted row.
+ */
+
+const CONTENT_SET_PROCEDURE = 'envelope.content.set';
+
+/**
+ * Hold the first `envelope.content.set` open so an edit can be made while it
+ * is in flight. Later saves are forwarded immediately.
+ */
+const installContentSetLag = async (page: Page, lagMs: number) => {
+ let markFirstInFlight: () => void = () => {};
+
+ const firstContentSetInFlight = new Promise((resolve) => {
+ markFirstInFlight = resolve;
+ });
+
+ const requestBodies: string[] = [];
+
+ await page.route('**/api/trpc/**', async (route) => {
+ const request = route.request();
+
+ if (request.method() !== 'POST' || !request.url().includes(CONTENT_SET_PROCEDURE)) {
+ await route.continue();
+ return;
+ }
+
+ const callIndex = requestBodies.length + 1;
+ requestBodies.push(request.postData() ?? '');
+
+ if (callIndex === 1) {
+ markFirstInFlight();
+
+ await new Promise((resolve) => setTimeout(resolve, lagMs));
+ }
+
+ await route.continue();
+ });
+
+ return { firstContentSetInFlight, requestBodies };
+};
+
+const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
+ const root = surface.root;
+
+ await getEnvelopeEditorSettingsTrigger(root).click();
+ await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
+ await root.locator('input[name="externalId"]').fill(`e2e-content-race-${nanoid()}`);
+ await root.getByRole('button', { name: 'Update' }).click();
+ await expectToastTextToBeVisible(root, 'Envelope updated');
+ await root.getByTestId('toast-close').click();
+
+ await clickAddMyselfButton(root);
+
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+
+ await selectEditorTab(root, 'Contents');
+};
+
+const getContentRows = async (envelopeId: string) =>
+ await prisma.envelopeContent.findMany({
+ where: { envelopeId },
+ select: { id: true, contentMeta: true },
+ });
+
+/**
+ * Drag the only content on the page by the given offset.
+ */
+const dragContent = async (root: Page, offsetX: number, offsetY: number) => {
+ const [content] = await getContentGroupsForPage(root);
+ const { scale } = await getPageSize(root);
+
+ const box = await getPageCanvas(root).boundingBox();
+
+ if (!box) {
+ throw new Error('Canvas bounding box not available');
+ }
+
+ const startX = box.x + (content.rect.x + content.rect.width / 2) * scale;
+ const startY = box.y + (content.rect.y + content.rect.height / 2) * scale;
+
+ await interactWithCanvasPastActionBar(root, async () => {
+ await root.mouse.move(startX, startY);
+ await root.mouse.down();
+ await root.mouse.move(startX + offsetX, startY + offsetY, { steps: 8 });
+ await root.mouse.up();
+ });
+};
+
+/**
+ * The same race on an image content, which used to be the worst case: the
+ * recreated row left the editor pointing at a data content the server had
+ * cleaned up, so every later save failed.
+ */
+test('editing a duplicated image content mid-save keeps saving', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const root = surface.root;
+
+ const envelopeId = surface.envelopeId;
+
+ if (!envelopeId) {
+ throw new Error('Expected the document editor surface to have an envelopeId');
+ }
+
+ await openContentsTab(surface);
+
+ await placeContentOnPdf(root, 'Image', { x: 150, y: 150 });
+
+ const png = await createCanvas(60, 30).encode('png');
+
+ await root
+ .locator('input[id^="content-image-input-"]')
+ .setInputFiles({ name: 'image.png', mimeType: 'image/png', buffer: png });
+
+ await waitForContentsAutosave(surface);
+ await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
+
+ const { firstContentSetInFlight } = await installContentSetLag(page, 4000);
+
+ // Duplicating clones the image server side, so the response carries a
+ // different data content id than was sent.
+ await getContentActionButton(root, 'Duplicate').click();
+
+ await firstContentSetInFlight;
+
+ await dragContent(root, 50, 30);
+
+ await waitForContentsAutosave(surface);
+
+ // A further edit must still save cleanly.
+ await dragContent(root, -20, 15);
+ await waitForContentsAutosave(surface);
+
+ await expect(root.getByText('Save failed')).toHaveCount(0);
+
+ const rows = await prisma.envelopeContent.findMany({
+ where: { envelopeId },
+ select: { id: true, dataContentId: true },
+ });
+
+ expect(rows).toHaveLength(2);
+
+ // Both contents keep their own image, and neither points at a deleted row.
+ const dataContentIds = rows.flatMap((row) => (row.dataContentId ? [row.dataContentId] : []));
+
+ expect(new Set(dataContentIds).size).toBe(2);
+
+ const dataContents = await prisma.dataContent.findMany({ where: { id: { in: dataContentIds } } });
+
+ expect(dataContents).toHaveLength(2);
+});
+
+/**
+ * Whatever the cause, if the editor ends up holding an ID the envelope no
+ * longer has, the server treats the content as new and the editor has to
+ * adopt the ID it gets back. Keeping the old one would make every later save
+ * create yet another row.
+ */
+test('a content whose row no longer exists recovers on the next save', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const root = surface.root;
+
+ const envelopeId = surface.envelopeId;
+
+ if (!envelopeId) {
+ throw new Error('Expected the document editor surface to have an envelopeId');
+ }
+
+ await openContentsTab(surface);
+
+ await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
+ await waitForContentsAutosave(surface);
+
+ await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
+
+ // Drop the row behind the editor's back, so the ID it holds is stale.
+ await prisma.envelopeContent.deleteMany({ where: { envelopeId } });
+
+ await dragContent(root, 40, 30);
+ await waitForContentsAutosave(surface);
+
+ const recreated = await getContentRows(envelopeId);
+
+ expect(recreated).toHaveLength(1);
+
+ // The editor must now be pointing at the recreated row, so a further edit
+ // updates it instead of creating a third one.
+ await dragContent(root, -20, 10);
+ await waitForContentsAutosave(surface);
+
+ const rowsAfterEdit = await getContentRows(envelopeId);
+
+ expect(rowsAfterEdit).toHaveLength(1);
+ expect(rowsAfterEdit[0].id).toBe(recreated[0].id);
+
+ await expect(root.getByText('Save failed')).toHaveCount(0);
+});
+
+test('editing a content while its first save is in flight keeps the same row', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const root = surface.root;
+
+ const envelopeId = surface.envelopeId;
+
+ if (!envelopeId) {
+ throw new Error('Expected the document editor surface to have an envelopeId');
+ }
+
+ await openContentsTab(surface);
+
+ const { firstContentSetInFlight, requestBodies } = await installContentSetLag(page, 4000);
+
+ await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
+
+ // Wait until the creating save is actually on the wire, then edit.
+ await firstContentSetInFlight;
+
+ await dragContent(root, 60, 40);
+
+ await waitForContentsAutosave(surface);
+ await expect.poll(async () => (await getContentRows(envelopeId)).length).toBe(1);
+
+ const [afterRace] = await getContentRows(envelopeId);
+
+ // A further edit must update that same row rather than replacing it.
+ await dragContent(root, -30, 20);
+ await waitForContentsAutosave(surface);
+
+ const rowsAfterEdit = await getContentRows(envelopeId);
+
+ expect(rowsAfterEdit).toHaveLength(1);
+ expect(rowsAfterEdit[0].id).toBe(afterRace.id);
+
+ await expect(root.getByText('Save failed')).toHaveCount(0);
+
+ // The save queued during the race must reference the row the first save
+ // created, rather than asking for another one to be created.
+ expect(requestBodies).toHaveLength(3);
+ expect(requestBodies[1]).toContain(afterRace.id);
+});
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
new file mode 100644
index 000000000..bd9fda437
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-image-upload-dialog.spec.ts
@@ -0,0 +1,181 @@
+import { prisma } from '@documenso/prisma';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+
+import {
+ getContentActionButton,
+ getContentGroupsForPage,
+ getPageCanvas,
+ placeContentOnPdf,
+ selectEditorTab,
+ waitForContentsAutosave,
+} from '../fixtures/contents';
+import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
+
+test.use({ storageState: { cookies: [], origins: [] } });
+
+/**
+ * Image uploads go through a dialog which blocks the editor until the upload
+ * settles, so there is only ever one in flight and it always lands on the
+ * content it was opened for.
+ */
+
+const createPng = async () => {
+ const canvas = createCanvas(80, 40);
+ const context = canvas.getContext('2d');
+
+ context.fillStyle = 'rgb(30, 120, 220)';
+ context.fillRect(0, 0, 80, 40);
+
+ return await canvas.encode('png');
+};
+
+const openContentsEditor = async (page: Page) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ await clickEnvelopeEditorStep(page, 'addFields');
+ await selectEditorTab(page, 'Contents');
+
+ return surface;
+};
+
+/**
+ * Click Upload / Replace on the action bar and hand the picker a file.
+ */
+const uploadThroughDialog = async (page: Page, buffer: Buffer) => {
+ const fileChooser = page.waitForEvent('filechooser');
+
+ await getContentActionButton(page, 'Upload image').or(getContentActionButton(page, 'Replace image')).click();
+
+ await (await fileChooser).setFiles({ name: 'logo.png', mimeType: 'image/png', buffer });
+};
+
+test('the dialog blocks the editor while an image uploads', async ({ page }) => {
+ const surface = await openContentsEditor(page);
+
+ await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
+ await waitForContentsAutosave(surface);
+
+ // Hold the upload open so the blocked state is observable.
+ let releaseUpload: () => void = () => {};
+ const uploadHeld = new Promise((resolve) => {
+ releaseUpload = resolve;
+ });
+
+ await page.route('**/api/trpc/envelope.content.uploadImage**', async (route) => {
+ await uploadHeld;
+ await route.continue();
+ });
+
+ await uploadThroughDialog(page, await createPng());
+
+ await expect(page.getByTestId('content-image-uploading')).toBeVisible();
+
+ // The dialog's overlay intercepts pointer events, so a normal click on the
+ // canvas cannot be performed.
+ await expect(getPageCanvas(page).click({ position: { x: 50, y: 50 }, timeout: 1500 })).rejects.toThrow();
+ await expect(page.getByTestId('content-image-uploading')).toBeVisible();
+
+ // Escape does not dismiss it either.
+ await page.keyboard.press('Escape');
+ await expect(page.getByTestId('content-image-uploading')).toBeVisible();
+
+ releaseUpload();
+
+ await expect(page.getByTestId('content-image-uploading')).toHaveCount(0);
+ await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
+});
+
+test('the upload lands on the content it was opened for', async ({ page }) => {
+ const surface = await openContentsEditor(page);
+
+ await placeContentOnPdf(page, 'Image', { x: 150, y: 150 });
+ await placeContentOnPdf(page, 'Image', { x: 350, y: 350 });
+ await waitForContentsAutosave(surface);
+
+ // The second placed content is the selected one.
+ await uploadThroughDialog(page, await createPng());
+
+ await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
+
+ const groups = await getContentGroupsForPage(page);
+ const withImage = groups.filter((group) => group.visibleChildren.includes('content-image'));
+
+ expect(withImage).toHaveLength(1);
+
+ // And it is persisted on that content, not the other.
+ const contents = await prisma.envelopeContent.findMany({
+ where: { envelopeId: surface.envelopeId },
+ });
+
+ const byStackingOrder = [...contents].sort((a, b) => a.contentMeta.zIndex - b.contentMeta.zIndex);
+
+ expect(byStackingOrder.map((content) => content.dataContentId !== null)).toEqual([false, true]);
+});
+
+/**
+ * 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.
+ */
+test('the picker can be reopened from the dialog 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 firstChooser = page.waitForEvent('filechooser');
+
+ await getContentActionButton(page, 'Upload image').click();
+ await firstChooser;
+
+ await expect(page.getByRole('button', { name: 'Choose image' })).toBeVisible();
+
+ // Pressing the dialog's button opens a fresh picker.
+ const secondChooser = page.waitForEvent('filechooser');
+
+ await page.getByRole('button', { name: 'Choose image' }).click();
+
+ await (await secondChooser).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 chooser = page.waitForEvent('filechooser');
+
+ await getContentActionButton(page, 'Upload image').click();
+ await chooser;
+
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ // 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);
+ await expect(getContentActionButton(page, 'Upload image')).toBeVisible();
+});
+
+test('a content placed and uploaded before autosave still receives its image', async ({ page }) => {
+ const surface = await openContentsEditor(page);
+
+ // No waitForContentsAutosave: the content has no server id yet when the
+ // upload is requested. The dialog flushes the save before uploading.
+ await placeContentOnPdf(page, 'Image', { x: 200, y: 200 });
+
+ await uploadThroughDialog(page, await createPng());
+
+ await expect(getContentActionButton(page, 'Replace image')).toBeVisible();
+
+ const content = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: surface.envelopeId } });
+
+ expect(content.dataContentId).not.toBeNull();
+});
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
new file mode 100644
index 000000000..98d38a5c2
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts
@@ -0,0 +1,439 @@
+import { nanoid } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+
+import {
+ getContentActionButton,
+ getContentGroupsForPage,
+ getPageCanvas,
+ getPageSize,
+ placeContentOnPdf,
+ selectEditorTab,
+ waitForContentsAutosave,
+} from '../fixtures/contents';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ getEnvelopeEditorSettingsTrigger,
+ openDocumentEnvelopeEditor,
+ openTemplateEnvelopeEditor,
+ type TEnvelopeEditorSurface,
+} from '../fixtures/envelope-editor';
+import { expectToastTextToBeVisible } from '../fixtures/generic';
+
+/**
+ * The default box an image content is dropped at, as a percentage of the page.
+ * Mirrors `CONTENT_IMAGE_DEFAULT_SIZE`.
+ */
+const IMAGE_DEFAULT_SIZE = { width: 15, height: 10 };
+
+/**
+ * Generate a solid color image of the given size in memory.
+ */
+const createImageFile = async (
+ name: string,
+ width: number,
+ height: number,
+ format: 'png' | 'jpeg' | 'webp' = 'png',
+) => {
+ const canvas = createCanvas(width, height);
+ const context = canvas.getContext('2d');
+
+ context.fillStyle = 'rgb(30, 120, 220)';
+ context.fillRect(0, 0, width, height);
+
+ const buffer = format === 'png' ? await canvas.encode('png') : await canvas.encode(format);
+
+ return { name, mimeType: `image/${format}`, buffer };
+};
+
+/**
+ * A minimal GIF, which is a real image in a format the editor does not accept.
+ */
+const createGifFile = (name: string) => ({
+ name,
+ mimeType: 'image/gif',
+ buffer: Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'),
+});
+
+const openSettingsDialog = async (root: Page) => {
+ await getEnvelopeEditorSettingsTrigger(root).click();
+ await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
+};
+
+const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ await openSettingsDialog(surface.root);
+ await surface.root.locator('input[name="externalId"]').fill(externalId);
+ await surface.root.getByRole('button', { name: 'Update' }).click();
+ await expectToastTextToBeVisible(surface.root, 'Envelope updated');
+ await surface.root.getByTestId('toast-close').click();
+};
+
+const openContentsTab = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ await updateExternalId(surface, externalId);
+ await clickAddMyselfButton(surface.root);
+
+ await clickEnvelopeEditorStep(surface.root, 'addFields');
+ await expect(getPageCanvas(surface.root)).toBeVisible();
+
+ await selectEditorTab(surface.root, 'Contents');
+ await expect(surface.root.getByRole('heading', { name: 'Add Content' })).toBeVisible();
+};
+
+type TestImageFile = { name: string; mimeType: string; buffer: Buffer };
+
+/**
+ * Upload through the action bar. Clicking Upload / Replace opens the upload
+ * dialog, which immediately opens the native picker; the file is set on that.
+ *
+ * Returns as soon as the file is handed over. Follow with
+ * `waitForUploadToLand` for a successful upload, or check the dialog's error
+ * text for a rejected one.
+ */
+const uploadImage = async (root: Page, file: TestImageFile) => {
+ const fileChooser = root.waitForEvent('filechooser');
+
+ await getContentActionButton(root, 'Upload image').or(getContentActionButton(root, 'Replace image')).click();
+
+ await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
+};
+
+/**
+ * Wait for a successful upload's dialog to close.
+ */
+const waitForUploadToLand = async (root: Page) => {
+ await expect(root.getByTestId('content-image-uploading')).toHaveCount(0);
+ await expect(root.getByRole('dialog')).toHaveCount(0);
+};
+
+/**
+ * Retry from the dialog's error state, which reopens the picker.
+ */
+const retryUpload = async (root: Page, file: TestImageFile) => {
+ const fileChooser = root.waitForEvent('filechooser');
+
+ await root.getByRole('button', { name: 'Try again' }).click();
+
+ await (await fileChooser).setFiles({ name: file.name, mimeType: file.mimeType, buffer: file.buffer });
+};
+
+const findEnvelopeWithContents = async (surface: TEnvelopeEditorSurface, externalId: string) =>
+ await prisma.envelope.findFirstOrThrow({
+ where: {
+ externalId,
+ userId: surface.userId,
+ teamId: surface.teamId,
+ type: surface.envelopeType,
+ },
+ orderBy: { createdAt: 'desc' },
+ include: {
+ contents: {
+ include: {
+ dataContent: true,
+ },
+ },
+ },
+ });
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
+
+const getMeta = (metadata: unknown): Record => {
+ if (!isRecord(metadata)) {
+ throw new Error('Metadata is not an object');
+ }
+
+ return metadata;
+};
+
+// --- Upload, replace and remove ---
+
+const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-content-images-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
+ await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
+ await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
+
+ // The image content is a placeholder before upload.
+ const [placeholder] = await getContentGroupsForPage(root);
+
+ expect(placeholder.visibleChildren).toContain('content-placeholder-rect');
+
+ // Upload a wide 2:1 image. The box is still the default size, so it is
+ // sized to the image at one point per pixel.
+ const wide = await createImageFile('wide-logo.png', 400, 200);
+
+ await uploadImage(root, wide);
+ await waitForUploadToLand(root);
+
+ await expect(root.getByText('wide-logo.png')).toBeVisible();
+ await expect(root.getByText('400 * 200')).toBeVisible();
+
+ const [withWide] = await getContentGroupsForPage(root);
+
+ expect(withWide.visibleChildren).toContain('content-image');
+ expect(withWide.visibleChildren).not.toContain('content-placeholder-rect');
+ expect(withWide.rect.width / withWide.rect.height).toBeCloseTo(2, 1);
+
+ await waitForContentsAutosave(surface);
+
+ const afterUpload = await findEnvelopeWithContents(surface, externalId);
+ const firstDataContentId = afterUpload.contents[0].dataContentId;
+
+ expect(firstDataContentId).not.toBeNull();
+ expect(getMeta(afterUpload.contents[0].dataContent?.metadata)).toMatchObject({
+ type: 'image',
+ width: 400,
+ height: 200,
+ mimeType: 'image/png',
+ fileName: 'wide-logo.png',
+ });
+
+ // The box is no longer the default size, so replacing with a tall image
+ // tightens the box around the fitted image rather than resizing to it.
+ const tall = await createImageFile('tall-logo.jpg', 100, 300, 'jpeg');
+
+ await uploadImage(root, tall);
+ await waitForUploadToLand(root);
+
+ await expect(root.getByText('tall-logo.jpg')).toBeVisible();
+
+ const [withTall] = await getContentGroupsForPage(root);
+
+ expect(withTall.rect.width / withTall.rect.height).toBeCloseTo(1 / 3, 1);
+ expect(withTall.rect.width).toBeLessThanOrEqual(withWide.rect.width + 0.5);
+ expect(withTall.rect.height).toBeLessThanOrEqual(withWide.rect.height + 0.5);
+
+ await waitForContentsAutosave(surface);
+
+ const afterReplace = await findEnvelopeWithContents(surface, externalId);
+ const secondDataContentId = afterReplace.contents[0].dataContentId;
+
+ expect(secondDataContentId).not.toBeNull();
+ expect(secondDataContentId).not.toBe(firstDataContentId);
+ expect(getMeta(afterReplace.contents[0].dataContent?.metadata)).toMatchObject({
+ mimeType: 'image/jpeg',
+ fileName: 'tall-logo.jpg',
+ });
+
+ // Data contents are never deleted: the replaced image's row remains, since
+ // another content (e.g. on a document created from this one) may still
+ // reference it.
+ expect(await prisma.dataContent.findUnique({ where: { id: firstDataContentId ?? '' } })).not.toBeNull();
+
+ // Remove returns to the drop zone and detaches the image, leaving the box.
+ await root.getByRole('button', { name: 'Remove image' }).click();
+ await expect(root.getByText('Click to upload or drag and drop')).toBeVisible();
+
+ const [afterRemoveGroup] = await getContentGroupsForPage(root);
+
+ expect(afterRemoveGroup.visibleChildren).toContain('content-placeholder-rect');
+ expect(afterRemoveGroup.rect.width).toBeCloseTo(withTall.rect.width, 0);
+
+ await waitForContentsAutosave(surface);
+
+ const afterRemove = await findEnvelopeWithContents(surface, externalId);
+
+ expect(afterRemove.contents[0].dataContentId).toBeNull();
+
+ // Removing detaches the content from its image; the data content itself is
+ // never deleted.
+ expect(await prisma.dataContent.findUnique({ where: { id: secondDataContentId ?? '' } })).not.toBeNull();
+
+ return { externalId };
+};
+
+// --- Upload via the action bar, auto size caps ---
+
+const runActionBarUploadFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-content-images-bar-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
+ await expect(getContentActionButton(root, 'Upload image')).toBeVisible();
+
+ // The bar's button opens the upload dialog, which opens the picker at once.
+ const fileChooser = root.waitForEvent('filechooser');
+
+ await getContentActionButton(root, 'Upload image').click();
+
+ const huge = await createImageFile('huge-logo.png', 2000, 2000);
+
+ await (await fileChooser).setFiles({ name: huge.name, mimeType: huge.mimeType, buffer: huge.buffer });
+
+ await expect(root.getByText('huge-logo.png')).toBeVisible();
+ await expect(getContentActionButton(root, 'Replace image')).toBeVisible();
+
+ const pageSize = await getPageSize(root);
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId, pageSize };
+};
+
+const assertActionBarUploadPersisted = async (
+ surface: TEnvelopeEditorSurface,
+ externalId: string,
+ pageSize: { width: number; height: number },
+) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+ const [imageContent] = envelope.contents;
+ const meta = getMeta(imageContent.contentMeta);
+
+ expect(imageContent.dataContent).not.toBeNull();
+
+ // A 2000px square exceeds the page, so its width is capped at 80% of the
+ // page width and the height follows the square ratio in page points, i.e.
+ // 80% of the page width expressed as a percentage of the page height.
+ expect(Number(meta.width)).toBeCloseTo(80, 0);
+ expect(Number(meta.height)).toBeCloseTo((80 * pageSize.width) / pageSize.height, 0);
+ expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100);
+ expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100);
+};
+
+// --- Rejected uploads ---
+
+const runRejectedUploadsFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-content-images-reject-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
+
+ // An unsupported format is rejected by the server's MIME allowlist. The
+ // dialog stays open showing why.
+ await uploadImage(root, createGifFile('animated.gif'));
+ await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
+
+ // Bytes which are not an image at all pass the type check and are rejected
+ // while the server decodes them.
+ await retryUpload(root, {
+ name: 'not-an-image.png',
+ mimeType: 'image/png',
+ buffer: Buffer.from('definitely not a png'),
+ });
+ await expect(root.getByText('This image could not be read. Use a PNG, JPEG or WebP file.')).toBeVisible();
+
+ // Give up, releasing the editor.
+ await root.getByRole('button', { name: 'Close' }).click();
+ await expect(root.getByRole('button', { name: 'Try again' })).toHaveCount(0);
+
+ // The placeholder is unaffected.
+ const [group] = await getContentGroupsForPage(root);
+
+ expect(group.visibleChildren).toContain('content-placeholder-rect');
+ expect(IMAGE_DEFAULT_SIZE.width).toBeGreaterThan(0);
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+const assertNoImageAttached = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ expect(envelope.contents).toHaveLength(1);
+ expect(envelope.contents[0].dataContentId).toBeNull();
+};
+
+// --- Send guard ---
+
+const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-content-images-guard-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Image', { x: 200, y: 200 });
+
+ // A signature field so the only thing blocking sending is the empty image.
+ await selectEditorTab(root, 'Fields');
+ await root.getByRole('button', { name: 'Signature', exact: true }).click();
+ await getPageCanvas(root).click({ position: { x: 200, y: 500 } });
+
+ await waitForContentsAutosave(surface);
+
+ await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
+ await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
+ await expect(root.getByText(/image content(s)? (has|have) no image/)).toBeVisible();
+ await expect(root.getByRole('button', { name: 'Send', exact: true })).toHaveCount(0);
+
+ await root.keyboard.press('Escape');
+
+ // Attach an image and the guard lifts.
+ await selectEditorTab(root, 'Contents');
+ await getPageCanvas(root).click({ position: { x: 200, y: 200 }, force: true });
+ await expect(root.getByRole('heading', { name: 'Image Settings' })).toBeVisible();
+
+ const logo = await createImageFile('logo.png', 120, 60);
+
+ await uploadImage(root, logo);
+ await waitForUploadToLand(root);
+ await expect(root.getByText('logo.png')).toBeVisible();
+
+ await waitForContentsAutosave(surface);
+
+ await root.getByRole('navigation').getByRole('button', { name: 'Send Document' }).click();
+ await expect(root.getByRole('heading', { name: 'Send Document' })).toBeVisible();
+ await expect(root.getByText(/image content(s)? (has|have) no image/)).toHaveCount(0);
+ await expect(root.getByRole('button', { name: 'Send', exact: true })).toBeVisible();
+};
+
+// --- Tests ---
+
+test.describe('document editor', () => {
+ test('upload, replace and remove a content image', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ await runUploadReplaceRemoveFlow(surface);
+ });
+
+ test('upload a content image from the canvas action bar', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId, pageSize } = await runActionBarUploadFlow(surface);
+
+ await assertActionBarUploadPersisted(surface, externalId, pageSize);
+ });
+
+ test('rejected uploads leave the placeholder untouched', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runRejectedUploadsFlow(surface);
+
+ await assertNoImageAttached(surface, externalId);
+ });
+
+ test('a document cannot be sent with an image content which has no image', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ await runSendGuardFlow(surface);
+ });
+});
+
+test.describe('template editor', () => {
+ test('upload, replace and remove a content image', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+
+ await runUploadReplaceRemoveFlow(surface);
+ });
+
+ test('upload a content image from the canvas action bar', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId, pageSize } = await runActionBarUploadFlow(surface);
+
+ await assertActionBarUploadPersisted(surface, externalId, pageSize);
+ });
+
+ test('rejected uploads leave the placeholder untouched', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runRejectedUploadsFlow(surface);
+
+ await assertNoImageAttached(surface, externalId);
+ });
+});
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
new file mode 100644
index 000000000..1741e0483
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-limits.spec.ts
@@ -0,0 +1,157 @@
+import { nanoid } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { expect, type Page, test } from '@playwright/test';
+
+import {
+ getContentActionButton,
+ getContentCountForPage,
+ getContentGroupsForPage,
+ getPageCanvas,
+ getPageSize,
+ placeContentOnPdf,
+ selectContentOnCanvas,
+ selectEditorTab,
+} from '../fixtures/contents';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ getEnvelopeEditorSettingsTrigger,
+ openDocumentEnvelopeEditor,
+ type TEnvelopeEditorSurface,
+} from '../fixtures/envelope-editor';
+import { expectToastTextToBeVisible } from '../fixtures/generic';
+
+/**
+ * The organisation's plan caps how many contents (and how many image
+ * contents) an envelope may hold. Reaching a limit hides the palette and the
+ * duplicate action, while existing contents stay editable and removable.
+ */
+
+const setOrganisationContentLimits = async (
+ teamId: number,
+ limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
+) => {
+ const team = await prisma.team.findFirstOrThrow({ where: { id: teamId } });
+
+ const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
+ where: { organisation: { id: team.organisationId } },
+ });
+
+ await prisma.organisationClaim.update({
+ where: { id: organisationClaim.id },
+ data: limits,
+ });
+};
+
+/**
+ * Open the editor for an organisation with the given content allowances.
+ *
+ * The claim is read from the organisation session, which is loaded when the
+ * app boots, so the limits are applied before the editor is reloaded.
+ */
+const openEditorWithContentLimits = async (
+ page: Page,
+ limits: { envelopeContentCount?: number; envelopeContentImageCount?: number },
+) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ await setOrganisationContentLimits(surface.teamId, limits);
+
+ await page.reload();
+
+ return surface;
+};
+
+const openContentsTab = async (surface: TEnvelopeEditorSurface) => {
+ const root = surface.root;
+
+ await getEnvelopeEditorSettingsTrigger(root).click();
+ await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
+ await root.locator('input[name="externalId"]').fill(`e2e-content-limit-${nanoid()}`);
+ await root.getByRole('button', { name: 'Update' }).click();
+ await expectToastTextToBeVisible(root, 'Envelope updated');
+ await root.getByTestId('toast-close').click();
+
+ await clickAddMyselfButton(root);
+
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+
+ await selectEditorTab(root, 'Contents');
+};
+
+const getLimitAlert = (root: Page) => root.getByTestId('content-limit-reached-alert');
+
+const getPaletteButton = (root: Page, name: string) => root.getByRole('button', { name, exact: true });
+
+test('content palette is hidden once the content limit is reached', async ({ page }) => {
+ const surface = await openEditorWithContentLimits(page, { envelopeContentCount: 2 });
+ const root = surface.root;
+
+ await openContentsTab(surface);
+
+ // Under the limit the palette is available.
+ await expect(getPaletteButton(root, 'Text')).toBeVisible();
+ await expect(getLimitAlert(root)).toHaveCount(0);
+
+ await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
+ await expect.poll(() => getContentCountForPage(root)).toBe(1);
+ await expect(getPaletteButton(root, 'Text')).toBeVisible();
+
+ // Reaching the limit replaces the whole palette with the message.
+ await placeContentOnPdf(root, 'Text', { x: 120, y: 220 });
+ await expect.poll(() => getContentCountForPage(root)).toBe(2);
+
+ await expect(getLimitAlert(root)).toBeVisible();
+ await expect(getLimitAlert(root)).toContainText('cannot have more than 2 contents');
+ await expect(getPaletteButton(root, 'Text')).toHaveCount(0);
+ await expect(getPaletteButton(root, 'Rectangle')).toHaveCount(0);
+});
+
+test('duplicate is hidden at the limit and existing contents stay removable', async ({ page }) => {
+ const surface = await openEditorWithContentLimits(page, { envelopeContentCount: 1 });
+ const root = surface.root;
+
+ await openContentsTab(surface);
+
+ await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
+ await expect.poll(() => getContentCountForPage(root)).toBe(1);
+
+ await expect(getLimitAlert(root)).toBeVisible();
+
+ 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 });
+
+ // Duplicating would add a content, so it is gone; removing is still offered.
+ await expect(getContentActionButton(root, 'Duplicate')).toHaveCount(0);
+ await expect(getContentActionButton(root, 'Remove')).toBeVisible();
+
+ await getContentActionButton(root, 'Remove').click();
+ await expect.poll(() => getContentCountForPage(root)).toBe(0);
+
+ // Back under the limit, the palette returns.
+ await expect(getLimitAlert(root)).toHaveCount(0);
+ await expect(getPaletteButton(root, 'Text')).toBeVisible();
+});
+
+test('image contents are limited separately from other contents', async ({ page }) => {
+ const surface = await openEditorWithContentLimits(page, {
+ envelopeContentCount: 0,
+ envelopeContentImageCount: 1,
+ });
+ const root = surface.root;
+
+ await openContentsTab(surface);
+
+ await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
+ await expect(getLimitAlert(root)).toHaveCount(0);
+
+ await placeContentOnPdf(root, 'Image', { x: 120, y: 260 });
+ await expect.poll(() => getContentCountForPage(root)).toBe(2);
+
+ // The image allowance is used up even though contents are unlimited.
+ await expect(getLimitAlert(root)).toBeVisible();
+ await expect(getLimitAlert(root)).toContainText('cannot have more than 1 image content');
+});
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
new file mode 100644
index 000000000..96886d0ea
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-zindex-ties.spec.ts
@@ -0,0 +1,110 @@
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { expect, type Page, test } from '@playwright/test';
+
+import { getContentGroupsForPage, getPageCanvas, selectEditorTab } from '../fixtures/contents';
+import { clickEnvelopeEditorStep, openDocumentEnvelopeEditor } from '../fixtures/envelope-editor';
+
+/**
+ * Contents sharing a `zIndex` are stacked by their database ID everywhere the
+ * document is rendered. The editor has to agree, otherwise what is authored
+ * is not what is sealed.
+ *
+ * Ties cannot be produced through the editor, which always places a content
+ * above the rest of its page, so they are seeded directly.
+ */
+
+const POSITIONS = [10, 25, 40, 55];
+
+const seedTiedContents = async (envelopeId: string) => {
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: envelopeId },
+ include: { envelopeItems: true },
+ });
+
+ await prisma.envelopeContent.createMany({
+ data: POSITIONS.map((positionX) => ({
+ id: generateDatabaseId('envelope_content'),
+ envelopeId,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.TEXT,
+ page: 1,
+ rotation: 0,
+ positionX,
+ positionY: 20,
+ width: 10,
+ height: 5,
+ text: `x${positionX}`,
+ }),
+ })),
+ });
+
+ const contents = await prisma.envelopeContent.findMany({
+ where: { envelopeId },
+ select: { id: true, contentMeta: true },
+ });
+
+ // The order every other renderer uses: zIndex, then database ID.
+ return [...contents]
+ .sort((a, b) => (a.id < b.id ? -1 : 1))
+ .map((content) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const contentMeta = content.contentMeta as { positionX?: number };
+
+ return contentMeta.positionX;
+ });
+};
+
+/**
+ * The horizontal positions of the page's contents, in the order they are
+ * stacked on the canvas.
+ */
+const getCanvasStackingOrder = async (root: Page) => {
+ const groups = await getContentGroupsForPage(root);
+ const { width } = await root.evaluate(() => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
+
+ return { width: (stage?.width() ?? 1) / (stage?.scaleX() ?? 1) };
+ });
+
+ return groups.map((group) =>
+ POSITIONS.reduce((closest, position) =>
+ Math.abs((position / 100) * width - group.rect.x) < Math.abs((closest / 100) * width - group.rect.x)
+ ? position
+ : closest,
+ ),
+ );
+};
+
+const openContentsTab = async (root: Page) => {
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+ await selectEditorTab(root, 'Contents');
+};
+
+test('contents sharing a zIndex stack by database ID in the editor', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ const envelopeId = surface.envelopeId;
+
+ if (!envelopeId) {
+ throw new Error('Expected the document editor surface to have an envelopeId');
+ }
+
+ const expectedOrder = await seedTiedContents(envelopeId);
+
+ // Reloaded repeatedly since the editor's own IDs are regenerated on each
+ // load, so a stacking order derived from them would vary between loads.
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await page.reload();
+ await openContentsTab(page);
+
+ await expect.poll(async () => (await getContentGroupsForPage(page)).length).toBe(POSITIONS.length);
+
+ expect(await getCanvasStackingOrder(page)).toEqual(expectedOrder);
+ }
+});
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts
new file mode 100644
index 000000000..be5c1f7ef
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts
@@ -0,0 +1,695 @@
+import { nanoid } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedPendingDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, type Page, test } from '@playwright/test';
+
+import { apiSignin } from '../fixtures/authentication';
+import {
+ getContentActionButton,
+ getContentCountForPage,
+ getContentGroupsForPage,
+ getFieldCountForPage,
+ getPageCanvas,
+ getPageSize,
+ interactWithCanvasPastActionBar,
+ placeContentOnPdf,
+ selectContentOnCanvas,
+ selectEditorTab,
+ waitForContentSelection,
+ waitForContentsAutosave,
+} from '../fixtures/contents';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ getEnvelopeEditorSettingsTrigger,
+ openDocumentEnvelopeEditor,
+ openEmbeddedEnvelopeEditor,
+ openTemplateEnvelopeEditor,
+ type TEnvelopeEditorSurface,
+} from '../fixtures/envelope-editor';
+import { expectToastTextToBeVisible } from '../fixtures/generic';
+import { getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
+
+/**
+ * Contents are only authored on the native document and template surfaces.
+ * The embedded editor persists through the public envelope API, which does
+ * not carry contents yet, so the tab is hidden there (see the embedded test).
+ */
+
+const openSettingsDialog = async (root: Page) => {
+ await getEnvelopeEditorSettingsTrigger(root).click();
+ await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
+};
+
+const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ await openSettingsDialog(surface.root);
+ await surface.root.locator('input[name="externalId"]').fill(externalId);
+ await surface.root.getByRole('button', { name: 'Update' }).click();
+ await expectToastTextToBeVisible(surface.root, 'Envelope updated');
+ await surface.root.getByTestId('toast-close').click();
+};
+
+/**
+ * Set up the editor on the fields step with the contents tab open.
+ */
+const openContentsTab = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ await updateExternalId(surface, externalId);
+ await clickAddMyselfButton(surface.root);
+
+ await clickEnvelopeEditorStep(surface.root, 'addFields');
+ await expect(getPageCanvas(surface.root)).toBeVisible();
+
+ await selectEditorTab(surface.root, 'Contents');
+ await expect(surface.root.getByRole('heading', { name: 'Add Content' })).toBeVisible();
+};
+
+const findEnvelopeWithContents = async (surface: TEnvelopeEditorSurface, externalId: string) =>
+ await prisma.envelope.findFirstOrThrow({
+ where: {
+ externalId,
+ userId: surface.userId,
+ teamId: surface.teamId,
+ type: surface.envelopeType,
+ },
+ orderBy: { createdAt: 'desc' },
+ include: {
+ contents: {
+ include: {
+ dataContent: true,
+ },
+ },
+ },
+ });
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
+
+const getMeta = (metadata: unknown): Record => {
+ if (!isRecord(metadata)) {
+ throw new Error('Content meta is not an object');
+ }
+
+ return metadata;
+};
+
+// --- Place and persist one of each content type ---
+
+const runPlaceAllContentTypesFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Text', { x: 120, y: 120 });
+ await placeContentOnPdf(root, 'Line', { x: 320, y: 120 });
+ await placeContentOnPdf(root, 'Rectangle', { x: 120, y: 260 });
+ await placeContentOnPdf(root, 'Highlight', { x: 320, y: 260 });
+ await placeContentOnPdf(root, 'Image', { x: 120, y: 420 });
+
+ expect(await getContentCountForPage(root)).toBe(5);
+
+ await waitForContentsAutosave(surface);
+
+ // Navigate away and back to verify the contents are reloaded.
+ await clickEnvelopeEditorStep(root, 'upload');
+ await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
+
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+ await selectEditorTab(root, 'Contents');
+
+ expect(await getContentCountForPage(root)).toBe(5);
+
+ return { externalId };
+};
+
+const assertAllContentTypesPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ expect(envelope.contents).toHaveLength(5);
+
+ const types = envelope.contents.map((content) => getMeta(content.contentMeta).type).sort();
+
+ expect(types).toEqual(['highlight', 'image', 'line', 'shape', 'text']);
+
+ const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
+
+ expect(getMeta(shape?.contentMeta).shape).toBe('rectangle');
+
+ for (const content of envelope.contents) {
+ const meta = getMeta(content.contentMeta);
+
+ expect(meta.page).toBe(1);
+
+ if (meta.type === 'line') {
+ for (const key of ['x1', 'x2', 'y1', 'y2']) {
+ expect(meta[key]).toBeGreaterThanOrEqual(0);
+ expect(meta[key]).toBeLessThanOrEqual(100);
+ }
+ } else {
+ expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100);
+ expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100);
+ }
+ }
+};
+
+// --- Edit settings via the sidebar ---
+
+const SIDEBAR_VALUES = {
+ text: 'Edited from the sidebar',
+ fontSize: '18',
+ strokeWidth: '3',
+};
+
+const runSidebarSettingsFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-sidebar-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ // Text: the content stays selected after being placed.
+ await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
+ await expect(root.getByRole('heading', { name: 'Text Settings' })).toBeVisible();
+
+ await root.getByTestId('content-form-text').fill(SIDEBAR_VALUES.text);
+ await root.getByTestId('field-form-fontSize').fill(SIDEBAR_VALUES.fontSize);
+
+ await root.getByTestId('field-form-textAlign').click();
+ await root.getByRole('option', { name: 'Center' }).click();
+
+ // Line.
+ await placeContentOnPdf(root, 'Line', { x: 300, y: 300 });
+ await expect(root.getByRole('heading', { name: 'Line Settings' })).toBeVisible();
+
+ await root.getByTestId('content-form-strokeWidth').fill(SIDEBAR_VALUES.strokeWidth);
+ await root.getByTestId('content-form-strokeStyle').click();
+ await root.getByRole('option', { name: 'Dashed' }).click();
+
+ // Rectangle: enable the fill.
+ await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 450 });
+ await expect(root.getByRole('heading', { name: 'Shape Settings' })).toBeVisible();
+
+ await root.getByTestId('content-form-fill').click();
+ await expect(root.getByText('Fill Color')).toBeVisible();
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+const assertSidebarSettingsPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ const text = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'text');
+ const line = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'line');
+ const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
+
+ expect(getMeta(text?.contentMeta)).toMatchObject({
+ text: SIDEBAR_VALUES.text,
+ fontSize: Number(SIDEBAR_VALUES.fontSize),
+ textAlign: 'center',
+ });
+
+ expect(getMeta(line?.contentMeta)).toMatchObject({
+ strokeWidth: Number(SIDEBAR_VALUES.strokeWidth),
+ strokeStyle: 'dashed',
+ });
+
+ expect(typeof getMeta(shape?.contentMeta).fillColor).toBe('string');
+};
+
+// --- Edit styles via the floating action bar ---
+
+const ACTION_BAR_VALUES = {
+ textColor: '#ff0000',
+ borderColor: '#0000ff',
+};
+
+/**
+ * Open a color action from the bar and type a hex value into its picker.
+ */
+const setActionBarColor = async (root: Page, title: string, hex: string) => {
+ await getContentActionButton(root, title).click();
+
+ const hexInput = root.locator('[role="dialog"] input, [data-radix-popper-content-wrapper] input').last();
+
+ await expect(hexInput).toBeVisible();
+ await hexInput.fill(hex.replace('#', ''));
+ await hexInput.press('Enter');
+
+ await root.keyboard.press('Escape');
+};
+
+const runActionBarStylesFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-bar-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Text', { x: 150, y: 150 });
+ await expect(getContentActionButton(root, 'Text color')).toBeVisible();
+
+ await setActionBarColor(root, 'Text color', ACTION_BAR_VALUES.textColor);
+
+ await getContentActionButton(root, 'Text align').click();
+ await root.getByRole('button', { name: 'Right', exact: true }).click();
+ await root.keyboard.press('Escape');
+
+ await getContentActionButton(root, 'Vertical align').click();
+ await root.getByRole('button', { name: 'Bottom', exact: true }).click();
+ await root.keyboard.press('Escape');
+
+ // The sidebar reflects the bar's changes.
+ await expect(root.getByText(ACTION_BAR_VALUES.textColor.toUpperCase())).toBeVisible();
+ await expect(root.getByTestId('field-form-textAlign')).toContainText('Right');
+
+ await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 400 });
+ await expect(getContentActionButton(root, 'Border color')).toBeVisible();
+
+ await setActionBarColor(root, 'Border color', ACTION_BAR_VALUES.borderColor);
+
+ // Only single selections get style actions.
+ await selectContentOnCanvas(root, { x: 150, y: 150 }, { shift: true });
+ expect(await getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
+ await expect(getContentActionButton(root, 'Text color')).toHaveCount(0);
+ await expect(getContentActionButton(root, 'Border color')).toHaveCount(0);
+ await expect(getContentActionButton(root, 'Duplicate')).toBeVisible();
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+const assertActionBarStylesPersisted = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ const text = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'text');
+ const shape = envelope.contents.find((content) => getMeta(content.contentMeta).type === 'shape');
+
+ expect(getMeta(text?.contentMeta)).toMatchObject({
+ color: ACTION_BAR_VALUES.textColor,
+ textAlign: 'right',
+ verticalAlign: 'bottom',
+ });
+
+ expect(getMeta(shape?.contentMeta)).toMatchObject({
+ strokeColor: ACTION_BAR_VALUES.borderColor,
+ });
+};
+
+// --- Duplicate and remove via the action bar ---
+
+const runDuplicateAndRemoveFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-actions-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Rectangle', { x: 150, y: 150 });
+ expect(await getContentCountForPage(root)).toBe(1);
+
+ const [original] = await getContentGroupsForPage(root);
+
+ await getContentActionButton(root, 'Duplicate').click();
+ expect(await getContentCountForPage(root)).toBe(2);
+
+ // The duplicate is offset from the original so it does not fully overlap.
+ const groups = await getContentGroupsForPage(root);
+ const duplicate = groups.find((group) => group.id !== original.id);
+
+ expect(duplicate).toBeDefined();
+ expect(duplicate?.rect.x).toBeGreaterThan(original.rect.x);
+ expect(duplicate?.rect.y).toBeGreaterThan(original.rect.y);
+
+ await waitForContentsAutosave(surface);
+
+ const afterDuplicate = await findEnvelopeWithContents(surface, externalId);
+
+ expect(afterDuplicate.contents).toHaveLength(2);
+
+ // Multi-select both via shift click and remove them together. The
+ // duplicate is offset down and right of the original and overlaps it, so
+ // each is clicked where only it is. Points sit at the top edge of their
+ // box: the selected content's floating action bar hangs just below its
+ // bottom edge, and a click near there would hit the bar's buttons instead.
+ const { scale } = await getPageSize(root);
+
+ if (!duplicate) {
+ throw new Error('Duplicate not found');
+ }
+
+ const duplicateOnly = {
+ x: (duplicate.rect.x + duplicate.rect.width - 4) * scale,
+ y: (duplicate.rect.y + 4) * scale,
+ };
+
+ const originalOnly = {
+ x: (original.rect.x + 4) * scale,
+ y: (original.rect.y + 4) * scale,
+ };
+
+ await selectContentOnCanvas(root, duplicateOnly);
+ await selectContentOnCanvas(root, originalOnly, { shift: true });
+ expect(await getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
+
+ await getContentActionButton(root, 'Remove').click();
+ expect(await getContentCountForPage(root)).toBe(0);
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+const assertContentsRemoved = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ expect(envelope.contents).toHaveLength(0);
+};
+
+// --- Fields auto hide on the contents tab ---
+
+const runFieldsAutoHideFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-hide-${nanoid()}`;
+ const root = surface.root;
+
+ await updateExternalId(surface, externalId);
+ await clickAddMyselfButton(root);
+
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await expect(getPageCanvas(root)).toBeVisible();
+
+ await root.getByRole('button', { name: 'Signature', exact: true }).click();
+ await getPageCanvas(root).click({ position: { x: 150, y: 150 } });
+ expect(await getFieldCountForPage(root)).toBe(1);
+
+ /**
+ * Whether every field group on the page satisfies the given Konva check.
+ */
+ const everyFieldGroup = async (check: 'isVisible' | 'isListening') => {
+ const results = await root.evaluate(
+ ({ check }) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
+
+ return stage?.find('.field-group').map((node) => node[check]()) ?? [];
+ },
+ { check },
+ );
+
+ return results.length > 0 && results.every(Boolean);
+ };
+
+ expect(await everyFieldGroup('isVisible')).toBe(true);
+
+ await selectEditorTab(root, 'Contents');
+ expect(await everyFieldGroup('isVisible')).toBe(false);
+ await expect(root.locator('button[title="Show fields"]')).toBeVisible();
+
+ await selectEditorTab(root, 'Fields');
+ expect(await everyFieldGroup('isVisible')).toBe(true);
+ await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
+
+ // Showing the fields from the toolbar while on the contents tab mutes them
+ // (visible but not interactive). Hiding them again and returning to the
+ // fields tab must hand the interactivity back.
+ await selectEditorTab(root, 'Contents');
+ await root.locator('button[title="Show fields"]').click();
+ await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
+ expect(await everyFieldGroup('isListening')).toBe(false);
+
+ await root.locator('button[title="Hide fields"]').click();
+ await expect(root.locator('button[title="Show fields"]')).toBeVisible();
+
+ await selectEditorTab(root, 'Fields');
+ expect(await everyFieldGroup('isVisible')).toBe(true);
+ expect(await everyFieldGroup('isListening')).toBe(true);
+
+ // Hiding the contents from the toolbar is an override for the current tab
+ // only. Switching tabs in either direction resets contents to visible.
+ await root.locator('button[title="Hide contents"]').click();
+ await expect(root.locator('button[title="Show contents"]')).toBeVisible();
+
+ await selectEditorTab(root, 'Contents');
+ await expect(root.locator('button[title="Hide contents"]')).toBeVisible();
+
+ await root.locator('button[title="Hide contents"]').click();
+ await expect(root.locator('button[title="Show contents"]')).toBeVisible();
+
+ await selectEditorTab(root, 'Fields');
+ await expect(root.locator('button[title="Hide contents"]')).toBeVisible();
+ await expect(root.locator('button[title="Hide fields"]')).toBeVisible();
+
+ // And the field can actually be selected by clicking it.
+ const fieldRect = await root.evaluate(() => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
+ const group = stage?.findOne('.field-group');
+
+ if (!stage || !group) {
+ throw new Error('Field group not found');
+ }
+
+ const rect = group.getClientRect();
+
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ });
+
+ await getPageCanvas(root).click({ position: fieldRect });
+ await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
+};
+
+// --- Resize is pinned to the page ---
+
+const runResizePinnedToPageFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-resize-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ await placeContentOnPdf(root, 'Rectangle', { x: 200, y: 200 });
+
+ const canvas = getPageCanvas(root);
+ const box = await canvas.boundingBox();
+
+ if (!box) {
+ throw new Error('Canvas bounding box not available');
+ }
+
+ const [group] = await getContentGroupsForPage(root);
+ const { scale } = await getPageSize(root);
+
+ // The anchors only exist once the placed content is selected.
+ await waitForContentSelection(root, [group.id]);
+
+ // Grab the bottom right anchor and drag it well past the page's bottom
+ // right corner. The mouse leaves the page, the box must not.
+ const anchorX = box.x + (group.rect.x + group.rect.width) * scale;
+ const anchorY = box.y + (group.rect.y + group.rect.height) * scale;
+
+ await interactWithCanvasPastActionBar(root, async () => {
+ await root.mouse.move(anchorX, anchorY);
+ await root.mouse.down();
+ await root.mouse.move(box.x + box.width + 200, box.y + box.height + 200, { steps: 10 });
+ await root.mouse.up();
+ });
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+const assertResizePinnedToPage = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ expect(envelope.contents).toHaveLength(1);
+
+ const meta = getMeta(envelope.contents[0].contentMeta);
+
+ // The box grew in both directions and stopped at the page edges. The pixel
+ // to percent conversion leaves floating point noise, hence the tolerance.
+ expect(Number(meta.positionX) + Number(meta.width)).toBeCloseTo(100, 3);
+ expect(Number(meta.positionY) + Number(meta.height)).toBeCloseTo(100, 3);
+};
+
+// --- Selecting brings to front ---
+
+const runSelectBringsToFrontFlow = async (surface: TEnvelopeEditorSurface) => {
+ const externalId = `e2e-contents-zindex-${nanoid()}`;
+ const root = surface.root;
+
+ await openContentsTab(surface, externalId);
+
+ // Two overlapping rectangles. The second is created on top.
+ await placeContentOnPdf(root, 'Rectangle', { x: 200, y: 200 });
+ await placeContentOnPdf(root, 'Rectangle', { x: 230, y: 220 });
+
+ const [first, second] = await getContentGroupsForPage(root);
+
+ // Konva renders children in order, so the later child is on top.
+ const orderAfterPlacing = await getContentGroupOrder(root);
+
+ expect(orderAfterPlacing.indexOf(second.id)).toBeGreaterThan(orderAfterPlacing.indexOf(first.id));
+
+ // Select the first where the second does not cover it: it comes to the front.
+ const { scale } = await getPageSize(root);
+
+ await selectContentOnCanvas(root, { x: (first.rect.x + 4) * scale, y: (first.rect.y + 4) * scale });
+
+ const orderAfterSelecting = await getContentGroupOrder(root);
+
+ expect(orderAfterSelecting.indexOf(first.id)).toBeGreaterThan(orderAfterSelecting.indexOf(second.id));
+
+ await waitForContentsAutosave(surface);
+
+ return { externalId };
+};
+
+/**
+ * The content group ids of a page in their current Konva child order.
+ */
+const getContentGroupOrder = async (root: Page) => (await getContentGroupsForPage(root)).map((group) => group.id);
+
+const assertSelectedContentPersistedOnTop = async (surface: TEnvelopeEditorSurface, externalId: string) => {
+ const envelope = await findEnvelopeWithContents(surface, externalId);
+
+ expect(envelope.contents).toHaveLength(2);
+
+ // The first rectangle was placed further left, so it is the one at the
+ // smaller x. Having been selected, it must now be above the second.
+ const [first, second] = [...envelope.contents].sort(
+ (a, b) => Number(getMeta(a.contentMeta).positionX) - Number(getMeta(b.contentMeta).positionX),
+ );
+
+ expect(first.contentMeta.zIndex).toBeGreaterThan(second.contentMeta.zIndex);
+};
+
+// --- Tests ---
+
+test.describe('document editor', () => {
+ test('place and persist one of each content type', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runPlaceAllContentTypesFlow(surface);
+
+ await assertAllContentTypesPersisted(surface, externalId);
+ });
+
+ test('edit content settings via the sidebar', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runSidebarSettingsFlow(surface);
+
+ await assertSidebarSettingsPersisted(surface, externalId);
+ });
+
+ test('edit content styles via the canvas action bar', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runActionBarStylesFlow(surface);
+
+ await assertActionBarStylesPersisted(surface, externalId);
+ });
+
+ test('duplicate and remove contents via the canvas action bar', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runDuplicateAndRemoveFlow(surface);
+
+ await assertContentsRemoved(surface, externalId);
+ });
+
+ test('fields are hidden while the contents tab is active', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+
+ await runFieldsAutoHideFlow(surface);
+ });
+
+ test('resizing a content is pinned to the page bounds', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runResizePinnedToPageFlow(surface);
+
+ await assertResizePinnedToPage(surface, externalId);
+ });
+
+ test('selecting a content brings it to the front', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { externalId } = await runSelectBringsToFrontFlow(surface);
+
+ await assertSelectedContentPersistedOnTop(surface, externalId);
+ });
+
+ test('contents cannot be added once the document has been sent', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
+
+ await apiSignin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=addFields`,
+ });
+
+ await expect(getPageCanvas(page)).toBeVisible();
+ await selectEditorTab(page, 'Contents');
+
+ await expect(page.getByRole('alert').filter({ hasText: 'Content cannot be changed' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Rectangle', exact: true })).toHaveCount(0);
+ });
+});
+
+test.describe('template editor', () => {
+ test('place and persist one of each content type', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runPlaceAllContentTypesFlow(surface);
+
+ await assertAllContentTypesPersisted(surface, externalId);
+ });
+
+ test('edit content settings via the sidebar', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runSidebarSettingsFlow(surface);
+
+ await assertSidebarSettingsPersisted(surface, externalId);
+ });
+
+ test('edit content styles via the canvas action bar', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runActionBarStylesFlow(surface);
+
+ await assertActionBarStylesPersisted(surface, externalId);
+ });
+
+ test('duplicate and remove contents via the canvas action bar', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runDuplicateAndRemoveFlow(surface);
+
+ await assertContentsRemoved(surface, externalId);
+ });
+
+ test('fields are hidden while the contents tab is active', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+
+ await runFieldsAutoHideFlow(surface);
+ });
+
+ test('resizing a content is pinned to the page bounds', async ({ page }) => {
+ const surface = await openTemplateEnvelopeEditor(page);
+ const { externalId } = await runResizePinnedToPageFlow(surface);
+
+ await assertResizePinnedToPage(surface, externalId);
+ });
+});
+
+test.describe('embedded editor', () => {
+ test('contents tab is not available', async ({ page }) => {
+ const surface = await openEmbeddedEnvelopeEditor(page, {
+ envelopeType: 'DOCUMENT',
+ mode: 'edit',
+ tokenNamePrefix: 'e2e-embed-contents',
+ });
+
+ await clickEnvelopeEditorStep(surface.root, 'addFields');
+ await expect(getPageCanvas(surface.root)).toBeVisible();
+
+ await expect(surface.root.getByRole('tab', { name: 'Contents' })).toHaveCount(0);
+ await expect(surface.root.getByRole('button', { name: 'Rectangle', exact: true })).toHaveCount(0);
+ });
+});
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
new file mode 100644
index 000000000..805985c14
--- /dev/null
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-replace-pdf-contents.spec.ts
@@ -0,0 +1,119 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { expect, type Page, test } from '@playwright/test';
+import {
+ getContentCountForPage,
+ placeContentOnPdf,
+ selectEditorTab,
+ waitForContentsAutosave,
+} from '../fixtures/contents';
+import {
+ clickEnvelopeEditorStep,
+ getEnvelopeItemReplaceButtons,
+ openDocumentEnvelopeEditor,
+} from '../fixtures/envelope-editor';
+
+test.use({
+ storageState: {
+ cookies: [],
+ origins: [],
+ },
+});
+
+const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
+
+const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
+
+const replacePdf = async (root: Page, buffer: Buffer, name: string) => {
+ const replaceButton = getEnvelopeItemReplaceButtons(root).nth(0);
+ await expect(replaceButton).toBeVisible();
+
+ const [fileChooser] = await Promise.all([root.waitForEvent('filechooser'), replaceButton.click()]);
+
+ await fileChooser.setFiles({ name, mimeType: 'application/pdf', buffer });
+
+ await expect(replaceButton).toBeDisabled({ timeout: 15000 });
+ await expect(replaceButton).toBeEnabled({ timeout: 15000 });
+};
+
+const seedContent = async (envelopeId: string, envelopeItemId: string, page: number) =>
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId,
+ envelopeItemId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.TEXT,
+ page,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 6,
+ text: `Page ${page}`,
+ }),
+ },
+ });
+
+const countContents = async (envelopeId: string) => await prisma.envelopeContent.count({ where: { envelopeId } });
+
+/**
+ * Replacing a PDF deletes the contents which sit on pages the new file does
+ * not have. The editor keeps its own copy of the contents, so unless that copy
+ * is resynced the next save posts the deleted content back and the server,
+ * finding no content with that id, creates it again on a page which no longer
+ * exists.
+ */
+test('does not recreate contents removed by a pdf replacement', async ({ page }) => {
+ const surface = await openDocumentEnvelopeEditor(page);
+ const { root, envelopeId } = surface;
+
+ // A three page PDF so a content can live on a page the replacement drops.
+ await replacePdf(root, multiPagePdfBuffer, 'multi-page.pdf');
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: envelopeId },
+ include: { envelopeItems: true },
+ });
+
+ const envelopeItemId = envelope.envelopeItems[0].id;
+
+ await seedContent(envelopeId, envelopeItemId, 1);
+ await seedContent(envelopeId, envelopeItemId, 3);
+
+ expect(await countContents(envelopeId)).toBe(2);
+
+ // Reload so the editor loads both contents into its local state.
+ await root.reload();
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await selectEditorTab(root, 'Contents');
+
+ await expect.poll(async () => await getContentCountForPage(root, 1)).toBe(1);
+
+ // Replace with a single page PDF, dropping the content on page 3.
+ await clickEnvelopeEditorStep(root, 'upload');
+ await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
+
+ await replacePdf(root, examplePdfBuffer, 'single-page.pdf');
+
+ await expect.poll(async () => await countContents(envelopeId)).toBe(1);
+
+ // Any later save posts every content the editor is holding, so placing one
+ // is enough to resurrect a stale content.
+ await clickEnvelopeEditorStep(root, 'addFields');
+ await selectEditorTab(root, 'Contents');
+
+ await placeContentOnPdf(root, 'Text', { x: 200, y: 260 });
+
+ await waitForContentsAutosave(surface);
+
+ // The placed content, and nothing else.
+ expect(await countContents(envelopeId)).toBe(2);
+
+ const pages = await prisma.envelopeContent.findMany({ where: { envelopeId } });
+
+ expect(pages.every((content) => (content.contentMeta.page ?? 1) === 1)).toBe(true);
+});
diff --git a/packages/app-tests/e2e/envelopes/envelope-content-alignment.spec.ts b/packages/app-tests/e2e/envelopes/envelope-content-alignment.spec.ts
new file mode 100644
index 000000000..259cdb267
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-content-alignment.spec.ts
@@ -0,0 +1,236 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
+import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
+import { prisma } from '@documenso/prisma';
+import { seedContentAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import type { APIRequestContext, Page, TestInfo } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+import { DocumentStatus } from '@prisma/client';
+import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
+import pixelMatch from 'pixelmatch';
+import { PNG } from 'pngjs';
+
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
+import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
+import { apiSignin } from '../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
+
+const VISUAL_REGRESSION_DIR = path.join(__dirname, '../../visual-regression');
+const ITEM_TITLE = 'content-alignment-pdf';
+
+test.describe.configure({ mode: 'parallel', timeout: 90000 });
+
+/**
+ * Seed the content alignment document, then run it through distribution and
+ * signing so the contents are imprinted by the real seal job.
+ *
+ * Returns the sealed PDF bytes.
+ */
+const sealContentAlignmentDocument = async ({ page, request }: { page: Page; request: APIRequestContext }) => {
+ const { user, team } = await seedUser();
+
+ const { token } = await createApiToken({
+ userId: user.id,
+ teamId: team.id,
+ tokenName: 'test',
+ expiresIn: null,
+ });
+
+ const envelope = await seedContentAlignmentTestDocument({
+ userId: user.id,
+ teamId: team.id,
+ recipientName: user.name || '',
+ recipientEmail: user.email,
+ status: DocumentStatus.DRAFT,
+ });
+
+ const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
+ headers: { Authorization: `Bearer ${token}` },
+ data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
+ });
+
+ expect(distributeEnvelopeRequest.ok()).toBeTruthy();
+
+ // Pre-insert the signature field so completing does not depend on the
+ // signing UI. This test is about the contents, not the fields.
+ const signatureField = await prisma.field.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ await prisma.field.update({
+ where: { id: signatureField.id },
+ data: {
+ inserted: true,
+ customText: '',
+ signature: {
+ create: {
+ recipientId: envelope.recipients[0].id,
+ typedSignature: 'Content Alignment',
+ },
+ },
+ },
+ });
+
+ // Sign as the recipient so the seal job runs.
+ const recipientToken = envelope.recipients[0].token;
+ const signUrl = `/sign/${recipientToken}`;
+
+ await apiSignin({ page, email: user.email, redirectPath: signUrl });
+
+ await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Complete' }).click();
+ await page.getByRole('button', { name: 'Sign' }).click();
+ await page.waitForURL(`${signUrl}/complete`);
+
+ await expect(async () => {
+ const { status } = await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id } });
+
+ expect(status).toBe(DocumentStatus.COMPLETED);
+ }).toPass({ timeout: 20000 });
+
+ const completed = await prisma.envelope.findFirstOrThrow({
+ where: { id: envelope.id },
+ include: { envelopeItems: { include: { documentData: true } } },
+ });
+
+ const documentUrl = getEnvelopeItemPdfUrl({
+ type: 'download',
+ envelopeItem: completed.envelopeItems[0],
+ token: recipientToken,
+ version: 'signed',
+ });
+
+ const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
+
+ return new Uint8Array(pdfData);
+};
+
+test('content placement visual regression', async ({ page, request }, testInfo) => {
+ const pdfData = await sealContentAlignmentDocument({ page, request });
+
+ const storedImages = fs
+ .readdirSync(VISUAL_REGRESSION_DIR)
+ .filter((image) => image.startsWith(`${ITEM_TITLE}-`))
+ .sort()
+ .map((image) => fs.readFileSync(path.join(VISUAL_REGRESSION_DIR, image)));
+
+ expect(storedImages.length, 'Reference images missing, run the "download" test to generate them').toBeGreaterThan(0);
+
+ await compareSealedPdfWithImages({ id: ITEM_TITLE, pdfData, images: storedImages, testInfo });
+});
+
+/**
+ * Used to regenerate the reference images when the rendering legitimately
+ * changes. Inspect the output carefully before committing.
+ *
+ * DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
+ */
+test.skip('download content alignment images', async ({ page, request }) => {
+ const pdfData = await sealContentAlignmentDocument({ page, request });
+
+ const pdfImages = await renderPdfToImage(pdfData);
+
+ // The last page is the dynamic certificate, which is not compared.
+ for (const [index, { image }] of pdfImages.slice(0, -1).entries()) {
+ fs.writeFileSync(path.join(VISUAL_REGRESSION_DIR, `${ITEM_TITLE}-${index}.png`), new Uint8Array(image));
+ }
+});
+
+async function renderPdfToImage(pdfBytes: Uint8Array) {
+ const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
+ const pdf = await loadingTask.promise;
+
+ // Increase for higher resolution
+ const scale = 4;
+
+ return await Promise.all(
+ Array.from({ length: pdf.numPages }, async (_, index) => {
+ const page = await pdf.getPage(index + 1);
+ const viewport = page.getViewport({ scale });
+
+ const canvas = createCanvas(viewport.width, viewport.height);
+ const canvasContext = canvas.getContext('2d');
+
+ canvasContext.imageSmoothingEnabled = false;
+
+ await page.render({
+ // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
+ canvas,
+ // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
+ canvasContext,
+ viewport,
+ }).promise;
+
+ return {
+ image: await canvas.encode('png'),
+ width: Math.floor(viewport.width),
+ height: Math.floor(viewport.height),
+ };
+ }),
+ );
+}
+
+type CompareSealedPdfWithImagesOptions = {
+ id: string;
+ pdfData: Uint8Array;
+ images: Buffer[];
+ testInfo: TestInfo;
+};
+
+/**
+ * Whether a rendered page has any non white pixels.
+ */
+const hasInk = (rgba: Uint8Array) => {
+ for (let index = 0; index < rgba.length; index += 4) {
+ if (rgba[index] < 250 || rgba[index + 1] < 250 || rgba[index + 2] < 250) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+const compareSealedPdfWithImages = async ({ id, pdfData, images, testInfo }: CompareSealedPdfWithImagesOptions) => {
+ const renderedImages = await renderPdfToImage(pdfData);
+
+ // Every content page must have a reference.
+ expect(images).toHaveLength(renderedImages.length - 1);
+
+ for (const [index, { image, width, height }] of renderedImages.entries()) {
+ const isCertificate = index === renderedImages.length - 1;
+
+ const newImage = new Uint8Array(PNG.sync.read(image).data);
+
+ fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-new.png`), new Uint8Array(image));
+
+ if (isCertificate) {
+ // The certificate is dynamic (dates, ids), so only assert it rendered.
+ expect(hasInk(newImage)).toBe(true);
+ continue;
+ }
+
+ const diff = new PNG({ width, height });
+ const oldImage = new Uint8Array(PNG.sync.read(images[index]).data);
+
+ const comparison = pixelMatch(
+ oldImage,
+ newImage,
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ diff.data as unknown as Uint8Array,
+ width,
+ height,
+ { threshold: 0.25 },
+ );
+
+ console.log(`${id}-${index}: ${comparison}`);
+
+ fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-diff.png`), new Uint8Array(PNG.sync.write(diff)));
+ fs.writeFileSync(path.join(testInfo.outputPath(), `${id}-${index}-old.png`), new Uint8Array(images[index]));
+
+ expect(comparison).toBe(0);
+ }
+};
diff --git a/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts b/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts
new file mode 100644
index 000000000..312748b9a
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-content-signing.spec.ts
@@ -0,0 +1,78 @@
+import {
+ EnvelopeContentShapeType,
+ EnvelopeContentType,
+ ZEnvelopeContentMetaSchema,
+} from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, test } from '@playwright/test';
+
+import { apiSignin } from '../fixtures/authentication';
+import { getContentGroupsForPage, getPageCanvas } from '../fixtures/contents';
+
+/**
+ * Seed a text content and a rectangle onto the first page of an envelope.
+ */
+const seedStaticContents = async (envelopeId: string, envelopeItemId: string) => {
+ await prisma.envelopeContent.createMany({
+ data: [
+ {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId,
+ envelopeItemId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.TEXT,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 40,
+ height: 6,
+ text: 'Signing content',
+ }),
+ },
+ {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId,
+ envelopeItemId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 20,
+ width: 40,
+ height: 10,
+ strokeWidth: 2,
+ strokeColor: '#d00000',
+ strokeStyle: 'solid',
+ }),
+ },
+ ],
+ });
+};
+
+test('contents are not rendered again on a completed document', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedCompletedDocument(user, team.id, [user], { internalVersion: 2 });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: true },
+ });
+
+ // Contents on a completed envelope are already imprinted onto its PDF.
+ await seedStaticContents(envelope.id, envelope.envelopeItems[0].id);
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${document.id}` });
+
+ await expect(getPageCanvas(page)).toBeVisible();
+
+ expect(await getContentGroupsForPage(page)).toHaveLength(0);
+});
diff --git a/packages/app-tests/e2e/envelopes/envelope-contents-inserted-on-send.spec.ts b/packages/app-tests/e2e/envelopes/envelope-contents-inserted-on-send.spec.ts
new file mode 100644
index 000000000..6cca1fd24
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-contents-inserted-on-send.spec.ts
@@ -0,0 +1,183 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import {
+ EnvelopeContentShapeType,
+ EnvelopeContentType,
+ type TEnvelopeContentMetaInput,
+ ZEnvelopeContentMetaSchema,
+} from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedBlankDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, type Page, test } from '@playwright/test';
+import { DocumentStatus, FieldType } from '@prisma/client';
+
+import { apiSignin } from '../fixtures/authentication';
+import { getKonvaElementCountForPage } from '../fixtures/konva';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({ mode: 'parallel' });
+
+/**
+ * Contents are rendered into the PDF when the envelope is sent, so everything
+ * after DRAFT works from bytes which already contain them. The signer never
+ * draws them and never fetches their images.
+ */
+
+const rectangleMeta: TEnvelopeContentMetaInput = {
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 30,
+ height: 20,
+ fillColor: '#ff0000',
+ fillOpacity: 1,
+};
+
+const setupSendableDocument = async (page: Page) => {
+ const { user, team } = await seedUser();
+ const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: { include: { documentData: true } } },
+ });
+
+ const envelopeItem = envelope.envelopeItems[0];
+
+ // A signer with a signature field, so the envelope is sendable.
+ const recipient = await prisma.recipient.create({
+ data: {
+ envelopeId: envelope.id,
+ email: `signer-${nanoid(8)}@example.com`,
+ name: 'Signer',
+ token: nanoid(),
+ },
+ });
+
+ await prisma.field.create({
+ data: {
+ envelopeId: envelope.id,
+ envelopeItemId: envelopeItem.id,
+ recipientId: recipient.id,
+ type: FieldType.SIGNATURE,
+ page: 1,
+ positionX: 60,
+ positionY: 60,
+ width: 20,
+ height: 8,
+ customText: '',
+ inserted: false,
+ fieldMeta: { type: 'signature', overflow: 'auto' },
+ },
+ });
+
+ return { envelope, envelopeItem, teamId: team.id, recipientToken: recipient.token };
+};
+
+const addContent = async (page: Page, teamId: number, envelopeId: string, envelopeItemId: string) =>
+ await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({
+ json: { envelopeId, contents: [{ envelopeItemId, contentMeta: rectangleMeta, dataContentId: null }] },
+ }),
+ });
+
+const sendDocument = async (page: Page, teamId: number, envelopeId: string) =>
+ await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.distribute`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({ json: { envelopeId } }),
+ });
+
+test('sending renders the contents into the pdf while keeping the original', async ({ page }) => {
+ const { envelope, envelopeItem, teamId } = await setupSendableDocument(page);
+
+ const originalDataId = envelopeItem.documentDataId;
+ const originalInitialData = envelopeItem.documentData.initialData;
+
+ expect((await addContent(page, teamId, envelope.id, envelopeItem.id)).ok()).toBeTruthy();
+
+ const sent = await sendDocument(page, teamId, envelope.id);
+
+ expect(sent.ok()).toBeTruthy();
+
+ const after = await prisma.envelopeItem.findFirstOrThrow({
+ where: { id: envelopeItem.id },
+ include: { documentData: true },
+ });
+
+ // A new document data was written...
+ expect(after.documentDataId).not.toBe(originalDataId);
+ // ...with the contents in it...
+ expect(after.documentData.data).not.toBe(originalInitialData);
+ // ...but the original upload is untouched.
+ expect(after.documentData.initialData).toBe(originalInitialData);
+});
+
+test('the signer page does not draw contents or request their images', async ({ page }) => {
+ const { envelope, envelopeItem, teamId, recipientToken } = await setupSendableDocument(page);
+
+ await addContent(page, teamId, envelope.id, envelopeItem.id);
+
+ expect((await sendDocument(page, teamId, envelope.id)).ok()).toBeTruthy();
+
+ const imageRequests: string[] = [];
+
+ page.on('request', (request) => {
+ if (request.url().includes('/dataContent/')) {
+ imageRequests.push(request.url());
+ }
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/sign/${recipientToken}`);
+ await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
+
+ // The signature field is drawn on the canvas, the content is not.
+ await expect.poll(async () => await getKonvaElementCountForPage(page, 1, '.field-group')).toBe(1);
+
+ expect(await getKonvaElementCountForPage(page, 1, '.content-group')).toBe(0);
+ expect(imageRequests).toEqual([]);
+});
+
+test('sending is blocked when a content image cannot be loaded', async ({ page }) => {
+ const { envelope, envelopeItem, teamId } = await setupSendableDocument(page);
+
+ // An image content with no image attached to it.
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelopeItem.id,
+ dataContentId: null,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 30,
+ height: 20,
+ }),
+ },
+ });
+
+ const sent = await sendDocument(page, teamId, envelope.id);
+
+ expect(sent.status()).toBe(400);
+ expect(await sent.text()).toContain('MISSING_CONTENT_IMAGE');
+
+ // Nothing was inserted and the envelope is still a draft.
+ const after = await prisma.envelope.findFirstOrThrow({
+ where: { id: envelope.id },
+ include: { envelopeItems: true },
+ });
+
+ expect(after.status).toBe(DocumentStatus.DRAFT);
+ expect(after.envelopeItems[0].documentDataId).toBe(envelopeItem.documentDataId);
+});
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
new file mode 100644
index 000000000..7e4780658
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-page-canvas-loading.spec.ts
@@ -0,0 +1,98 @@
+import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
+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 { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, test } from '@playwright/test';
+
+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.
+ */
+
+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 envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: true, recipients: 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({
+ file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
+ });
+
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ dataContentId: dataContent.id,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 10,
+ }),
+ },
+ });
+
+ await page.route('**/dataContent/**/image', async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, IMAGE_LOAD_MS));
+
+ await route.continue();
+ });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
+
+ const pageLoader = page.getByTestId('page-loader').first();
+ const pageImage = page.locator('img[alt=""]').first();
+
+ // While the content image is on its way, neither the page nor its contents
+ // are shown.
+ await expect(pageLoader).toBeVisible();
+ await expect(pageImage).not.toBeVisible();
+ await expect(page.locator('.konva-container canvas')).toHaveCount(0);
+
+ // Once it lands they appear together.
+ await expect(pageImage).toBeVisible({ timeout: 15000 });
+ await expect(page.locator('.konva-container canvas').first()).toBeVisible();
+ await expect(pageLoader).toHaveCount(0);
+});
+
+test('a page with no content images renders as soon as the page image is ready', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { recipients: true },
+ });
+
+ await prisma.field.deleteMany({ where: { envelopeId: envelope.id } });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
+
+ await expect(page.locator('.konva-container canvas').first()).toBeVisible();
+ await expect(page.locator('img[alt=""]').first()).toBeVisible();
+ await expect(page.getByTestId('page-loader')).toHaveCount(0);
+});
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
new file mode 100644
index 000000000..80dcd9ff8
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-replace-pdf-content-pruning.spec.ts
@@ -0,0 +1,162 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedBlankDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, test } from '@playwright/test';
+
+/**
+ * Replacing an envelope item's PDF already drops fields which fall beyond the
+ * new page count. Contents have to be dropped for the same reason: at seal
+ * time they are looked up by page, so one left behind is either drawn onto
+ * the certificate or aborts the job.
+ */
+
+const singlePagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
+const multiPagePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf'));
+
+const textMetaOnPage = (page: number) => ({
+ type: EnvelopeContentType.TEXT,
+ page,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 6,
+ text: `Page ${page}`,
+});
+
+const replacePdf = async (envelopeId: string, buffer: Buffer, name: string) => {
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: envelopeId },
+ include: { recipients: true, envelopeItems: true, documentMeta: true },
+ });
+
+ const user = await prisma.user.findUniqueOrThrow({ where: { id: envelope.userId } });
+
+ return await UNSAFE_replaceEnvelopeItemPdf({
+ envelope,
+ recipients: envelope.recipients,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ oldDocumentDataId: envelope.envelopeItems[0].documentDataId,
+ data: {
+ file: new File([new Uint8Array(buffer)], name, { type: 'application/pdf' }),
+ },
+ user: { id: user.id, name: user.name, email: user.email },
+ apiRequestMetadata: {
+ requestMetadata: {},
+ source: 'app',
+ auth: null,
+ auditUser: { id: user.id, name: user.name, email: user.email },
+ },
+ });
+};
+
+test('replacing a PDF drops contents which fall beyond the new page count', async () => {
+ const { user, team } = await seedUser();
+
+ const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
+
+ // Start from a three page PDF.
+ await replacePdf(document.id, multiPagePdf, 'multi-page.pdf');
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: true },
+ });
+
+ const envelopeItemId = envelope.envelopeItems[0].id;
+
+ await prisma.envelopeContent.createMany({
+ data: [1, 3].map((page) => ({
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)),
+ })),
+ });
+
+ await replacePdf(document.id, singlePagePdf, 'single-page.pdf');
+
+ const contents = await prisma.envelopeContent.findMany({
+ where: { envelopeId: envelope.id },
+ select: { contentMeta: true },
+ });
+
+ // The page 1 content survives, the page 3 content is gone.
+ expect(contents).toHaveLength(1);
+
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const contentMeta = contents[0].contentMeta as { page?: number };
+
+ 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();
+
+ 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 },
+ });
+
+ await prisma.envelopeContent.createMany({
+ data: [1, 2, 3].map((page) => ({
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ contentMeta: ZEnvelopeContentMetaSchema.parse(textMetaOnPage(page)),
+ })),
+ });
+
+ // Replacing with another three page PDF leaves them all in place.
+ await replacePdf(document.id, multiPagePdf, 'multi-page-again.pdf');
+
+ const contents = await prisma.envelopeContent.findMany({ where: { envelopeId: envelope.id } });
+
+ expect(contents).toHaveLength(3);
+});
diff --git a/packages/app-tests/e2e/envelopes/envelope-seal-inserts-contents-once.spec.ts b/packages/app-tests/e2e/envelopes/envelope-seal-inserts-contents-once.spec.ts
new file mode 100644
index 000000000..ecb6091e9
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-seal-inserts-contents-once.spec.ts
@@ -0,0 +1,164 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import {
+ EnvelopeContentShapeType,
+ EnvelopeContentType,
+ type TEnvelopeContentMetaInput,
+} from '@documenso/lib/types/envelope-content-meta';
+import { nanoid } from '@documenso/lib/universal/id';
+import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
+import { prisma } from '@documenso/prisma';
+import { seedBlankDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+import { DocumentStatus, RecipientRole } from '@prisma/client';
+import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
+
+import { apiSignin } from '../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+/**
+ * Contents are inserted into the PDF on send. The seal must then sign those
+ * bytes as they are, not draw the contents a second time on top of them.
+ *
+ * A translucent fill makes a second draw measurable: two 40% layers of the
+ * same colour compound to 64%, which reads as a much darker pixel.
+ */
+
+const FILL_OPACITY = 0.4;
+
+const translucentGreen: TEnvelopeContentMetaInput = {
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: 1,
+ rotation: 0,
+ positionX: 20,
+ positionY: 20,
+ width: 40,
+ height: 30,
+ // Pure green so red and blue are driven entirely by the opacity. The stroke
+ // sits at the edge and the sample is taken at the centre, so it is ignored.
+ fillColor: '#00ff00',
+ fillOpacity: FILL_OPACITY,
+};
+
+/**
+ * Red/blue channel of pure green at the given opacity over white.
+ */
+const channelAtOpacity = (opacity: number) => Math.round(255 * (1 - opacity));
+
+const SINGLE_DRAW = channelAtOpacity(FILL_OPACITY);
+const DOUBLE_DRAW = channelAtOpacity(1 - (1 - FILL_OPACITY) ** 2);
+
+/**
+ * Render the first page of a PDF and return the pixel at the centre of the
+ * given percentage box.
+ */
+const samplePixelAtBoxCentre = async (
+ pdfBytes: Uint8Array,
+ box: { positionX: number; positionY: number; width: number; height: number },
+) => {
+ const pdf = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
+ const page = await pdf.getPage(1);
+
+ const scale = 2;
+ const viewport = page.getViewport({ scale });
+
+ const canvas = createCanvas(viewport.width, viewport.height);
+ const canvasContext = canvas.getContext('2d');
+
+ await page.render({
+ // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
+ canvas,
+ // @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
+ canvasContext,
+ viewport,
+ }).promise;
+
+ const x = Math.round(viewport.width * ((box.positionX + box.width / 2) / 100));
+ const y = Math.round(viewport.height * ((box.positionY + box.height / 2) / 100));
+
+ const [r, g, b] = canvasContext.getImageData(x, y, 1, 1).data;
+
+ return { r, g, b };
+};
+
+const setupEnvelopeWithContent = async (page: Page) => {
+ const { user, team } = await seedUser();
+ const document = await seedBlankDocument(user, team.id, { internalVersion: 2 });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents` });
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: { include: { documentData: true } } },
+ });
+
+ const envelopeItem = envelope.envelopeItems[0];
+
+ const addContent = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
+ data: JSON.stringify({
+ json: {
+ envelopeId: envelope.id,
+ contents: [{ envelopeItemId: envelopeItem.id, contentMeta: translucentGreen, dataContentId: null }],
+ },
+ }),
+ });
+
+ expect(addContent.ok()).toBeTruthy();
+
+ // A CC-only envelope has nobody left to act, so sending it seals it at
+ // once - through the real seal job, on the bytes the send just wrote.
+ await prisma.recipient.create({
+ data: {
+ envelopeId: envelope.id,
+ email: `cc-${nanoid(8)}@example.com`,
+ name: 'Copied',
+ token: nanoid(),
+ role: RecipientRole.CC,
+ },
+ });
+
+ return { envelope, envelopeItem, teamId: team.id };
+};
+
+test('sealing does not draw the contents a second time', async ({ page }) => {
+ const { envelope, envelopeItem, teamId } = await setupEnvelopeWithContent(page);
+
+ const sent = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.distribute`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({ json: { envelopeId: envelope.id } }),
+ });
+
+ expect(sent.ok()).toBeTruthy();
+
+ await expect
+ .poll(async () => (await prisma.envelope.findFirstOrThrow({ where: { id: envelope.id } })).status, {
+ timeout: 30_000,
+ })
+ .toBe(DocumentStatus.COMPLETED);
+
+ const sealed = await prisma.envelopeItem.findFirstOrThrow({
+ where: { id: envelopeItem.id },
+ include: { documentData: true },
+ });
+
+ const sealedBytes = await getFileServerSide(sealed.documentData);
+ const pixel = await samplePixelAtBoxCentre(sealedBytes, translucentGreen);
+
+ // Green stays saturated either way; red and blue tell the two apart.
+ expect(pixel.g).toBeGreaterThan(240);
+ expect(pixel.r).toBeGreaterThan((SINGLE_DRAW + DOUBLE_DRAW) / 2);
+ expect(pixel.b).toBeGreaterThan((SINGLE_DRAW + DOUBLE_DRAW) / 2);
+
+ // The original upload never had the content drawn on it at all.
+ const originalBytes = await getFileServerSide({
+ type: sealed.documentData.type,
+ data: sealed.documentData.initialData,
+ });
+ const originalPixel = await samplePixelAtBoxCentre(originalBytes, translucentGreen);
+
+ expect(originalPixel).toEqual({ r: 255, g: 255, b: 255 });
+});
diff --git a/packages/app-tests/e2e/envelopes/envelope-sealed-content-rendering.spec.ts b/packages/app-tests/e2e/envelopes/envelope-sealed-content-rendering.spec.ts
new file mode 100644
index 000000000..ecbc4ccb7
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-sealed-content-rendering.spec.ts
@@ -0,0 +1,92 @@
+import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
+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 { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+import { DocumentStatus } from '@prisma/client';
+
+import { apiSignin } from '../fixtures/authentication';
+import { getPageCanvas } from '../fixtures/contents';
+
+/**
+ * A sealed envelope has its contents imprinted on the PDF, so their images
+ * are never loaded. The page must still render, otherwise the fields drawn
+ * alongside them disappear too.
+ */
+
+const seedSealedDocumentWithImageContent = async ({ withImageContent }: { withImageContent: boolean }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { envelopeItems: true },
+ });
+
+ if (withImageContent) {
+ const png = await createCanvas(60, 30).encode('png');
+
+ const dataContent = await createDataContentImage({
+ file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
+ });
+
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ dataContentId: dataContent.id,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 10,
+ }),
+ },
+ });
+ }
+
+ // Rejecting seals the envelope, and unlike completing it the fields are
+ // still meant to be drawn.
+ await prisma.envelope.update({
+ where: { id: envelope.id },
+ data: { status: DocumentStatus.REJECTED },
+ });
+
+ return { user, team, documentId: document.id };
+};
+
+const countFieldGroups = async (page: Page) =>
+ await page.evaluate(() => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva = (window as unknown as { Konva: typeof import('konva').default }).Konva;
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === 'page-1');
+
+ return stage?.find('.field-group').length ?? 0;
+ });
+
+test('a rejected document with an image content still draws its fields', async ({ page }) => {
+ const { user, team, documentId } = await seedSealedDocumentWithImageContent({ withImageContent: true });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${documentId}` });
+
+ await expect(getPageCanvas(page)).toBeVisible();
+ await expect.poll(() => countFieldGroups(page)).toBeGreaterThan(0);
+});
+
+test('a rejected document without contents is unaffected', async ({ page }) => {
+ const { user, team, documentId } = await seedSealedDocumentWithImageContent({ withImageContent: false });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${documentId}` });
+
+ await expect(getPageCanvas(page)).toBeVisible();
+ await expect.poll(() => countFieldGroups(page)).toBeGreaterThan(0);
+});
diff --git a/packages/app-tests/e2e/envelopes/envelope-viewer-toolbar.spec.ts b/packages/app-tests/e2e/envelopes/envelope-viewer-toolbar.spec.ts
new file mode 100644
index 000000000..f10646870
--- /dev/null
+++ b/packages/app-tests/e2e/envelopes/envelope-viewer-toolbar.spec.ts
@@ -0,0 +1,112 @@
+import { prisma } from '@documenso/prisma';
+import { seedPendingDocument } from '@documenso/prisma/seed/documents';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { expect, type Page, test } from '@playwright/test';
+
+import { apiSignin } from '../fixtures/authentication';
+import { getPageCanvas } from '../fixtures/contents';
+
+/**
+ * The viewer toolbar is positioned against the visible area of the scroll
+ * container, which can change size without the window doing so (e.g. the
+ * signing view's collapsible sidebar).
+ */
+
+const SIDEBAR_EXPANDED_WIDTH = 320;
+const SIDEBAR_COLLAPSED_WIDTH = 48;
+
+/**
+ * The horizontal centre of the toolbar and of the scroll container it belongs
+ * to. They must stay aligned.
+ */
+const getToolbarAndContainerCentres = async (page: Page) =>
+ await page.evaluate(() => {
+ const zoomOut = document.querySelector('button[title="Zoom out"]');
+ const toolbar = zoomOut?.parentElement;
+ const scroller = document.querySelector('.embed--DocumentContainer');
+
+ if (!toolbar || !scroller) {
+ throw new Error('Toolbar or scroll container not found');
+ }
+
+ const toolbarRect = toolbar.getBoundingClientRect();
+ const scrollerRect = scroller.getBoundingClientRect();
+
+ return {
+ toolbar: toolbarRect.left + toolbarRect.width / 2,
+ container: scrollerRect.left + scrollerRect.width / 2,
+ containerWidth: scrollerRect.width,
+ };
+ });
+
+test('the viewer toolbar follows the scroll container when the sidebar collapses', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const document = await seedPendingDocument(user, team.id, [user], { internalVersion: 2 });
+
+ // The seeded fields carry no `fieldMeta`, which the v2 signer rejects, and
+ // fields are not what this test is about.
+ await prisma.field.deleteMany({ where: { envelopeId: document.id } });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: document.id },
+ include: { recipients: true },
+ });
+
+ // The scroll container is observed from within the observer's own callback,
+ // so a mistake there surfaces as a resize loop.
+ const consoleErrors: string[] = [];
+
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ consoleErrors.push(message.text());
+ }
+ });
+
+ // The toolbar is only shown from the `lg` breakpoint on the signing view.
+ await page.setViewportSize({ width: 1400, height: 900 });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/sign/${envelope.recipients[0].token}` });
+ await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
+ await expect(getPageCanvas(page)).toBeVisible();
+
+ const collapseButton = page.getByRole('button', { name: 'Collapse sidebar' });
+ await expect(collapseButton).toBeVisible();
+
+ const expanded = await getToolbarAndContainerCentres(page);
+
+ expect(expanded.toolbar).toBeCloseTo(expanded.container, 0);
+
+ await collapseButton.click();
+
+ // The container widens by the difference in sidebar widths, so its centre
+ // shifts left by half of that.
+ await expect
+ .poll(async () => (await getToolbarAndContainerCentres(page)).containerWidth)
+ .toBeGreaterThan(expanded.containerWidth + (SIDEBAR_EXPANDED_WIDTH - SIDEBAR_COLLAPSED_WIDTH) / 2);
+
+ await expect
+ .poll(async () => {
+ const { toolbar, container } = await getToolbarAndContainerCentres(page);
+
+ return Math.abs(toolbar - container);
+ })
+ .toBeLessThan(1);
+
+ // And it tracks the container back the other way.
+ await page.getByRole('button', { name: 'Expand sidebar' }).click();
+
+ await expect
+ .poll(async () => (await getToolbarAndContainerCentres(page)).containerWidth)
+ .toBeLessThan(expanded.containerWidth + 1);
+
+ await expect
+ .poll(async () => {
+ const { toolbar, container } = await getToolbarAndContainerCentres(page);
+
+ return Math.abs(toolbar - container);
+ })
+ .toBeLessThan(1);
+
+ expect(consoleErrors.filter((error) => error.includes('ResizeObserver'))).toEqual([]);
+});
diff --git a/packages/app-tests/e2e/fixtures/contents.ts b/packages/app-tests/e2e/fixtures/contents.ts
new file mode 100644
index 000000000..608cb542b
--- /dev/null
+++ b/packages/app-tests/e2e/fixtures/contents.ts
@@ -0,0 +1,212 @@
+import { expect, type Locator, type Page } from '@playwright/test';
+import type Konva from 'konva';
+
+import type { TEnvelopeEditorSurface } from './envelope-editor';
+import { getKonvaElementCountForPage } from './konva';
+
+export type ContentButtonName = 'Text' | 'Line' | 'Rectangle' | 'Highlight' | 'Image';
+
+/**
+ * Switch the fields step sidebar to the given tab.
+ */
+export const selectEditorTab = async (root: Page, tab: 'Fields' | 'Contents') => {
+ await root.getByRole('tab', { name: tab }).click();
+ await expect(root.getByRole('tab', { name: tab })).toHaveAttribute('data-state', 'active');
+};
+
+export const getPageCanvas = (root: Page) => root.locator('.konva-container canvas').first();
+
+/**
+ * Run a mouse interaction on the canvas with the floating action bar of the
+ * current selection unable to intercept it.
+ *
+ * The bar hangs just below the selected content and sits above the canvas,
+ * so a click there would press one of its buttons (e.g. Remove) rather than
+ * reach the canvas.
+ */
+export const interactWithCanvasPastActionBar = async (root: Page, interaction: () => Promise) => {
+ const setActionBarPointerEvents = async (value: string) => {
+ await root.evaluate((pointerEvents) => {
+ for (const bar of document.querySelectorAll('[data-testid="envelope-canvas-action-bar"]')) {
+ bar.style.pointerEvents = pointerEvents;
+ }
+ }, value);
+ };
+
+ await setActionBarPointerEvents('none');
+
+ try {
+ await interaction();
+ } finally {
+ await setActionBarPointerEvents('');
+ }
+};
+
+/**
+ * Pick a content from the palette and click on the canvas to place it.
+ */
+export const placeContentOnPdf = async (
+ root: Page,
+ contentName: ContentButtonName,
+ position: { x: number; y: number },
+) => {
+ await root.getByRole('button', { name: contentName, exact: true }).click();
+
+ const canvas = getPageCanvas(root);
+ await expect(canvas).toBeVisible();
+
+ await interactWithCanvasPastActionBar(root, async () => {
+ await canvas.click({ position });
+ });
+};
+
+/**
+ * Click a content on the canvas to select it.
+ */
+export const selectContentOnCanvas = async (
+ root: Page,
+ position: { x: number; y: number },
+ options: { shift?: boolean } = {},
+) => {
+ const canvas = getPageCanvas(root);
+ await expect(canvas).toBeVisible();
+ await root.waitForTimeout(300);
+
+ await interactWithCanvasPastActionBar(root, async () => {
+ await canvas.click({ position, modifiers: options.shift ? ['Shift'] : [], force: true });
+ });
+};
+
+export const getContentCountForPage = async (root: Page, pageNumber = 1) =>
+ await getKonvaElementCountForPage(root, pageNumber, '.content-group');
+
+export const getFieldCountForPage = async (root: Page, pageNumber = 1) =>
+ await getKonvaElementCountForPage(root, pageNumber, '.field-group');
+
+type ContentGroupSnapshot = {
+ id: string;
+ contentType: string | undefined;
+ visible: boolean;
+ /**
+ * The group's client rect in unscaled page coordinates.
+ */
+ rect: { x: number; y: number; width: number; height: number };
+ /**
+ * The names of the group's visible child nodes.
+ */
+ visibleChildren: string[];
+};
+
+/**
+ * Snapshot every content group on a page, straight from Konva.
+ */
+export const getContentGroupsForPage = async (root: Page, pageNumber = 1): Promise => {
+ await getPageCanvas(root).waitFor({ state: 'visible' });
+
+ return await root.evaluate(
+ ({ pageNumber }) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
+
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
+
+ if (!stage) {
+ return [];
+ }
+
+ const scale = stage.scaleX();
+
+ return stage.find('.content-group').map((node) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const group = node as Konva.Group;
+ const rect = group.getClientRect({ skipStroke: true, skipShadow: true });
+
+ return {
+ id: group.id(),
+ contentType: group.getAttr('contentType'),
+ visible: group.isVisible(),
+ rect: { x: rect.x / scale, y: rect.y / scale, width: rect.width / scale, height: rect.height / scale },
+ visibleChildren: group
+ .getChildren()
+ .filter((child) => child.isVisible())
+ .map((child) => child.name()),
+ };
+ });
+ },
+ { pageNumber },
+ );
+};
+
+/**
+ * The unscaled page size of a page's Konva stage.
+ */
+export const getPageSize = async (root: Page, pageNumber = 1) => {
+ await getPageCanvas(root).waitFor({ state: 'visible' });
+
+ return await root.evaluate(
+ ({ pageNumber }) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
+
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
+
+ if (!stage) {
+ throw new Error(`Stage for page ${pageNumber} not found`);
+ }
+
+ return { width: stage.width() / stage.scaleX(), height: stage.height() / stage.scaleY(), scale: stage.scaleX() };
+ },
+ { pageNumber },
+ );
+};
+
+/**
+ * Wait until exactly the given contents are selected, i.e. the transformer is
+ * attached to them and their anchors can be grabbed.
+ */
+export const waitForContentSelection = async (root: Page, contentIds: string[], pageNumber = 1) => {
+ await expect
+ .poll(
+ async () =>
+ await root.evaluate(
+ ({ pageNumber }) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
+
+ const stage = konva.stages.find((currentStage) => currentStage.attrs.id === `page-${pageNumber}`);
+
+ if (!stage) {
+ return [];
+ }
+
+ return stage
+ .find('Transformer')
+ .flatMap((node) =>
+ node instanceof konva.Transformer ? node.nodes().map((selected) => selected.id()) : [],
+ )
+ .sort();
+ },
+ { pageNumber },
+ ),
+ )
+ .toEqual([...contentIds].sort());
+};
+
+/**
+ * Open the settings dialog and set the external ID used to find the envelope
+ * in the database.
+ */
+export const getContentActionButton = (root: Page, title: string): Locator =>
+ root.getByTestId('envelope-canvas-action-bar').locator(`button[title="${title}"]`);
+
+/**
+ * Wait for the editor's debounced autosave to flush.
+ */
+export const waitForContentsAutosave = async (surface: TEnvelopeEditorSurface) => {
+ if (surface.isEmbedded) {
+ return;
+ }
+
+ // Autosave debounces for 2 seconds.
+ await surface.root.waitForTimeout(2500);
+};
diff --git a/packages/app-tests/e2e/templates/direct-template-missing-content-image.spec.ts b/packages/app-tests/e2e/templates/direct-template-missing-content-image.spec.ts
new file mode 100644
index 000000000..444d2e700
--- /dev/null
+++ b/packages/app-tests/e2e/templates/direct-template-missing-content-image.spec.ts
@@ -0,0 +1,151 @@
+import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId } from '@documenso/lib/universal/id';
+import { prisma } from '@documenso/prisma';
+import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, test } from '@playwright/test';
+
+import { getContentGroupsForPage } from '../fixtures/contents';
+
+/**
+ * An image content with no image renders nothing, so a direct template
+ * holding one would let a signer fill everything in and only fail when the
+ * document is created. The signing page reports it up front instead.
+ */
+
+const INVALID_TEMPLATE_HEADING = 'Invalid direct link template';
+
+/**
+ * A real PNG, encoded the same way as an uploaded one, so the browser can
+ * actually decode it when the signing page loads the image.
+ */
+const createPng = async () => {
+ const canvas = createCanvas(60, 30);
+ const context = canvas.getContext('2d');
+
+ context.fillStyle = 'rgb(30, 120, 220)';
+ context.fillRect(0, 0, 60, 30);
+
+ return await canvas.encode('png');
+};
+
+const seedDirectTemplateWithImageContent = async ({ withImage }: { withImage: boolean }) => {
+ const { user, team } = await seedUser();
+
+ const template = await seedDirectTemplate({
+ title: `E2E Direct Template Missing Image ${Date.now()}`,
+ userId: user.id,
+ teamId: team.id,
+ internalVersion: 2,
+ createDirectRecipientSignatureField: true,
+ });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: template.id },
+ include: { envelopeItems: true, directLink: true },
+ });
+
+ let dataContentId: string | null = null;
+
+ if (withImage) {
+ const png = await createPng();
+
+ const dataContent = await createDataContentImage({
+ file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
+ });
+
+ dataContentId = dataContent.id;
+ }
+
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ dataContentId,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 10,
+ }),
+ },
+ });
+
+ if (!envelope.directLink) {
+ throw new Error('Direct link not seeded');
+ }
+
+ return { token: envelope.directLink.token };
+};
+
+test('the direct signing page reports an image content with no image', async ({ page }) => {
+ const { token } = await seedDirectTemplateWithImageContent({ withImage: false });
+
+ await page.goto(`/d/${token}`);
+
+ await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toBeVisible();
+ await expect(page.getByText('image content')).toBeVisible();
+
+ // The signing form is not offered at all.
+ await expect(page.getByRole('button', { name: 'Complete' })).toHaveCount(0);
+});
+
+test('the direct signing page still reports a signer with no signature field', async ({ page }) => {
+ const { user, team } = await seedUser();
+
+ const template = await seedDirectTemplate({
+ title: `E2E Direct Template Missing Signature ${Date.now()}`,
+ userId: user.id,
+ teamId: team.id,
+ internalVersion: 2,
+ createDirectRecipientSignatureField: false,
+ });
+
+ const envelope = await prisma.envelope.findUniqueOrThrow({
+ where: { id: template.id },
+ include: { directLink: true },
+ });
+
+ if (!envelope.directLink) {
+ throw new Error('Direct link not seeded');
+ }
+
+ await page.goto(`/d/${envelope.directLink.token}`);
+
+ await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toBeVisible();
+ await expect(page.getByText('do not have a signature field assigned')).toBeVisible();
+});
+
+/**
+ * A direct template is signed as-is, before any document exists, so its
+ * contents are not yet inserted into the PDF. The signer must still see them,
+ * which means they are drawn on the canvas here rather than read from the
+ * bytes as they are for a sent document.
+ */
+test('the direct signing page draws the contents once the image is attached', async ({ page }) => {
+ const { token } = await seedDirectTemplateWithImageContent({ withImage: true });
+
+ await page.goto(`/d/${token}`);
+
+ await expect(page.getByRole('heading', { name: INVALID_TEMPLATE_HEADING })).toHaveCount(0);
+ await expect(page.locator('.konva-container canvas').first()).toBeVisible();
+
+ // The image decodes asynchronously, so poll until the group has drawn it.
+ await expect
+ .poll(async () => {
+ const groups = await getContentGroupsForPage(page);
+
+ return groups.map((group) => ({
+ type: group.contentType,
+ hasImage: group.visibleChildren.includes('content-image'),
+ }));
+ })
+ .toEqual([{ type: EnvelopeContentType.IMAGE, hasImage: true }]);
+});
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
new file mode 100644
index 000000000..99b0a1864
--- /dev/null
+++ b/packages/app-tests/e2e/templates/template-shares-data-contents.spec.ts
@@ -0,0 +1,144 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
+import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
+import { prisma } from '@documenso/prisma';
+import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
+import { seedUser } from '@documenso/prisma/seed/users';
+import { createCanvas } from '@napi-rs/canvas';
+import { expect, type Page, test } from '@playwright/test';
+
+import { apiSignin } from '../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({ mode: 'parallel' });
+
+/**
+ * A data content (e.g. an uploaded image) is an immutable blob which any
+ * number of contents may point at. Copying a template shares its images with
+ * the new document rather than duplicating them, and removing a content never
+ * deletes the data content behind it.
+ */
+
+const createPng = async () => {
+ const canvas = createCanvas(60, 30);
+ const context = canvas.getContext('2d');
+
+ context.fillStyle = 'rgb(30, 120, 220)';
+ context.fillRect(0, 0, 60, 30);
+
+ return await canvas.encode('png');
+};
+
+const seedTemplateWithImageContent = async (page: Page) => {
+ const { user, team } = await seedUser();
+
+ const template = await seedBlankTemplate(user, team.id, { internalVersion: 2 });
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: { id: template.id },
+ include: { envelopeItems: true },
+ });
+
+ const recipient = await prisma.recipient.create({
+ data: {
+ envelopeId: envelope.id,
+ email: `signer-${nanoid(8)}@example.com`,
+ name: 'Signer',
+ token: nanoid(),
+ },
+ });
+
+ const png = await createPng();
+
+ const dataContent = await createDataContentImage({
+ file: { name: 'image.png', arrayBuffer: async () => png.buffer as ArrayBuffer },
+ });
+
+ await prisma.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: envelope.envelopeItems[0].id,
+ dataContentId: dataContent.id,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ rotation: 0,
+ zIndex: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 20,
+ height: 10,
+ }),
+ },
+ });
+
+ await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/templates` });
+
+ return { envelope, recipient, dataContentId: dataContent.id, teamId: team.id };
+};
+
+const createDocumentFromTemplate = async (
+ page: Page,
+ teamId: number,
+ templateSecondaryId: string,
+ recipient: { id: number; email: string; name: string },
+) => {
+ const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/template.createDocumentFromTemplate`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({
+ json: {
+ templateId: mapSecondaryIdToTemplateId(templateSecondaryId),
+ recipients: [{ id: recipient.id, email: recipient.email, name: recipient.name }],
+ },
+ }),
+ });
+
+ expect(res.ok()).toBeTruthy();
+
+ const body = await res.json();
+
+ // The route answers with the legacy document shape, whose `id` is the
+ // numeric document ID; `envelopeId` is the envelope's own ID.
+ return body.result.data.json.envelopeId as string;
+};
+
+test('a document created from a template shares its data contents', async ({ page }) => {
+ const { envelope, recipient, dataContentId, teamId } = await seedTemplateWithImageContent(page);
+
+ const documentId = await createDocumentFromTemplate(page, teamId, envelope.secondaryId, recipient);
+
+ const copied = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: documentId } });
+
+ // Same row, not a copy of it.
+ expect(copied.dataContentId).toBe(dataContentId);
+ expect(await prisma.dataContent.count({ where: { id: dataContentId } })).toBe(1);
+
+ // Both contents point at it.
+ expect(await prisma.envelopeContent.count({ where: { dataContentId } })).toBe(2);
+});
+
+test('removing a content leaves the shared data content in place', async ({ page }) => {
+ const { envelope, recipient, dataContentId, teamId } = await seedTemplateWithImageContent(page);
+
+ const documentId = await createDocumentFromTemplate(page, teamId, envelope.secondaryId, recipient);
+
+ // Save the document with no contents, removing the copied one.
+ const removed = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.content.set`, {
+ headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
+ data: JSON.stringify({ json: { envelopeId: documentId, contents: [] } }),
+ });
+
+ expect(removed.ok()).toBeTruthy();
+ expect(await prisma.envelopeContent.count({ where: { envelopeId: documentId } })).toBe(0);
+
+ // The template's content still has its image.
+ expect(await prisma.dataContent.count({ where: { id: dataContentId } })).toBe(1);
+
+ const templateContent = await prisma.envelopeContent.findFirstOrThrow({ where: { envelopeId: envelope.id } });
+
+ expect(templateContent.dataContentId).toBe(dataContentId);
+});
diff --git a/packages/lib/client-only/hooks/use-content-images.ts b/packages/lib/client-only/hooks/use-content-images.ts
new file mode 100644
index 000000000..a80582c96
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-content-images.ts
@@ -0,0 +1,226 @@
+import type { EnvelopeContent } from '@prisma/client';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import type { ContentImageStatus, ContentImageStatusMap } from '../../universal/content-renderer/content-image-status';
+import type { ContentImageMap, ContentImageSource } from '../../universal/content-renderer/content-renderer';
+import { getDataContentIds } from '../../utils/envelope-content';
+import { getDataContentImageUrl } from '../../utils/envelope-download';
+import { type ContentImageDetails, loadContentImage } from '../load-content-image';
+import { useLatestRef } from './use-latest-ref';
+
+export type ContentImages = {
+ /**
+ * The loaded images by data content ID.
+ */
+ images: ContentImageMap;
+
+ /**
+ * The file details of the loaded images by data content ID, for display.
+ */
+ details: ReadonlyMap;
+
+ /**
+ * The loading status by data content ID. IDs which have not started
+ * loading are absent.
+ */
+ loadingStatuses: ContentImageStatusMap;
+
+ /**
+ * The images being loaded.
+ *
+ * An image belonging to a content is not always requested, e.g. nothing is
+ * loaded for a sealed envelope since its contents are imprinted on the PDF.
+ * Consumers need this to tell an image which is still on its way from one
+ * which is never coming.
+ */
+ requestedDataContentIds: ReadonlySet;
+
+ /**
+ * Insert an already decoded image, e.g. an upload decoded locally, so it is
+ * available immediately without a request.
+ */
+ setImage: (dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => void;
+};
+
+type UseContentImagesOptions = {
+ envelopeId: string;
+
+ /**
+ * The envelope item currently being rendered. Only its images are loaded.
+ */
+ envelopeItemId: string | null;
+
+ contents: Pick[];
+ token: string | undefined;
+ presignToken?: string | undefined;
+};
+
+type ContentImagesState = {
+ images: Map;
+ details: Map;
+ loadingStatuses: Map;
+};
+
+const createEmptyState = (): ContentImagesState => ({
+ images: new Map(),
+ details: new Map(),
+ loadingStatuses: new Map(),
+});
+
+/**
+ * Load and cache the images of the image contents of the envelope item being
+ * rendered, starting as soon as the contents are known so they are ready by
+ * the time the pages render.
+ *
+ * Images are kept for the lifetime of the provider, including across item
+ * switches, so navigating back never refetches or re-decodes. They are only
+ * closed on unmount, since a Konva node may still draw an image until its
+ * stage is destroyed and drawing a closed bitmap throws.
+ */
+export const useContentImages = ({
+ envelopeId,
+ envelopeItemId,
+ contents,
+ token,
+ presignToken,
+}: UseContentImagesOptions): ContentImages => {
+ const [state, setState] = useState(createEmptyState);
+
+ // Mirrors of the state for the effects, so starting loads does not depend
+ // on the state itself (which would re-run the effect on every update) and
+ // unmounting can close whatever was loaded.
+ const loadingStatusesRef = useLatestRef(state.loadingStatuses);
+ const imagesRef = useLatestRef(state.images);
+
+ // Identifies the current mount. Bumped on unmount so a load started under a
+ // previous mount discards its result rather than writing into state which
+ // has since been cleared. A strict mode remount counts as a new mount.
+ const mountIdRef = useRef(0);
+
+ const requestedDataContentIds = useMemo(() => {
+ const itemContents = contents.filter((content) => content.envelopeItemId === envelopeItemId);
+
+ return getDataContentIds(itemContents).sort();
+ }, [contents, envelopeItemId]);
+
+ // A stable key so the effect only re-runs when the set of IDs changes.
+ const requestedDataContentIdsKey = requestedDataContentIds.join(',');
+
+ useEffect(() => {
+ const unrequestedDataContentIds = requestedDataContentIds.filter(
+ (dataContentId) => !loadingStatusesRef.current.has(dataContentId),
+ );
+
+ if (unrequestedDataContentIds.length === 0) {
+ return;
+ }
+
+ const mountId = mountIdRef.current;
+
+ setState((prev) => ({
+ ...prev,
+ loadingStatuses: setDataContentStatuses(prev.loadingStatuses, unrequestedDataContentIds, 'loading'),
+ }));
+
+ for (const dataContentId of unrequestedDataContentIds) {
+ const url = getDataContentImageUrl({ envelopeId, dataContentId, token, presignToken });
+
+ void loadContentImage(url)
+ .then(({ image, details }) => {
+ if (mountIdRef.current !== mountId) {
+ closeImage(image);
+ return;
+ }
+
+ setState((prev) => ({
+ images: new Map(prev.images).set(dataContentId, image),
+ details: new Map(prev.details).set(dataContentId, details),
+ loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
+ }));
+ })
+ .catch((error: unknown) => {
+ if (mountIdRef.current !== mountId) {
+ return;
+ }
+
+ console.error(`Failed to load content image ${dataContentId}`, error);
+
+ setState((prev) => ({
+ ...prev,
+ loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'failed'),
+ }));
+ });
+ }
+ // The IDs are tracked via their key so an equal set does not re-run.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [envelopeId, requestedDataContentIdsKey, token, presignToken]);
+
+ useEffect(() => {
+ return () => {
+ mountIdRef.current += 1;
+
+ for (const image of imagesRef.current.values()) {
+ closeImage(image);
+ }
+
+ // Also reset the mirrors so a strict mode remount reloads everything
+ // rather than reusing closed images.
+ imagesRef.current = new Map();
+ loadingStatusesRef.current = new Map();
+
+ setState(createEmptyState());
+ };
+ }, []);
+
+ const setImage = useCallback((dataContentId: string, image: ContentImageSource, details: ContentImageDetails) => {
+ setState((prev) => ({
+ images: new Map(prev.images).set(dataContentId, image),
+ details: new Map(prev.details).set(dataContentId, details),
+ loadingStatuses: setDataContentStatuses(prev.loadingStatuses, [dataContentId], 'loaded'),
+ }));
+ }, []);
+
+ return useMemo(
+ () => ({
+ images: state.images,
+ details: state.details,
+ loadingStatuses: state.loadingStatuses,
+
+ /**
+ * The images being loaded, so consumers can tell an image which is
+ * still on its way from one which is never coming.
+ */
+ requestedDataContentIds: new Set(requestedDataContentIds),
+ setImage,
+ }),
+ [state, setImage, requestedDataContentIdsKey],
+ );
+};
+
+/**
+ * A copy of the statuses with the given data contents set to the given
+ * status. The original is left untouched so it can be used as React state.
+ */
+const setDataContentStatuses = (
+ loadingStatuses: Map,
+ dataContentIds: string[],
+ status: ContentImageStatus,
+) => {
+ const remappedLoadingStatuses = new Map(loadingStatuses);
+
+ for (const dataContentId of dataContentIds) {
+ remappedLoadingStatuses.set(dataContentId, status);
+ }
+
+ return remappedLoadingStatuses;
+};
+
+/**
+ * Release the memory of a decoded bitmap. Image elements have nothing to
+ * release.
+ */
+const closeImage = (image: ContentImageSource) => {
+ if ('close' in image) {
+ image.close();
+ }
+};
diff --git a/packages/lib/client-only/hooks/use-editor-contents.ts b/packages/lib/client-only/hooks/use-editor-contents.ts
new file mode 100644
index 000000000..568ebd6c9
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-editor-contents.ts
@@ -0,0 +1,316 @@
+import type { TEnvelopeContentMeta } from '@documenso/lib/types/envelope-content-meta';
+import { EnvelopeContentType, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
+import { getLocalContentOrderId, getNextContentZIndex, isContentOnTop } from '@documenso/lib/utils/envelope-content';
+import { clampPercentage, clampPercentageBox } from '@documenso/lib/utils/geometry';
+import { useCallback } from 'react';
+import { match } from 'ts-pattern';
+import { z } from 'zod';
+import { EDITOR_DUPLICATE_ITEM_OFFSET } from '../../constants/envelope-editor';
+import { createLocalFormId, useEditorFormArray } from './use-editor-form-array';
+
+/**
+ * The local editor representation of an envelope content.
+ *
+ * Note: Unlike fields, geometry (page, position, size and stacking order) is
+ * stored within the content meta since it differs per content type, e.g.
+ * lines use start/end coordinates instead of a position and size.
+ */
+export const ZLocalContentSchema = z.object({
+ // This is the actual ID of the envelope content if created.
+ id: z.string().optional(),
+ // This is the local client side ID of the content.
+ formId: z.string().min(1),
+ // This is the ID of the envelope item to put the content on.
+ envelopeItemId: z.string(),
+ contentMeta: ZEnvelopeContentMetaSchema,
+ // This is the ID of the uploaded data content (e.g. an image) attached to
+ // the content, or null for none.
+ dataContentId: z.string().nullable(),
+});
+
+export type TLocalContent = z.infer;
+
+type EditorContentsProps = {
+ envelope: TEditorEnvelope;
+
+ /**
+ * Receives a getter rather than the contents so the values are read when
+ * the save is sent, not when it was queued.
+ */
+ handleContentsUpdate: (getContents: () => TLocalContent[]) => unknown;
+};
+
+type UseEditorContentsResponse = {
+ localContents: TLocalContent[];
+
+ // Selected content
+ selectedContent: TLocalContent | undefined;
+ setSelectedContent: (formId: string | null) => void;
+
+ // Content operations
+ /**
+ * Add a content. It is placed above every other content on its page.
+ */
+ addContent: (content: Omit) => TLocalContent;
+ setContentPersistedIds: (
+ formId: string,
+ persisted: { id: string; dataContentId: string | null },
+ sentDataContentId: string | null,
+ ) => void;
+ removeContentsByFormId: (formIds: string[]) => void;
+ updateContentByFormId: (formId: string, updates: Partial) => void;
+
+ /**
+ * Merge a partial meta onto a content's current meta.
+ *
+ * The settings form provider is the single writer of a content's
+ * presentational meta. Anything else which edits those keys must go through
+ * the form (e.g. `form.setValue`) rather than calling this directly, so the
+ * form never falls behind the store.
+ */
+ patchContentMeta: (formId: string, patch: Partial) => void;
+
+ duplicateContent: (content: TLocalContent) => TLocalContent;
+
+ // Content utilities
+ getContentByFormId: (formId: string) => TLocalContent | undefined;
+
+ /**
+ * Place the content above every other content on its page, unless it is
+ * already on top.
+ */
+ bringContentToFront: (formId: string) => void;
+
+ resetForm: (contents?: TEditorEnvelope['contents']) => void;
+};
+
+export const useEditorContents = ({
+ envelope,
+ handleContentsUpdate,
+}: EditorContentsProps): UseEditorContentsResponse => {
+ const contentsArray = useEditorFormArray({
+ schema: ZLocalContentSchema,
+ sources: envelope.contents,
+ mapSource: (content) => ({
+ id: content.id,
+ envelopeItemId: content.envelopeItemId,
+ contentMeta: content.contentMeta,
+ dataContentId: content.dataContentId,
+ }),
+ onChange: handleContentsUpdate,
+ });
+
+ /**
+ * The contents sharing a page with the given content, read live from the
+ * form so this is safe to call from long lived callbacks.
+ */
+ const getPageSiblings = (content: Pick, excludeFormId?: string) =>
+ contentsArray
+ .getItems()
+ .filter(
+ (candidate) =>
+ candidate.formId !== excludeFormId &&
+ candidate.envelopeItemId === content.envelopeItemId &&
+ candidate.contentMeta.page === content.contentMeta.page,
+ );
+
+ /**
+ * The zIndex which places a content above every other content on its page.
+ */
+ const getTopZIndex = (content: Pick, excludeFormId?: string) =>
+ getNextContentZIndex(getPageSiblings(content, excludeFormId).map((sibling) => sibling.contentMeta.zIndex));
+
+ const addContent = useCallback(
+ (contentData: Omit): TLocalContent => {
+ const content: TLocalContent = {
+ ...contentData,
+ formId: createLocalFormId(),
+ dataContentId: null,
+ contentMeta: {
+ ...restrictContentMetaPosValues(contentData.contentMeta),
+ // New contents go on top.
+ zIndex: getTopZIndex(contentData),
+ },
+ };
+
+ contentsArray.appendItems([content]);
+ contentsArray.setSelected(content.formId, true);
+
+ return content;
+ },
+ [contentsArray.appendItems, contentsArray.setSelected],
+ );
+
+ /**
+ * Adopt the IDs assigned by the server after a save, without triggering
+ * another save.
+ *
+ * The content ID is always taken from the response, since the server owns
+ * row identity and a save may have replaced the row. Keeping a local ID
+ * here would leave every later save pointing at a row which no longer
+ * exists, which the server would then recreate over and over.
+ *
+ * The data content ID is different: the server may assign a different one
+ * than was sent, e.g. when a duplicated content's image is cloned, so it is
+ * only adopted while the local value is still the one that was sent and the
+ * response of an older save cannot overwrite a newer upload.
+ */
+ const setContentPersistedIds = (
+ formId: string,
+ persisted: { id: string; dataContentId: string | null },
+ sentDataContentId: string | null,
+ ) => {
+ contentsArray.updateByFormId(
+ formId,
+ (current) => {
+ const currentDataContentId = current.dataContentId;
+
+ const nextDataContentId =
+ currentDataContentId === sentDataContentId ? persisted.dataContentId : currentDataContentId;
+
+ if (persisted.id === current.id && nextDataContentId === currentDataContentId) {
+ return current;
+ }
+
+ return { ...current, id: persisted.id, dataContentId: nextDataContentId };
+ },
+ { shouldTriggerChange: false },
+ );
+ };
+
+ const updateContentByFormId = useCallback(
+ (formId: string, updates: Partial) => {
+ contentsArray.updateByFormId(formId, (content) => {
+ const updated = { ...content, ...updates };
+
+ return { ...updated, contentMeta: restrictContentMetaPosValues(updated.contentMeta) };
+ });
+ },
+ [contentsArray.updateByFormId],
+ );
+
+ const patchContentMeta = useCallback(
+ (formId: string, patch: Partial) => {
+ const content = contentsArray.findItem(formId);
+
+ if (!content) {
+ return;
+ }
+
+ // The patch only ever carries keys valid for the content's own type, so
+ // the merge keeps the discriminant intact.
+ const contentMeta = { ...content.contentMeta, ...patch } as TEnvelopeContentMeta;
+
+ updateContentByFormId(formId, { contentMeta });
+ },
+ [contentsArray.findItem, updateContentByFormId],
+ );
+
+ const duplicateContent = useCallback(
+ (content: TLocalContent): TLocalContent => {
+ const newContent: TLocalContent = {
+ ...structuredClone(content),
+ id: undefined,
+ formId: createLocalFormId(),
+ contentMeta: {
+ ...restrictContentMetaPosValues(offsetContentMetaPosValues(content.contentMeta)),
+ // The duplicate goes on top of the original.
+ zIndex: getTopZIndex(content),
+ },
+ };
+
+ contentsArray.appendItems([newContent]);
+
+ return newContent;
+ },
+ [contentsArray.appendItems],
+ );
+
+ const bringContentToFront = useCallback(
+ (formId: string) => {
+ contentsArray.updateByFormId(formId, (content) => {
+ const page = getPageSiblings(content);
+
+ // Nothing to do when it already renders last on its page. The
+ // tiebreak matches how the page is rendered.
+ const isAlreadyOnTop = isContentOnTop(
+ page.map((candidate) => ({ id: getLocalContentOrderId(candidate), zIndex: candidate.contentMeta.zIndex })),
+ getLocalContentOrderId(content),
+ );
+
+ if (isAlreadyOnTop) {
+ return content;
+ }
+
+ return {
+ ...content,
+ contentMeta: { ...content.contentMeta, zIndex: getTopZIndex(content, formId) },
+ };
+ });
+ },
+ [contentsArray.updateByFormId],
+ );
+
+ return {
+ // Core state
+ localContents: contentsArray.items,
+
+ // Content operations
+ addContent,
+ setContentPersistedIds,
+ removeContentsByFormId: contentsArray.removeByFormId,
+ updateContentByFormId,
+ patchContentMeta,
+ duplicateContent,
+
+ // Content utilities
+ getContentByFormId: contentsArray.findItem,
+ bringContentToFront,
+
+ // Selected content
+ selectedContent: contentsArray.selectedItem,
+ setSelectedContent: contentsArray.setSelected,
+
+ resetForm: contentsArray.resetForm,
+ };
+};
+
+/**
+ * Restrict the positional values of a content meta to be within the page bounds.
+ *
+ * Positional values are handled per content type since they differ, e.g. lines
+ * use start/end coordinates instead of a position and size.
+ */
+const restrictContentMetaPosValues = (contentMeta: TEnvelopeContentMeta): TEnvelopeContentMeta => {
+ return match(contentMeta)
+ .with({ type: EnvelopeContentType.LINE }, (meta) => ({
+ ...meta,
+ x1: clampPercentage(meta.x1),
+ y1: clampPercentage(meta.y1),
+ x2: clampPercentage(meta.x2),
+ y2: clampPercentage(meta.y2),
+ }))
+ .otherwise((meta) => clampPercentageBox(meta));
+};
+
+/**
+ * Offset the position of a content meta, used when duplicating contents so the
+ * duplicate does not fully overlap the original.
+ */
+const offsetContentMetaPosValues = (
+ contentMeta: TEnvelopeContentMeta,
+ offset = EDITOR_DUPLICATE_ITEM_OFFSET,
+): TEnvelopeContentMeta => {
+ return match(contentMeta)
+ .with({ type: EnvelopeContentType.LINE }, (meta) => ({
+ ...meta,
+ y1: meta.y1 + offset,
+ y2: meta.y2 + offset,
+ }))
+ .otherwise((meta) => ({
+ ...meta,
+ positionX: meta.positionX + offset,
+ positionY: meta.positionY + offset,
+ }));
+};
diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts
index 8f86f0a2c..51da798c0 100644
--- a/packages/lib/client-only/hooks/use-editor-fields.ts
+++ b/packages/lib/client-only/hooks/use-editor-fields.ts
@@ -1,13 +1,12 @@
-import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
-import { nanoid } from '@documenso/lib/universal/id';
-import { zodResolver } from '@hookform/resolvers/zod';
+import { clampPercentageBox } from '@documenso/lib/utils/geometry';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useFieldArray, useForm } from 'react-hook-form';
+import { useCallback, useMemo, useState } from 'react';
import { z } from 'zod';
+import { EDITOR_DUPLICATE_ITEM_OFFSET } from '../../constants/envelope-editor';
+import { createLocalFormId, getOtherPageNumbers, useEditorFormArray } from './use-editor-form-array';
export const ZLocalFieldSchema = z.object({
// This is the actual ID of the field if created.
@@ -28,15 +27,14 @@ export const ZLocalFieldSchema = z.object({
export type TLocalField = z.infer;
-const ZEditorFieldsFormSchema = z.object({
- fields: z.array(ZLocalFieldSchema),
-});
-
-export type TEditorFieldsFormSchema = z.infer;
-
type EditorFieldsProps = {
envelope: TEditorEnvelope;
- handleFieldsUpdate: (fields: TLocalField[]) => unknown;
+
+ /**
+ * Receives a getter rather than the fields so the values are read when the
+ * save is sent, not when it was queued.
+ */
+ handleFieldsUpdate: (getFields: () => TLocalField[]) => unknown;
};
type UseEditorFieldsResponse = {
@@ -51,12 +49,11 @@ type UseEditorFieldsResponse = {
setFieldId: (formId: string, id: number) => void;
removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial) => void;
- duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
- duplicateFieldToAllPages: (field: TLocalField, recipientId?: number) => TLocalField[];
+ duplicateField: (field: TLocalField) => TLocalField;
+ duplicateFieldToAllPages: (field: TLocalField) => TLocalField[];
// Field utilities
getFieldByFormId: (formId: string) => TLocalField | undefined;
- getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
@@ -66,133 +63,79 @@ type UseEditorFieldsResponse = {
};
export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsProps): UseEditorFieldsResponse => {
- const [selectedFieldFormId, setSelectedFieldFormId] = useState(null);
const [selectedRecipientId, setSelectedRecipientId] = useState(null);
- const generateDefaultValues = (fields?: Field[]) => {
- const formFields = (fields || envelope.fields).map(
- (field): TLocalField => ({
- id: field.id,
- formId: nanoid(),
- envelopeItemId: field.envelopeItemId,
- page: field.page,
- type: field.type,
- positionX: Number(field.positionX),
- positionY: Number(field.positionY),
- width: Number(field.width),
- height: Number(field.height),
- recipientId: field.recipientId,
- fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
- }),
- );
-
- return {
- fields: formFields,
- };
- };
-
- const form = useForm({
- defaultValues: generateDefaultValues(),
- resolver: zodResolver(ZEditorFieldsFormSchema),
+ const fieldsArray = useEditorFormArray({
+ schema: ZLocalFieldSchema,
+ sources: envelope.fields,
+ mapSource: (field) => ({
+ id: field.id,
+ envelopeItemId: field.envelopeItemId,
+ page: field.page,
+ type: field.type,
+ positionX: Number(field.positionX),
+ positionY: Number(field.positionY),
+ width: Number(field.width),
+ height: Number(field.height),
+ recipientId: field.recipientId,
+ fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
+ }),
+ onChange: handleFieldsUpdate,
});
- const {
- append,
- remove,
- update,
- fields: localFields,
- } = useFieldArray({
- control: form.control,
- name: 'fields',
- keyName: 'react-hook-form-id',
- });
+ const setSelectedRecipient = useCallback(
+ (recipientId: number | null) => {
+ const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
- const triggerFieldsUpdate = () => {
- void handleFieldsUpdate(form.getValues().fields);
- };
+ setSelectedRecipientId(foundRecipient?.id ?? null);
+ },
+ [envelope.recipients],
+ );
- const setSelectedField = (formId: string | null, bypassCheck = false) => {
- if (!formId) {
- setSelectedFieldFormId(null);
- return;
- }
+ /**
+ * Selecting a field also selects its recipient.
+ */
+ const setSelectedField = useCallback(
+ (formId: string | null, bypassCheck = false) => {
+ const foundField = formId ? fieldsArray.findItem(formId) : undefined;
- const foundField = localFields.find((field) => field.formId === formId);
- const recipient = envelope.recipients.find((recipient) => recipient.id === foundField?.recipientId);
+ if (foundField) {
+ setSelectedRecipient(foundField.recipientId);
+ }
- if (recipient) {
- setSelectedRecipient(recipient.id);
- }
-
- if (bypassCheck) {
- setSelectedFieldFormId(formId);
- return;
- }
-
- setSelectedFieldFormId(foundField?.formId ?? null);
- };
+ fieldsArray.setSelected(formId, bypassCheck);
+ },
+ [fieldsArray.findItem, fieldsArray.setSelected, setSelectedRecipient],
+ );
const addField = useCallback(
(fieldData: Omit): TLocalField => {
- const field: TLocalField = {
+ const field: TLocalField = clampPercentageBox({
...fieldData,
- formId: nanoid(12),
- ...restrictFieldPosValues(fieldData),
- };
+ formId: createLocalFormId(),
+ });
- append(field);
- triggerFieldsUpdate();
+ fieldsArray.appendItems([field]);
setSelectedField(field.formId, true);
+
return field;
},
- [append, triggerFieldsUpdate, setSelectedField],
- );
-
- const removeFieldsByFormId = useCallback(
- (formIds: string[]) => {
- const indexes = formIds
- .map((formId) => localFields.findIndex((field) => field.formId === formId))
- .filter((index) => index !== -1);
-
- if (indexes.length > 0) {
- remove(indexes);
- triggerFieldsUpdate();
- }
- },
- [localFields, remove, triggerFieldsUpdate],
+ [fieldsArray.appendItems, setSelectedField],
);
+ /**
+ * Adopt the ID assigned by the server after a save, without triggering
+ * another save.
+ */
const setFieldId = (formId: string, id: number) => {
- const { fields } = form.getValues();
-
- const index = fields.findIndex((field) => field.formId === formId);
-
- if (index !== -1) {
- update(index, {
- ...fields[index],
- id,
- });
- }
+ fieldsArray.updateByFormId(formId, (field) => ({ ...field, id }), { shouldTriggerChange: false });
};
const updateFieldByFormId = useCallback(
(formId: string, updates: Partial) => {
- const index = localFields.findIndex((field) => field.formId === formId);
-
- if (index !== -1) {
- const updatedField = {
- ...localFields[index],
- ...updates,
- };
-
- update(index, {
- ...updatedField,
- ...restrictFieldPosValues(updatedField),
- });
- triggerFieldsUpdate();
- }
+ fieldsArray.updateByFormId(formId, (field) => clampPercentageBox({ ...field, ...updates }));
},
- [localFields, update, triggerFieldsUpdate],
+ [fieldsArray.updateByFormId],
);
const duplicateField = useCallback(
@@ -200,123 +143,65 @@ export const useEditorFields = ({ envelope, handleFieldsUpdate }: EditorFieldsPr
const newField: TLocalField = {
...structuredClone(field),
id: undefined,
- formId: nanoid(12),
- recipientId: field.recipientId,
- positionX: field.positionX + 3,
- positionY: field.positionY + 3,
+ formId: createLocalFormId(),
+ positionX: field.positionX + EDITOR_DUPLICATE_ITEM_OFFSET,
+ positionY: field.positionY + EDITOR_DUPLICATE_ITEM_OFFSET,
};
- append(newField);
- triggerFieldsUpdate();
+ fieldsArray.appendItems([newField]);
+
return newField;
},
- [append, triggerFieldsUpdate],
+ [fieldsArray.appendItems],
);
const duplicateFieldToAllPages = useCallback(
(field: TLocalField): TLocalField[] => {
- const totalPages = getPdfPagesCount();
- const newFields: TLocalField[] = [];
-
- if (totalPages < 1) {
- return newFields;
- }
-
- for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
- if (pageNumber === field.page) {
- continue;
- }
-
- const newField: TLocalField = {
+ const newFields = getOtherPageNumbers(field.page).map(
+ (page): TLocalField => ({
...structuredClone(field),
id: undefined,
- formId: nanoid(12),
- page: pageNumber,
- };
+ formId: createLocalFormId(),
+ page,
+ }),
+ );
- append(newField);
- newFields.push(newField);
+ if (newFields.length > 0) {
+ fieldsArray.appendItems(newFields);
}
- triggerFieldsUpdate();
return newFields;
},
- [append, triggerFieldsUpdate],
- );
-
- const getFieldByFormId = useCallback(
- (formId: string): TLocalField | undefined => {
- return localFields.find((field) => field.formId === formId) as TLocalField | undefined;
- },
- [localFields],
- );
-
- const getFieldsByRecipient = useCallback(
- (recipientId: number): TLocalField[] => {
- return localFields.filter((field) => field.recipientId === recipientId);
- },
- [localFields],
+ [fieldsArray.appendItems],
);
const selectedRecipient = useMemo(() => {
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
}, [selectedRecipientId, envelope.recipients]);
- const selectedField = useMemo(() => {
- return localFields.find((field) => field.formId === selectedFieldFormId);
- }, [selectedFieldFormId, 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 setSelectedRecipient = (recipientId: number | null) => {
- const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
-
- setSelectedRecipientId(foundRecipient?.id ?? null);
- };
-
- const resetForm = (fields?: Field[]) => {
- form.reset(generateDefaultValues(fields));
- };
-
return {
// Core state
- localFields,
+ localFields: fieldsArray.items,
// Field operations
addField,
setFieldId,
- removeFieldsByFormId,
+ removeFieldsByFormId: fieldsArray.removeByFormId,
updateFieldByFormId,
duplicateField,
duplicateFieldToAllPages,
// Field utilities
- getFieldByFormId,
- getFieldsByRecipient,
+ getFieldByFormId: fieldsArray.findItem,
// Selected field
- selectedField,
+ selectedField: fieldsArray.selectedItem,
setSelectedField,
// Selected recipient
selectedRecipient,
setSelectedRecipient,
- resetForm,
- };
-};
-
-const restrictFieldPosValues = (field: Pick) => {
- return {
- positionX: Math.max(0, Math.min(100, field.positionX)),
- positionY: Math.max(0, Math.min(100, field.positionY)),
- width: Math.max(0, Math.min(100, field.width)),
- height: Math.max(0, Math.min(100, field.height)),
+ resetForm: fieldsArray.resetForm,
};
};
diff --git a/packages/lib/client-only/hooks/use-editor-form-array.ts b/packages/lib/client-only/hooks/use-editor-form-array.ts
new file mode 100644
index 000000000..920de7271
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-editor-form-array.ts
@@ -0,0 +1,245 @@
+import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
+import { nanoid } from '@documenso/lib/universal/id';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { type Resolver, useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { useLatestRef } from './use-latest-ref';
+
+/**
+ * The length of the client side IDs given to items created in the editor.
+ */
+const LOCAL_FORM_ID_LENGTH = 12;
+
+type EditorItem = {
+ /**
+ * The local client side ID of the item, stable for its editor lifetime.
+ */
+ formId: string;
+};
+
+/**
+ * The form is typed against the base item internally, since react-hook-form
+ * cannot resolve field paths against an open generic. Values are cast back to
+ * the caller's item type at the boundary.
+ */
+type EditorFormArrayValues = {
+ items: EditorItem[];
+};
+
+type UseEditorFormArrayOptions = {
+ schema: z.ZodType;
+
+ /**
+ * The persisted items the local list starts from and is reset to.
+ */
+ sources: TSource[];
+
+ /**
+ * Map a persisted item to its local shape. The form ID is assigned by the
+ * hook.
+ */
+ mapSource: (source: TSource) => Omit;
+
+ /**
+ * Called after any change which needs saving. Receives a getter so the
+ * values are read when the save is sent, not when it was queued, which
+ * matters when a save in flight corrects the local items (e.g. assigns
+ * server IDs).
+ */
+ onChange: (getItems: () => TItem[]) => unknown;
+};
+
+/**
+ * The local, autosaved list backing an editor surface (fields, contents).
+ *
+ * All lookups and writes go through the live form values rather than the
+ * render captured list. Callers can be arbitrarily far behind the list, e.g.
+ * a Konva handler bound at render time or an upload finishing after its item
+ * was deleted, and an index resolved from a stale list would hit whichever
+ * item has since moved into that slot.
+ */
+export const useEditorFormArray = ({
+ schema,
+ sources,
+ mapSource,
+ onChange,
+}: UseEditorFormArrayOptions) => {
+ const [selectedFormId, setSelectedFormId] = useState(null);
+
+ // The callers pass these as inline functions, so they are read through refs
+ // to keep every operation below referentially stable. Long lived callbacks
+ // (e.g. Konva handlers) capture those operations and must not go stale.
+ const mapSourceRef = useLatestRef(mapSource);
+ const onChangeRef = useLatestRef(onChange);
+
+ const toDefaultValues = useCallback(
+ (items: TSource[]): EditorFormArrayValues => ({
+ items: items.map((source) => ({ ...mapSourceRef.current(source), formId: createLocalFormId() })),
+ }),
+ [],
+ );
+
+ // Only read on mount, so compute it once rather than on every render.
+ const [initialValues] = useState(() => toDefaultValues(sources));
+
+ const form = useForm({
+ defaultValues: initialValues,
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ resolver: zodResolver(z.object({ items: z.array(schema) })) as Resolver,
+ });
+
+ const { append, remove, update, fields } = useFieldArray({
+ control: form.control,
+ name: 'items',
+ keyName: 'react-hook-form-id',
+ });
+
+ // `useFieldArray` merges its own key into each item, which the callers do
+ // not want to see.
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const items = fields as unknown as TItem[];
+
+ /**
+ * The live items. This is the form's own array, so callers must treat the
+ * items as read only and replace rather than mutate them.
+ */
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const getItems = useCallback(() => form.getValues().items as TItem[], [form]);
+
+ const findIndex = useCallback((formId: string) => getItems().findIndex((item) => item.formId === formId), [getItems]);
+
+ const findItem = useCallback(
+ (formId: string): TItem | undefined => getItems().find((item) => item.formId === formId),
+ [getItems],
+ );
+
+ const triggerChange = useCallback(() => {
+ void onChangeRef.current(getItems);
+ }, [getItems]);
+
+ /**
+ * Select an item by form ID. Unless bypassed, an ID which is not in the
+ * list clears the selection.
+ */
+ const setSelected = useCallback(
+ (formId: string | null, bypassCheck = false) => {
+ if (!formId || bypassCheck) {
+ setSelectedFormId(formId);
+ return;
+ }
+
+ setSelectedFormId(findItem(formId)?.formId ?? null);
+ },
+ [findItem],
+ );
+
+ const appendItems = useCallback(
+ (newItems: TItem[]) => {
+ if (newItems.length === 0) {
+ return;
+ }
+
+ // A single append so the list updates once however many items there are.
+ append(newItems);
+ triggerChange();
+ },
+ [append, triggerChange],
+ );
+
+ const removeByFormId = useCallback(
+ (formIds: string[]) => {
+ const indexes = formIds.map((formId) => findIndex(formId)).filter((index) => index !== -1);
+
+ if (indexes.length === 0) {
+ return;
+ }
+
+ remove(indexes);
+ triggerChange();
+ },
+ [findIndex, remove, triggerChange],
+ );
+
+ /**
+ * Replace an item with the result of `updater`. Returning the same item
+ * (by reference) leaves the list untouched and does not trigger a save.
+ */
+ const updateByFormId = useCallback(
+ (formId: string, updater: (current: TItem) => TItem, { shouldTriggerChange = true } = {}) => {
+ const index = findIndex(formId);
+
+ if (index === -1) {
+ return;
+ }
+
+ const current = getItems()[index];
+ const next = updater(current);
+
+ if (next === current) {
+ return;
+ }
+
+ update(index, next);
+
+ if (shouldTriggerChange) {
+ triggerChange();
+ }
+ },
+ [findIndex, getItems, update, triggerChange],
+ );
+
+ const selectedItem = useMemo(() => items.find((item) => item.formId === selectedFormId), [items, selectedFormId]);
+
+ // Drop the selection once its item leaves the list.
+ useEffect(() => {
+ if (selectedFormId && !items.some((item) => item.formId === selectedFormId)) {
+ setSelectedFormId(null);
+ }
+ }, [items, selectedFormId]);
+
+ const sourcesRef = useLatestRef(sources);
+
+ const resetForm = useCallback(
+ (nextSources?: TSource[]) => {
+ form.reset(toDefaultValues(nextSources ?? sourcesRef.current));
+ },
+ [form, toDefaultValues],
+ );
+
+ return {
+ items,
+ getItems,
+ findItem,
+ appendItems,
+ removeByFormId,
+ updateByFormId,
+ selectedItem,
+ setSelected,
+ resetForm,
+ };
+};
+
+/**
+ * A fresh local ID for an item created in the editor.
+ */
+export const createLocalFormId = () => nanoid(LOCAL_FORM_ID_LENGTH);
+
+/**
+ * Every page of the current document except the given one, for duplicating
+ * an item across pages.
+ */
+export const getOtherPageNumbers = (currentPage: number) => {
+ const totalPages = getPdfPagesCount();
+
+ const pages: number[] = [];
+
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
+ if (pageNumber !== currentPage) {
+ pages.push(pageNumber);
+ }
+ }
+
+ return pages;
+};
diff --git a/packages/lib/client-only/hooks/use-envelope-autosave.ts b/packages/lib/client-only/hooks/use-envelope-autosave.ts
index 93cc8a162..990e91387 100644
--- a/packages/lib/client-only/hooks/use-envelope-autosave.ts
+++ b/packages/lib/client-only/hooks/use-envelope-autosave.ts
@@ -6,12 +6,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
* Only one save runs at a time and the latest edit always wins. If the user
* keeps editing while a save is on the wire, their newest changes get saved
* right after, never dropped.
+ *
+ * A getter may be queued instead of a value, in which case it is read when
+ * the save is about to be sent rather than when it was queued. Callers whose
+ * state is corrected by the response of a save (e.g. rows which are assigned
+ * an ID by the server) need this, otherwise the queued save would be sent
+ * with data captured before that correction.
*/
export function useEnvelopeAutosave(saveFn: (data: T) => Promise, delay = 1000) {
const timeoutRef = useRef | null>(null);
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
- const pendingRef = useRef<{ value: T } | null>(null);
+ const pendingRef = useRef<{ value: T | (() => T) } | null>(null);
// The save currently running, if any. Shared so we never kick off two at once.
const commitPromiseRef = useRef | null>(null);
@@ -45,7 +51,8 @@ export function useEnvelopeAutosave(saveFn: (data: T) => Promise, delay
const { value } = pendingRef.current;
pendingRef.current = null;
- await saveFnRef.current(value);
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ await saveFnRef.current(typeof value === 'function' ? (value as () => T)() : value);
}
} finally {
// eslint-disable-next-line require-atomic-updates
@@ -61,7 +68,7 @@ export function useEnvelopeAutosave(saveFn: (data: T) => Promise, delay
}, []);
const triggerSave = useCallback(
- (data: T) => {
+ (data: T | (() => T)) => {
pendingRef.current = { value: data };
setIsPending(true);
diff --git a/packages/lib/client-only/hooks/use-latest-ref.ts b/packages/lib/client-only/hooks/use-latest-ref.ts
new file mode 100644
index 000000000..035fbf506
--- /dev/null
+++ b/packages/lib/client-only/hooks/use-latest-ref.ts
@@ -0,0 +1,15 @@
+import { useRef } from 'react';
+
+/**
+ * A ref which always holds the latest value.
+ *
+ * Useful for long lived callbacks (e.g. Konva event handlers bound once when
+ * a stage is created) which need to read current state without stale closures.
+ */
+export const useLatestRef = (value: T) => {
+ const ref = useRef(value);
+
+ ref.current = value;
+
+ return ref;
+};
diff --git a/packages/lib/client-only/hooks/use-page-renderer.ts b/packages/lib/client-only/hooks/use-page-renderer.ts
index 0d32a6199..7091caba6 100644
--- a/packages/lib/client-only/hooks/use-page-renderer.ts
+++ b/packages/lib/client-only/hooks/use-page-renderer.ts
@@ -1,18 +1,124 @@
import Konva from 'konva';
-import { useEffect, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { EnvelopePageItemsVisibility } from '../../types/envelope-page-items-visibility';
+import { resolvePageItemsVisibility } from '../../types/envelope-page-items-visibility';
+import { areContentImagesReady, hasFailedContentImage } from '../../universal/content-renderer/content-image-status';
+import { createPageItemsVisibilityApplier } from '../../universal/konva/page-items-visibility';
import type { PageRenderData } from '../providers/envelope-render-provider';
+import { useCurrentEnvelopeRender } from '../providers/envelope-render-provider';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
-export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => {
- const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber } = pageData;
+type UsePageRendererOptions = {
+ /**
+ * Additional fields visibility input which is composed with the viewer
+ * controls visibility, where the most restrictive input wins.
+ *
+ * Used by renderers with extra context, e.g. the editor muting fields while
+ * the contents tab is active.
+ */
+ fieldsVisibility?: EnvelopePageItemsVisibility;
+
+ /**
+ * Additional contents visibility input which is composed with the viewer
+ * controls visibility, where the most restrictive input wins.
+ */
+ contentsVisibility?: EnvelopePageItemsVisibility;
+};
+
+export const usePageRenderer = (
+ renderFunction: RenderFunction,
+ pageData: PageRenderData,
+ options: UsePageRendererOptions = {},
+) => {
+ const { pageWidth, pageHeight, scale, imageLoadingState, pageNumber, onReadyChange } = pageData;
+
+ const { viewerControls, contents, contentImages, currentEnvelopeItem, pageSizes } = useCurrentEnvelopeRender();
const konvaContainer = useRef(null);
+ // Publish the page size so page relative geometry can be computed outside
+ // the renderer, e.g. when sizing a content to an uploaded image.
+ useEffect(() => {
+ if (currentEnvelopeItem) {
+ pageSizes.register(currentEnvelopeItem.id, pageNumber, { width: pageWidth, height: pageHeight });
+ }
+ }, [currentEnvelopeItem?.id, pageNumber, pageWidth, pageHeight]);
+
+ const pageContentImagesOptions = {
+ contents,
+ loadingStatuses: contentImages.loadingStatuses,
+ requestedDataContentIds: contentImages.requestedDataContentIds,
+ pageNumber,
+ envelopeItemId: currentEnvelopeItem?.id ?? '',
+ };
+
+ const hasFailedImage = hasFailedContentImage(pageContentImagesOptions);
+
+ /**
+ * Whether the images of this page's contents have finished loading, so the
+ * page renders with its images in place rather than them popping in.
+ *
+ * Latched once true: readiness only delays the initial render and never
+ * tears down a live page, e.g. when the editor's contents gain a new image
+ * which is then drawn progressively.
+ */
+ const hasBeenReady = useRef(false);
+
+ if (!hasBeenReady.current && areContentImagesReady(pageContentImagesOptions)) {
+ hasBeenReady.current = true;
+ }
+
+ const isReady = imageLoadingState === 'loaded' && hasBeenReady.current;
+
+ // The page holds back its image until everything drawn on it is ready, so
+ // the two appear together rather than the page looking finished while its
+ // contents are still loading.
+ useEffect(() => {
+ onReadyChange?.(isReady);
+ }, [isReady, onReadyChange]);
+
const stage = useRef(null);
const pageLayer = useRef(null);
+ const fieldsVisibility = resolvePageItemsVisibility(
+ viewerControls.fieldsVisibility,
+ options.fieldsVisibility ?? 'visible',
+ );
+
+ const contentsVisibility = resolvePageItemsVisibility(
+ viewerControls.contentsVisibility,
+ options.contentsVisibility ?? 'visible',
+ );
+
+ // One applier per item kind, since each tracks its own muted state for the
+ // lifetime of the page (including across stage recreation).
+ const [applyFieldsVisibility] = useState(createPageItemsVisibilityApplier);
+ const [applyContentsVisibility] = useState(createPageItemsVisibilityApplier);
+
+ /**
+ * Apply the current fields/contents visibility to all named groups on the
+ * page layer.
+ *
+ * This is automatically applied when the page is created and when the
+ * visibility changes. Renderers which re-render groups outside of those
+ * moments should call this after drawing so newly created groups pick up
+ * the current visibility.
+ */
+ const applyPageItemsVisibility = useCallback(() => {
+ const layer = pageLayer.current;
+
+ if (!layer) {
+ return;
+ }
+
+ applyFieldsVisibility(layer.find('.field-group'), fieldsVisibility);
+ applyContentsVisibility(layer.find('.content-group'), contentsVisibility);
+
+ layer.batchDraw();
+ }, [fieldsVisibility, contentsVisibility]);
+
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
@@ -40,7 +146,7 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
useEffect(() => {
const { current: container } = konvaContainer;
- if (!container || imageLoadingState !== 'loaded') {
+ if (!container || !isReady) {
return;
}
@@ -65,6 +171,8 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
pageLayer: pageLayer.current,
});
+ applyPageItemsVisibility();
+
void document.fonts.ready.then(() => {
pageLayer.current?.batchDraw();
});
@@ -73,7 +181,14 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
stage.current?.destroy();
stage.current = null;
};
- }, [imageLoadingState, scaledViewport]);
+ }, [isReady, scaledViewport]);
+
+ /**
+ * Reapply the visibility whenever it changes.
+ */
+ useEffect(() => {
+ applyPageItemsVisibility();
+ }, [applyPageItemsVisibility]);
return {
konvaContainer,
@@ -81,5 +196,21 @@ export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRe
pageLayer,
unscaledViewport,
scaledViewport,
+ fieldsVisibility,
+ contentsVisibility,
+ applyPageItemsVisibility,
+
+ /**
+ * Whether the page bitmap and content images are ready and the stage has
+ * been (or is about to be) created.
+ */
+ isReady,
+
+ /**
+ * Whether a content image on this page failed to load. Renderers decide
+ * how to surface this, e.g. an error state for signers or a placeholder
+ * for authors.
+ */
+ hasFailedImage,
};
};
diff --git a/packages/lib/client-only/load-content-image.ts b/packages/lib/client-only/load-content-image.ts
new file mode 100644
index 000000000..52cc9a9ec
--- /dev/null
+++ b/packages/lib/client-only/load-content-image.ts
@@ -0,0 +1,88 @@
+import type { ContentImageSource } from '../universal/content-renderer/content-renderer';
+
+type LoadContentImageDependencies = {
+ fetch: (url: string) => Promise;
+ createImageBitmap: (blob: Blob) => Promise;
+};
+
+const defaultDependencies = (): LoadContentImageDependencies => ({
+ fetch: async (url) => await fetch(url),
+ createImageBitmap: async (blob) => await createImageBitmap(blob),
+});
+
+/**
+ * What is known about a content image's file, for display.
+ */
+export type ContentImageDetails = {
+ /**
+ * The size of the stored bytes.
+ */
+ fileSize: number;
+};
+
+export type LoadedContentImage = {
+ image: ContentImageSource;
+ details: ContentImageDetails;
+};
+
+/**
+ * Fetch and decode a content image, retrying once after a network or server
+ * error. Client errors (e.g. not found) fail immediately.
+ *
+ * The image is decoded off the main thread into an `ImageBitmap`, which is
+ * what the canvas renderer wants and carries no cross origin taint.
+ */
+export const loadContentImage = async (
+ url: string,
+ dependencies: LoadContentImageDependencies = defaultDependencies(),
+): Promise => {
+ try {
+ return await fetchAndDecode(url, dependencies);
+ } catch (error) {
+ if (!isRetryable(error)) {
+ throw error;
+ }
+
+ return await fetchAndDecode(url, dependencies);
+ }
+};
+
+type ContentImageResponseError = Error & { status: number };
+
+const createResponseError = (status: number): ContentImageResponseError =>
+ Object.assign(new Error(`Failed to load content image (${status})`), { status });
+
+const isResponseError = (error: unknown): error is ContentImageResponseError =>
+ error instanceof Error && 'status' in error && typeof error.status === 'number';
+
+const fetchAndDecode = async (
+ url: string,
+ { fetch, createImageBitmap }: LoadContentImageDependencies,
+): Promise => {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw createResponseError(response.status);
+ }
+
+ const blob = await response.blob();
+
+ return {
+ image: await createImageBitmap(blob),
+ details: {
+ fileSize: blob.size,
+ },
+ };
+};
+
+/**
+ * Network failures surface as thrown `TypeError`s, and server errors are
+ * worth one more attempt. Anything else is a definitive answer.
+ */
+const isRetryable = (error: unknown) => {
+ if (isResponseError(error)) {
+ return error.status >= 500;
+ }
+
+ return error instanceof TypeError;
+};
diff --git a/packages/lib/client-only/providers/envelope-editor-provider.tsx b/packages/lib/client-only/providers/envelope-editor-provider.tsx
index 70e929dbe..5752ed8c2 100644
--- a/packages/lib/client-only/providers/envelope-editor-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-editor-provider.tsx
@@ -6,6 +6,7 @@ import {
type TEditorEnvelope,
} from '@documenso/lib/types/envelope-editor';
import { trpc } from '@documenso/trpc/react';
+import type { TSetEnvelopeContentsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-contents.types';
import type { TSetEnvelopeFieldsResponse } from '@documenso/trpc/server/envelope-router/set-envelope-fields.types';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -21,6 +22,8 @@ import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useAnalytics } from '../hooks/use-analytics';
+import type { TLocalContent } from '../hooks/use-editor-contents';
+import { useEditorContents } from '../hooks/use-editor-contents';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorFields } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
@@ -28,6 +31,12 @@ import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
export type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
+/**
+ * The active tab within the fields step of the editor, which determines
+ * whether fields or contents are currently being edited on the canvas.
+ */
+export type EnvelopeEditorTab = 'fields' | 'contents';
+
type UpdateEnvelopePayload = Pick;
type EnvelopeEditorProviderValue = {
@@ -54,8 +63,12 @@ type EnvelopeEditorProviderValue = {
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType;
+ editorContents: ReturnType;
editorRecipients: ReturnType;
+ selectedEditorTab: EnvelopeEditorTab;
+ setSelectedEditorTab: (tab: EnvelopeEditorTab) => void;
+
isAutosaving: boolean;
flushAutosave: () => Promise;
autosaveError: boolean;
@@ -144,6 +157,8 @@ export const EnvelopeEditorProvider = ({
const [autosaveError, setAutosaveError] = useState(false);
+ const [selectedEditorTab, setSelectedEditorTab] = useState('fields');
+
const isCscMode = IS_INSTANCE_CSC_MODE();
/**
@@ -192,7 +207,12 @@ export const EnvelopeEditorProvider = ({
const editorFields = useEditorFields({
envelope,
- handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
+ handleFieldsUpdate: (getFields) => setFieldsDebounced(getFields),
+ });
+
+ const editorContents = useEditorContents({
+ envelope,
+ handleContentsUpdate: (getContents) => setContentsDebounced(getContents),
});
const editorRecipients = useEditorRecipients({
@@ -201,6 +221,7 @@ export const EnvelopeEditorProvider = ({
const setRecipientsMutation = trpc.envelope.recipient.set.useMutation();
const setFieldsMutation = trpc.envelope.field.set.useMutation();
+ const setContentsMutation = trpc.envelope.content.set.useMutation();
const updateEnvelopeMutation = trpc.envelope.update.useMutation();
/**
@@ -334,6 +355,79 @@ export const EnvelopeEditorProvider = ({
await flushSetFields();
};
+ /**
+ * Handles debouncing the content updates to the server.
+ *
+ * Will set the local envelope contents after the update is complete.
+ */
+ const {
+ triggerSave: setContentsDebounced,
+ flush: flushSetContents,
+ isPending: isContentsMutationPending,
+ } = useEnvelopeAutosave(async (localContents: TLocalContent[]) => {
+ try {
+ let contents: TSetEnvelopeContentsResponse['data'] = [];
+
+ const currentEnvelope = getEnvelope();
+
+ if (!isEmbedded) {
+ const response = await setContentsMutation.mutateAsync({
+ envelopeId: currentEnvelope.id,
+ contents: localContents.map((content) => ({
+ id: content.id,
+ formId: content.formId,
+ envelopeItemId: content.envelopeItemId,
+ contentMeta: content.contentMeta,
+ dataContentId: content.dataContentId,
+ })),
+ });
+
+ contents = response.data;
+ } else {
+ contents = mapLocalContentsToContents({ envelope: currentEnvelope, localContents });
+ }
+
+ setEnvelope((prev) => ({
+ ...prev,
+ contents,
+ }));
+
+ setAutosaveError(false);
+
+ // Insert the IDs into the local contents.
+ contents.forEach((content) => {
+ const localContent = localContents.find((localContent) => localContent.formId === content.formId);
+
+ if (!localContent) {
+ return;
+ }
+
+ editorContents.setContentPersistedIds(
+ localContent.formId,
+ { id: content.id, dataContentId: content.dataContentId },
+ localContent.dataContentId,
+ );
+ });
+ } catch (err) {
+ console.error(err);
+
+ analytics.captureException(err, {
+ source: isEmbedded ? 'embed' : 'editor',
+ location: 'autosave_contents',
+ envelopeId: envelope.id,
+ });
+
+ setAutosaveError(true);
+
+ toast({
+ title: t`Save failed`,
+ description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
+ variant: 'destructive',
+ duration: 7500,
+ });
+ }
+ }, 2000);
+
/**
* Handles debouncing the envelope updates to the server.
*
@@ -454,6 +548,7 @@ export const EnvelopeEditorProvider = ({
});
editorFields.resetForm(fetchedEnvelopeData.data.fields);
+ editorContents.resetForm(fetchedEnvelopeData.data.contents);
}
};
@@ -462,8 +557,10 @@ export const EnvelopeEditorProvider = ({
};
const isAutosaving = useMemo(() => {
- return isFieldsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending;
- }, [isFieldsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
+ return (
+ isFieldsMutationPending || isContentsMutationPending || isRecipientsMutationPending || isEnvelopeMutationPending
+ );
+ }, [isFieldsMutationPending, isContentsMutationPending, isRecipientsMutationPending, isEnvelopeMutationPending]);
const relativePath = useMemo(() => {
let documentRootPath = formatDocumentsPath(envelope.team.url);
@@ -519,10 +616,11 @@ export const EnvelopeEditorProvider = ({
});
editorFields.resetForm(currentEnvelope.fields);
+ editorContents.resetForm(currentEnvelope.contents);
};
const flushAutosave = async (): Promise => {
- await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
+ await Promise.all([flushSetFields(), flushSetContents(), flushSetRecipients(), flushUpdateEnvelope()]);
// Flush all registered external flushes (e.g., upload page's debounced item updates).
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
@@ -553,7 +651,10 @@ export const EnvelopeEditorProvider = ({
setRecipientsDebounced,
setRecipientsAsync,
editorFields,
+ editorContents,
editorRecipients,
+ selectedEditorTab,
+ setSelectedEditorTab,
autosaveError,
flushAutosave,
isAutosaving,
@@ -620,6 +721,28 @@ const mapLocalRecipientsToRecipients = ({
});
};
+type MapLocalContentsToContentsOptions = {
+ localContents: TLocalContent[];
+ envelope: TEditorEnvelope;
+};
+
+const mapLocalContentsToContents = ({
+ envelope,
+ localContents,
+}: MapLocalContentsToContentsOptions): TEditorEnvelope['contents'] => {
+ return localContents.map((content) => {
+ return {
+ // Local contents that have not been persisted yet use their formId as a
+ // placeholder ID until the server returns the real one.
+ id: content.id ?? content.formId,
+ envelopeId: envelope.id,
+ envelopeItemId: content.envelopeItemId,
+ contentMeta: content.contentMeta,
+ dataContentId: content.dataContentId,
+ };
+ });
+};
+
type MapLocalFieldsToFieldsOptions = {
localFields: TLocalField[];
envelope: TEditorEnvelope;
diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx
index 67f8d1405..2b035e2ce 100644
--- a/packages/lib/client-only/providers/envelope-render-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-render-provider.tsx
@@ -2,12 +2,46 @@ import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
-import type { Field, Recipient } from '@prisma/client';
+import type { EnvelopeContent, Field, Recipient } from '@prisma/client';
import type React from 'react';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { clamp } from 'remeda';
import type { TEnvelope } from '../../types/envelope';
+import { areContentsImprinted } from '../../utils/envelope';
+
+/**
+ * The minimum content data required to render a content.
+ */
+export type EnvelopeRenderContent = Pick;
+
+import type { EnvelopePageItemsVisibility } from '../../types/envelope-page-items-visibility';
import type { FieldRenderMode } from '../../universal/field-renderer/field-renderer';
+import { type ContentImages, useContentImages } from '../hooks/use-content-images';
+
+export const ENVELOPE_VIEWER_MIN_ZOOM = 0.5;
+export const ENVELOPE_VIEWER_MAX_ZOOM = 2;
+export const ENVELOPE_VIEWER_ZOOM_STEP = 0.25;
+
+/**
+ * User controls for the envelope PDF viewer, shared across the editor, signing
+ * and preview surfaces.
+ *
+ * Written by the viewer toolbar and consumed by the PDF viewer (zoom) and
+ * `usePageRenderer` (fields/contents visibility).
+ */
+export type EnvelopeViewerControls = {
+ zoom: number;
+ setZoom: (zoom: number) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+
+ fieldsVisibility: EnvelopePageItemsVisibility;
+ setFieldsVisibility: (visibility: EnvelopePageItemsVisibility) => void;
+
+ contentsVisibility: EnvelopePageItemsVisibility;
+ setContentsVisibility: (visibility: EnvelopePageItemsVisibility) => void;
+};
/**
* The signature data for an inserted signature field.
@@ -28,10 +62,34 @@ export type PageRenderData = {
pageWidth: number;
pageHeight: number;
imageLoadingState: ImageLoadingState;
+
+ /**
+ * Report whether everything drawn on the page is ready.
+ *
+ * A page renderer may have to load things of its own (e.g. the images of
+ * the page's contents), which it can only start once the page image is
+ * ready. The page waits for this so it is never shown half drawn.
+ */
+ onReadyChange?: (isReady: boolean) => void;
};
export type ImageLoadingState = 'loading' | 'loaded' | 'error';
+export type PageSize = {
+ width: number;
+ height: number;
+};
+
+/**
+ * The unscaled sizes of the pages rendered so far, registered by the page
+ * renderers so page relative geometry can be computed outside of them, e.g.
+ * by the settings panel.
+ */
+export type PageSizeRegistry = {
+ register: (envelopeItemId: string, pageNumber: number, size: PageSize) => void;
+ get: (envelopeItemId: string, pageNumber: number) => PageSize | null;
+};
+
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
showRecipientTooltip?: boolean;
@@ -60,6 +118,15 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[];
+ contents: EnvelopeRenderContent[];
+
+ /**
+ * The images of the image contents, loaded ahead of rendering.
+ */
+ contentImages: ContentImages;
+
+ pageSizes: PageSizeRegistry;
+
signatures: EnvelopeRenderFieldSignature[];
recipients: Pick[];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -67,6 +134,8 @@ type EnvelopeRenderProviderValue = {
renderError: boolean;
setRenderError: (renderError: boolean) => void;
overrideSettings?: EnvelopeRenderOverrideSettings;
+
+ viewerControls: EnvelopeViewerControls;
};
interface EnvelopeRenderProviderProps {
@@ -100,6 +169,13 @@ interface EnvelopeRenderProviderProps {
*/
fields?: Field[];
+ /**
+ * Optional contents which are passed down to renderers for custom rendering needs.
+ *
+ * Only pass if the CustomRenderer you are passing in wants contents.
+ */
+ contents?: EnvelopeRenderContent[];
+
/**
* Optional inserted signature data for signature fields.
*
@@ -157,6 +233,7 @@ export const EnvelopeRenderProvider = ({
envelope,
envelopeItems: envelopeItemsFromProps,
fields,
+ contents,
signatures,
token,
presignToken,
@@ -166,6 +243,36 @@ export const EnvelopeRenderProvider = ({
}: EnvelopeRenderProviderProps) => {
const [renderError, setRenderError] = useState(false);
+ const [zoom, setZoomInternal] = useState(1);
+ const [fieldsVisibility, setFieldsVisibility] = useState('visible');
+ const [contentsVisibility, setContentsVisibility] = useState('visible');
+
+ const setZoom = useCallback((value: number) => {
+ setZoomInternal(clamp(value, { min: ENVELOPE_VIEWER_MIN_ZOOM, max: ENVELOPE_VIEWER_MAX_ZOOM }));
+ }, []);
+
+ const zoomIn = useCallback(() => {
+ setZoomInternal((prev) => Math.min(ENVELOPE_VIEWER_MAX_ZOOM, prev + ENVELOPE_VIEWER_ZOOM_STEP));
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomInternal((prev) => Math.max(ENVELOPE_VIEWER_MIN_ZOOM, prev - ENVELOPE_VIEWER_ZOOM_STEP));
+ }, []);
+
+ const viewerControls = useMemo(
+ (): EnvelopeViewerControls => ({
+ zoom,
+ setZoom,
+ zoomIn,
+ zoomOut,
+ fieldsVisibility,
+ setFieldsVisibility,
+ contentsVisibility,
+ setContentsVisibility,
+ }),
+ [zoom, setZoom, zoomIn, zoomOut, fieldsVisibility, contentsVisibility],
+ );
+
const envelopeItems = useMemo(
() =>
[...envelopeItemsFromProps]
@@ -213,6 +320,34 @@ export const EnvelopeRenderProvider = ({
}
}, [currentItem, envelopeItems]);
+ // Once an envelope is sealed the contents are imprinted onto the current
+ // PDF and are not rendered, so their images are not loaded either.
+ const isContentsImprinted = version === 'current' && areContentsImprinted(envelope.status);
+
+ const contentImages = useContentImages({
+ envelopeId: envelope.id,
+ envelopeItemId: currentItem?.id ?? null,
+ contents: isContentsImprinted ? [] : (contents ?? []),
+ token,
+ presignToken,
+ });
+
+ // Sizes do not drive rendering, so a ref avoids re-rendering the tree as
+ // pages register themselves.
+ const pageSizesRef = useRef(new Map());
+
+ const pageSizes = useMemo(
+ (): PageSizeRegistry => ({
+ register: (envelopeItemId, pageNumber, size) => {
+ pageSizesRef.current.set(`${envelopeItemId}:${pageNumber}`, size);
+ },
+ get: (envelopeItemId, pageNumber) => {
+ return pageSizesRef.current.get(`${envelopeItemId}:${pageNumber}`) ?? null;
+ },
+ }),
+ [],
+ );
+
const recipientIds = useMemo(() => recipients.map((recipient) => recipient.id).sort(), [recipients]);
const getRecipientColorKey = useCallback(
@@ -230,12 +365,16 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
+ contents: contents ?? [],
+ contentImages,
+ pageSizes,
signatures: signatures ?? [],
recipients,
getRecipientColorKey,
renderError,
setRenderError,
overrideSettings,
+ viewerControls,
}}
>
{children}
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
index 98dff4757..a5f80e621 100644
--- a/packages/lib/constants/app.ts
+++ b/packages/lib/constants/app.ts
@@ -148,3 +148,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
};
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
+
+export const MAX_POSTGRES_INT = 2147483647;
diff --git a/packages/lib/constants/envelope-content.ts b/packages/lib/constants/envelope-content.ts
new file mode 100644
index 000000000..c0addc545
--- /dev/null
+++ b/packages/lib/constants/envelope-content.ts
@@ -0,0 +1,28 @@
+import { env } from '../utils/env';
+
+/**
+ * The maximum size of an image uploaded as envelope content, in MB.
+ */
+export const APP_CONTENT_IMAGE_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_CONTENT_IMAGE_SIZE_UPLOAD_LIMIT')) || 10;
+
+/**
+ * The MIME types accepted for envelope content image uploads.
+ *
+ * SVG is deliberately excluded, since it can embed scripts and external
+ * references and is not rasterized consistently across renderers.
+ */
+export const APP_CONTENT_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
+
+/**
+ * The number of contents allowed on a single envelope.
+ *
+ * 0 = Unlimited contents.
+ */
+export const DEFAULT_ENVELOPE_CONTENT_COUNT = 0;
+
+/**
+ * The number of image contents allowed on a single envelope.
+ *
+ * 0 = Unlimited image contents.
+ */
+export const DEFAULT_ENVELOPE_CONTENT_IMAGE_COUNT = 0;
diff --git a/packages/lib/constants/envelope-editor.ts b/packages/lib/constants/envelope-editor.ts
new file mode 100644
index 000000000..a17c2ed0b
--- /dev/null
+++ b/packages/lib/constants/envelope-editor.ts
@@ -0,0 +1,4 @@
+/**
+ * How far a duplicated field is offset from its original, in page percent.
+ */
+export const EDITOR_DUPLICATE_ITEM_OFFSET = 3;
diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
index 49758b323..107a5d587 100644
--- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts
+++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts
@@ -8,17 +8,17 @@ import { getLastPageDimensions } from '@documenso/lib/server-only/pdf/get-page-s
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
import { PDF } from '@libpdf/core';
-import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
+import type { DocumentData, Envelope, EnvelopeContent, EnvelopeItem, Field } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { nanoid } from 'nanoid';
-import { groupBy } from 'remeda';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
+import { loadContentImages } from '../../../server-only/data-content/load-content-images';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
-import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
+import { insertPageOverlays } from '../../../server-only/pdf/insert-page-overlay';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
@@ -67,6 +67,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
signature: true,
},
},
+ contents: true,
},
},
},
@@ -211,12 +212,19 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const { envelopeItem, pdfData } of prefetchedItems) {
- const envelopeItemFields = envelope.envelopeItems.find((item) => item.id === envelopeItem.id)?.field;
+ const originalEnvelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItem.id);
- if (!envelopeItemFields) {
+ if (!originalEnvelopeItem) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
+ const envelopeItemFields = originalEnvelopeItem.field;
+
+ // Contents are inserted into the PDF when the envelope is sent, so the
+ // current bytes already carry them and must not be drawn again. Only a
+ // reseal starts over from the `initialData` and has to reinsert them.
+ const envelopeItemContents = isResealing ? originalEnvelopeItem.contents : [];
+
let certificateDoc: PDF | null = null;
let auditLogDoc: PDF | null = null;
@@ -278,6 +286,7 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
envelope,
envelopeItem,
envelopeItemFields,
+ envelopeItemContents,
isRejected,
rejectionReason,
pdfData,
@@ -361,6 +370,7 @@ type DecorateAndSignPdfOptions = {
envelope: Pick;
envelopeItem: EnvelopeItem & { documentData: DocumentData };
envelopeItemFields: Field[];
+ envelopeItemContents: EnvelopeContent[];
isRejected: boolean;
rejectionReason: string;
pdfData: Uint8Array;
@@ -375,6 +385,7 @@ const decorateAndSignPdf = async ({
envelope,
envelopeItem,
envelopeItemFields,
+ envelopeItemContents,
isRejected,
rejectionReason,
pdfData,
@@ -388,10 +399,9 @@ const decorateAndSignPdf = async ({
// Upgrade to PDF 1.7 for better compatibility with signing
pdfDoc.upgradeVersion('1.7');
- // Add rejection stamp if the document is rejected
- if (isRejected) {
- await addRejectionStampToPdf(pdfDoc, rejectionReason);
- }
+ // The pages of the original document, before the certificate and audit log
+ // pages are appended, so the rejection stamp is only applied to those.
+ const documentPageCount = pdfDoc.getPageCount();
if (certificateDoc) {
await pdfDoc.copyPagesFrom(
@@ -430,56 +440,20 @@ const decorateAndSignPdf = async ({
// Handle V2 envelope insertions.
if (envelope.internalVersion === 2) {
- const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
+ const images = await loadContentImages(envelopeItemContents);
- for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
- const page = pdfDoc.getPage(Number(pageNumber) - 1);
+ await insertPageOverlays({
+ pdfDoc,
+ fields: envelopeItemFields,
+ contents: envelopeItemContents,
+ images,
+ });
+ }
- if (!page) {
- throw new Error(`Page ${pageNumber} does not exist`);
- }
-
- const pageWidth = page.width;
- const pageHeight = page.height;
-
- const overlayBytes = await insertFieldInPDFV2({
- pageWidth,
- pageHeight,
- fields,
- });
-
- const overlayPdf = await PDF.load(overlayBytes);
-
- const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
-
- // Rotate the page to the orientation that the react-pdf renders on the frontend.
- let translateX = 0;
- let translateY = 0;
-
- switch (page.rotation) {
- case 90:
- translateX = pageHeight;
- translateY = 0;
- break;
- case 180:
- translateX = pageWidth;
- translateY = pageHeight;
- break;
- case 270:
- translateX = 0;
- translateY = pageWidth;
- break;
- }
-
- // Draw the overlay on the page
- page.drawPage(embeddedPage, {
- x: translateX,
- y: translateY,
- rotate: {
- angle: page.rotation,
- },
- });
- }
+ // Add the rejection stamp after the fields and contents have been inserted
+ // so it is drawn on top of them, rather than being hidden by opaque contents.
+ if (isRejected) {
+ await addRejectionStampToPdf(pdfDoc, rejectionReason, { pageCount: documentPageCount });
}
// Re-flatten the form to handle our checkbox and radio fields that
diff --git a/packages/lib/server-only/admin/admin-global-search.ts b/packages/lib/server-only/admin/admin-global-search.ts
index 3a6e5fd40..a6c1331b4 100644
--- a/packages/lib/server-only/admin/admin-global-search.ts
+++ b/packages/lib/server-only/admin/admin-global-search.ts
@@ -1,9 +1,9 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
-export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
+import { MAX_POSTGRES_INT } from '../../constants/app';
-const MAX_POSTGRES_INT = 2147483647;
+export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
diff --git a/packages/lib/server-only/data-content/create-data-content-image.ts b/packages/lib/server-only/data-content/create-data-content-image.ts
new file mode 100644
index 000000000..db9d678b1
--- /dev/null
+++ b/packages/lib/server-only/data-content/create-data-content-image.ts
@@ -0,0 +1,59 @@
+import { prisma } from '@documenso/prisma';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { DataContentType, ZDataContentMetaSchema } from '../../types/data-content-meta';
+import { generateDatabaseId } from '../../universal/id';
+import { putFileServerSide } from '../../universal/upload/put-file.server';
+import { getNormalizedImageFileName, type NormalizeImageOptions, normalizeImage } from './normalize-image';
+
+export type CreateDataContentImageOptions = {
+ file: {
+ name: string;
+ arrayBuffer: () => Promise;
+ };
+ normalizeOptions?: NormalizeImageOptions;
+};
+
+/**
+ * Normalize an uploaded image, store it and create the data content record
+ * describing it.
+ *
+ * The record is created unlinked. It is attached to an envelope content via
+ * `setEnvelopeContents`.
+ */
+export const createDataContentImage = async ({ file, normalizeOptions }: CreateDataContentImageOptions) => {
+ const normalized = await normalizeImage(Buffer.from(await file.arrayBuffer()), normalizeOptions);
+
+ const fileName = getNormalizedImageFileName(file.name, normalized.mimeType);
+
+ // Validate the metadata before anything is written to storage so a bad
+ // name or dimension cannot leave behind an orphaned object.
+ const parsedMetadata = ZDataContentMetaSchema.safeParse({
+ type: DataContentType.IMAGE,
+ width: normalized.width,
+ height: normalized.height,
+ mimeType: normalized.mimeType,
+ fileName,
+ fileSize: normalized.buffer.byteLength,
+ });
+
+ if (!parsedMetadata.success) {
+ throw new AppError(AppErrorCode.INVALID_BODY, {
+ message: 'Invalid image meta',
+ });
+ }
+
+ const { type, data } = await putFileServerSide({
+ name: fileName,
+ type: normalized.mimeType,
+ arrayBuffer: async () => Promise.resolve(normalized.buffer),
+ });
+
+ return await prisma.dataContent.create({
+ data: {
+ id: generateDatabaseId('data'),
+ type,
+ data,
+ metadata: parsedMetadata.data,
+ },
+ });
+};
diff --git a/packages/lib/server-only/data-content/load-content-images.ts b/packages/lib/server-only/data-content/load-content-images.ts
new file mode 100644
index 000000000..9d911ca3e
--- /dev/null
+++ b/packages/lib/server-only/data-content/load-content-images.ts
@@ -0,0 +1,49 @@
+import { prisma } from '@documenso/prisma';
+import { Image as SkiaImage } from '@documenso/skia-canvas';
+import type { EnvelopeContent } from '@prisma/client';
+
+import { DataContentType } from '../../types/data-content-meta';
+import type { ContentImageMap, ContentImageSource } from '../../universal/content-renderer/content-renderer';
+import { getFileServerSide } from '../../universal/upload/get-file.server';
+import { getDataContentIds } from '../../utils/envelope-content';
+
+/**
+ * Load and decode the images attached to the given contents, keyed by data
+ * content ID, ready for the content renderer to draw on the server.
+ *
+ * Data contents which are not images are skipped.
+ */
+export const loadContentImages = async (
+ contents: Pick[],
+): Promise => {
+ const dataContentIds = getDataContentIds(contents);
+
+ if (dataContentIds.length === 0) {
+ return new Map();
+ }
+
+ const dataContents = await prisma.dataContent.findMany({
+ where: {
+ id: {
+ in: dataContentIds,
+ },
+ },
+ });
+
+ const entries = await Promise.all(
+ dataContents.map(async (dataContent): Promise<[string, ContentImageSource] | null> => {
+ if (dataContent.metadata.type !== DataContentType.IMAGE) {
+ return null;
+ }
+
+ const bytes = await getFileServerSide({
+ type: dataContent.type,
+ data: dataContent.data,
+ });
+
+ return [dataContent.id, new SkiaImage(Buffer.from(bytes)) as unknown as ContentImageSource];
+ }),
+ );
+
+ return new Map(entries.filter((entry) => entry !== null));
+};
diff --git a/packages/lib/server-only/data-content/normalize-image.test.ts b/packages/lib/server-only/data-content/normalize-image.test.ts
new file mode 100644
index 000000000..103c9aace
--- /dev/null
+++ b/packages/lib/server-only/data-content/normalize-image.test.ts
@@ -0,0 +1,187 @@
+import sharp from 'sharp';
+import { describe, expect, it } from 'vitest';
+
+import { AppError } from '../../errors/app-error';
+import { ZDataContentImageMeta } from '../../types/data-content-meta';
+import {
+ CONTENT_IMAGE_MAX_EDGE,
+ CONTENT_IMAGE_MAX_FILE_NAME_LENGTH,
+ getNormalizedImageFileName,
+ normalizeImage,
+} from './normalize-image';
+
+const createImage = (width: number, height: number) =>
+ sharp({
+ create: {
+ width,
+ height,
+ channels: 4,
+ background: { r: 200, g: 40, b: 40, alpha: 1 },
+ },
+ });
+
+describe('normalizeImage', () => {
+ it('keeps the dimensions and format of a png within the size limit', async () => {
+ const input = await createImage(30, 20).png().toBuffer();
+
+ const result = await normalizeImage(input);
+
+ expect(result).toMatchObject({ width: 30, height: 20, mimeType: 'image/png' });
+
+ const output = await sharp(result.buffer).metadata();
+
+ expect(output).toMatchObject({ format: 'png', width: 30, height: 20 });
+ });
+
+ it('keeps jpeg images as jpeg', async () => {
+ const input = await createImage(30, 20).jpeg().toBuffer();
+
+ const result = await normalizeImage(input);
+
+ expect(result.mimeType).toBe('image/jpeg');
+ expect((await sharp(result.buffer).metadata()).format).toBe('jpeg');
+ });
+
+ it('keeps webp images as webp', async () => {
+ const input = await createImage(30, 20).webp().toBuffer();
+
+ const result = await normalizeImage(input);
+
+ expect(result.mimeType).toBe('image/webp');
+ expect((await sharp(result.buffer).metadata()).format).toBe('webp');
+ });
+
+ it('downscales images larger than the maximum edge while preserving the aspect ratio', async () => {
+ const input = await createImage(CONTENT_IMAGE_MAX_EDGE * 2, CONTENT_IMAGE_MAX_EDGE / 2)
+ .png()
+ .toBuffer();
+
+ const result = await normalizeImage(input);
+
+ expect(result).toMatchObject({ width: CONTENT_IMAGE_MAX_EDGE, height: CONTENT_IMAGE_MAX_EDGE / 4 });
+ });
+
+ it('preserves transparency', async () => {
+ const input = await sharp({
+ create: {
+ width: 10,
+ height: 10,
+ channels: 4,
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
+ },
+ })
+ .png()
+ .toBuffer();
+
+ const result = await normalizeImage(input);
+
+ const { data } = await sharp(result.buffer).raw().toBuffer({ resolveWithObject: true });
+
+ // The alpha byte of the first RGBA pixel.
+ expect(data[3]).toBe(0);
+ });
+
+ it('applies the EXIF orientation and strips the tag', async () => {
+ // Orientation 6 means the stored pixels must be rotated 90ยฐ clockwise to
+ // be displayed correctly, which swaps the width and height.
+ const input = await createImage(30, 20).jpeg().withMetadata({ orientation: 6 }).toBuffer();
+
+ expect((await sharp(input).metadata()).orientation).toBe(6);
+
+ const result = await normalizeImage(input);
+
+ expect(result).toMatchObject({ width: 20, height: 30 });
+
+ const output = await sharp(result.buffer).metadata();
+
+ expect(output).toMatchObject({ width: 20, height: 30 });
+ expect(output.orientation).toBeUndefined();
+ });
+
+ it('rejects image formats outside the allowlist', async () => {
+ const input = await createImage(10, 10).gif().toBuffer();
+
+ await expect(normalizeImage(input)).rejects.toMatchObject({ code: 'INVALID_IMAGE_FILE' });
+ });
+
+ it('rejects svg images', async () => {
+ const input = Buffer.from('');
+
+ await expect(normalizeImage(input)).rejects.toMatchObject({ code: 'INVALID_IMAGE_FILE' });
+ });
+
+ it('rejects bytes which are not an image', async () => {
+ const input = Buffer.from('definitely not an image');
+
+ const error = await normalizeImage(input).catch((e) => e);
+
+ expect(error).toBeInstanceOf(AppError);
+ expect(error.code).toBe('INVALID_IMAGE_FILE');
+ });
+
+ it('rejects images exceeding the input pixel limit', async () => {
+ const input = await createImage(100, 100).png().toBuffer();
+
+ await expect(normalizeImage(input, { maxInputPixels: 50 * 50 })).rejects.toMatchObject({
+ code: 'INVALID_IMAGE_FILE',
+ });
+ });
+});
+
+describe('getNormalizedImageFileName', () => {
+ it('replaces the extension with the one matching the normalized format', () => {
+ expect(getNormalizedImageFileName('photo.JPEG', 'image/jpeg')).toBe('photo.jpg');
+ expect(getNormalizedImageFileName('logo.png', 'image/webp')).toBe('logo.webp');
+ });
+
+ it('appends an extension when the name has none', () => {
+ expect(getNormalizedImageFileName('stamp', 'image/png')).toBe('stamp.png');
+ });
+
+ it('only strips a trailing image extension', () => {
+ expect(getNormalizedImageFileName('my.png.backup', 'image/png')).toBe('my.png.backup.png');
+ });
+
+ it('truncates long names, which are echoed back as a response header', () => {
+ const normalized = getNormalizedImageFileName(`${'a'.repeat(5_000)}.png`, 'image/png');
+
+ expect(normalized.length).toBeLessThanOrEqual(CONTENT_IMAGE_MAX_FILE_NAME_LENGTH + '.png'.length);
+ expect(normalized.endsWith('.png')).toBe(true);
+ });
+
+ it('keeps names made of astral characters within the stored length limit', () => {
+ // Each emoji is two UTF-16 code units, so a code point based cap would
+ // let the stored name exceed the `length` based schema limit.
+ const normalized = getNormalizedImageFileName(`${'๐'.repeat(5_000)}.png`, 'image/png');
+
+ expect(normalized.length).toBeLessThanOrEqual(CONTENT_IMAGE_MAX_FILE_NAME_LENGTH + '.png'.length);
+ expect(normalized.endsWith('.png')).toBe(true);
+ expect(ZDataContentImageMeta.shape.fileName.safeParse(normalized).success).toBe(true);
+ });
+
+ it('does not leave a lone surrogate when truncating', () => {
+ // An odd number of leading code units puts the cut in the middle of an
+ // emoji, which must be dropped whole rather than split.
+ const normalized = getNormalizedImageFileName(`a${'๐'.repeat(5_000)}.png`, 'image/png');
+
+ expect(normalized.isWellFormed()).toBe(true);
+ expect(normalized.length).toBeLessThanOrEqual(CONTENT_IMAGE_MAX_FILE_NAME_LENGTH + '.png'.length);
+ });
+
+ it('strips characters which cannot appear in a header', () => {
+ expect(getNormalizedImageFileName('a\r\nb\tc.png', 'image/png')).toBe('a b c.png');
+ });
+
+ it('strips the extension when the name has trailing whitespace', () => {
+ expect(getNormalizedImageFileName('photo.png ', 'image/png')).toBe('photo.png');
+ expect(getNormalizedImageFileName('photo.png\n', 'image/png')).toBe('photo.png');
+ expect(getNormalizedImageFileName('photo .png', 'image/png')).toBe('photo.png');
+ });
+
+ it('falls back to a default name when nothing usable is left', () => {
+ expect(getNormalizedImageFileName('.png', 'image/png')).toBe('image.png');
+ expect(getNormalizedImageFileName(' ', 'image/jpeg')).toBe('image.jpg');
+ expect(getNormalizedImageFileName('\u0000\u0001', 'image/webp')).toBe('image.webp');
+ expect(getNormalizedImageFileName('', 'image/png')).toBe('image.png');
+ });
+});
diff --git a/packages/lib/server-only/data-content/normalize-image.ts b/packages/lib/server-only/data-content/normalize-image.ts
new file mode 100644
index 000000000..4565f293e
--- /dev/null
+++ b/packages/lib/server-only/data-content/normalize-image.ts
@@ -0,0 +1,177 @@
+import sharp from 'sharp';
+import { match } from 'ts-pattern';
+
+import { AppError } from '../../errors/app-error';
+import type { TDataContentImageMeta } from '../../types/data-content-meta';
+
+/**
+ * The longest edge an uploaded content image is scaled down to, in pixels.
+ *
+ * Content images are drawn at page scale, so anything beyond this only
+ * inflates storage and render times without a visible difference.
+ */
+export const CONTENT_IMAGE_MAX_EDGE = 2048;
+
+/**
+ * The longest file name kept for an uploaded content image, in UTF-16 code
+ * units (i.e. `String.prototype.length`).
+ *
+ * The name is stored in the data content's metadata and used to name the
+ * stored file, so it is capped to keep both bounded. Measured in code units
+ * to match the `fileName` limit in `ZDataContentImageMeta`, which Zod also
+ * checks against `length`. The extension is added on top of this cap.
+ */
+export const CONTENT_IMAGE_MAX_FILE_NAME_LENGTH = 200;
+
+/**
+ * The file name used when nothing usable is left of the uploaded name.
+ */
+export const CONTENT_IMAGE_FALLBACK_FILE_NAME = 'image';
+
+/**
+ * The maximum number of pixels an uploaded image may decode to.
+ *
+ * Bounds the memory used to decode an upload, since a tiny compressed file
+ * can expand to a huge bitmap. Comfortably fits a 12 megapixel phone photo.
+ */
+export const CONTENT_IMAGE_MAX_INPUT_PIXELS = 25_000_000;
+
+type SupportedImageFormat = 'png' | 'jpeg' | 'webp';
+
+const SUPPORTED_IMAGE_FORMATS: ReadonlySet = new Set(['png', 'jpeg', 'webp']);
+
+const isSupportedImageFormat = (format: string | undefined): format is SupportedImageFormat =>
+ format !== undefined && SUPPORTED_IMAGE_FORMATS.has(format);
+
+export type NormalizeImageOptions = {
+ maxEdge?: number;
+ maxInputPixels?: number;
+};
+
+export type NormalizedImage = {
+ buffer: Buffer;
+ width: number;
+ height: number;
+ mimeType: TDataContentImageMeta['mimeType'];
+};
+
+/**
+ * Validate and normalize an uploaded image for use as content.
+ *
+ * - Only PNG, JPEG and WebP are accepted, verified against the decoded bytes
+ * rather than the client supplied MIME type. Notably this excludes SVG.
+ * - The EXIF orientation is applied so the pixels match how the image is
+ * displayed, then all metadata (including EXIF and GPS) is stripped.
+ * - Images larger than `maxEdge` are scaled down, preserving aspect ratio.
+ *
+ * The original format is kept so photos stay compact as JPEG while graphics
+ * with transparency stay lossless as PNG.
+ */
+export const normalizeImage = async (
+ input: Buffer | Uint8Array,
+ { maxEdge = CONTENT_IMAGE_MAX_EDGE, maxInputPixels = CONTENT_IMAGE_MAX_INPUT_PIXELS }: NormalizeImageOptions = {},
+): Promise => {
+ const image = sharp(input, { limitInputPixels: maxInputPixels });
+
+ // Reading the metadata only parses the header, so an unsupported or
+ // malformed file is rejected before any pixels are decoded.
+ const metadata = await image.metadata().catch((error) => {
+ throw new AppError('INVALID_IMAGE_FILE', {
+ message: `Unable to read image: ${error instanceof Error ? error.message : 'unknown error'}`,
+ });
+ });
+
+ if (!isSupportedImageFormat(metadata.format)) {
+ throw new AppError('INVALID_IMAGE_FILE', {
+ message: `Unsupported image format: ${metadata.format ?? 'unknown'}`,
+ });
+ }
+
+ const format = metadata.format;
+
+ const { data, info } = await image
+ .rotate()
+ .resize({
+ width: maxEdge,
+ height: maxEdge,
+ fit: 'inside',
+ withoutEnlargement: true,
+ })
+ .toFormat(format)
+ .toBuffer({ resolveWithObject: true })
+ .catch((error) => {
+ throw new AppError('INVALID_IMAGE_FILE', {
+ message: `Unable to process image: ${error instanceof Error ? error.message : 'unknown error'}`,
+ });
+ });
+
+ return {
+ buffer: data,
+ width: info.width,
+ height: info.height,
+ mimeType: match(format)
+ .with('png', () => 'image/png' as const)
+ .with('jpeg', () => 'image/jpeg' as const)
+ .with('webp', () => 'image/webp' as const)
+ .exhaustive(),
+ };
+};
+
+/**
+ * Derive a file name whose extension matches the normalized format, since
+ * the upload may have been re-encoded.
+ */
+export const getNormalizedImageFileName = (fileName: string, mimeType: NormalizedImage['mimeType']) => {
+ const extension = match(mimeType)
+ .with('image/png', () => 'png')
+ .with('image/jpeg', () => 'jpg')
+ .with('image/webp', () => 'webp')
+ .exhaustive();
+
+ const baseName = fileName
+ // Non-whitespace control characters are stripped. Besides being
+ // meaningless in a file name, a NUL byte is rejected by Postgres jsonb
+ // and would fail the metadata insert. Whitespace controls (tab, CR, LF,
+ // VT, FF) are left for the collapse below.
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point
+ .replace(/[\u0000-\u0008\u000E-\u001F\u007F]/g, '')
+ // Runs of whitespace are collapsed and the length is capped below to keep
+ // the stored name tidy and bounded.
+ .replace(/\s+/g, ' ')
+ .trim()
+ // The extension is stripped after trimming so a name with trailing
+ // whitespace ("photo.png ") does not end up as "photo.png.png".
+ .replace(/\.(png|jpe?g|webp)$/i, '')
+ .trim();
+
+ const truncatedBaseName = truncateWithoutSplittingSurrogates(baseName, CONTENT_IMAGE_MAX_FILE_NAME_LENGTH);
+
+ // A name such as ".png" or one made entirely of control characters leaves
+ // nothing behind, which would otherwise produce a bare ".png".
+ const finalBaseName = truncatedBaseName === '' ? CONTENT_IMAGE_FALLBACK_FILE_NAME : truncatedBaseName;
+
+ return `${finalBaseName}.${extension}`;
+};
+
+/**
+ * Truncate a string to at most `maxLength` UTF-16 code units without cutting
+ * a surrogate pair in half.
+ *
+ * `String.prototype.slice` counts code units, so a naive slice can split an
+ * astral character (e.g. an emoji) and leave a lone surrogate, which Postgres
+ * jsonb rejects. Counting code units rather than code points keeps the result
+ * within the `length` based limit Zod enforces on the stored file name.
+ */
+const truncateWithoutSplittingSurrogates = (value: string, maxLength: number) => {
+ if (value.length <= maxLength) {
+ return value;
+ }
+
+ const lastCodeUnit = value.charCodeAt(maxLength - 1);
+
+ // A high surrogate at the cut point means the next unit is its low
+ // surrogate, so back up one to drop the whole pair.
+ const isHighSurrogate = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff;
+
+ return value.slice(0, isHighSurrogate ? maxLength - 1 : maxLength);
+};
diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts
index 1715739f7..f7c4e01e6 100644
--- a/packages/lib/server-only/document/send-document.ts
+++ b/packages/lib/server-only/document/send-document.ts
@@ -37,9 +37,11 @@ import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
+import { assertEnvelopeContentLimits, getContentsMissingImages } from '../../utils/envelope-content';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
+import { insertContentsIntoEnvelopeItem } from '../envelope-content/insert-contents-into-envelope-item';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -73,6 +75,13 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
},
fields: true,
documentMeta: true,
+ contents: {
+ select: {
+ id: true,
+ dataContentId: true,
+ contentMeta: true,
+ },
+ },
envelopeItems: {
select: {
id: true,
@@ -93,6 +102,8 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
organisationClaim: {
select: {
recipientCount: true,
+ envelopeContentCount: true,
+ envelopeContentImageCount: true,
},
},
},
@@ -186,6 +197,22 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
}
});
+ // Validate that every image content has an image, since they
+ // render nothing otherwise and the document would silently ship without them.
+ const contentsMissingImages = getContentsMissingImages(envelope.contents);
+
+ if (contentsMissingImages.length > 0) {
+ throw new AppError('MISSING_CONTENT_IMAGE', {
+ message: `The following contents have no image attached: ${contentsMissingImages.map((content) => content.id).join(', ')}.`,
+ statusCode: 400,
+ });
+ }
+
+ assertEnvelopeContentLimits(
+ envelope.contents.map((content) => content.contentMeta.type),
+ envelope.team.organisation.organisationClaim,
+ );
+
// Validate that recipients who require fields (e.g., signers need signature fields) have them.
const recipientsWithMissingFields = getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
@@ -199,6 +226,14 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
});
}
+ if (envelope.status === DocumentStatus.DRAFT) {
+ await Promise.all(
+ envelope.envelopeItems.map(async (envelopeItem) => {
+ await insertContentsIntoEnvelopeItem({ envelopeItemId: envelopeItem.id });
+ }),
+ );
+ }
+
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
(recipient) => recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
diff --git a/packages/lib/server-only/envelope-content/insert-contents-into-envelope-item.ts b/packages/lib/server-only/envelope-content/insert-contents-into-envelope-item.ts
new file mode 100644
index 000000000..bfdc69d48
--- /dev/null
+++ b/packages/lib/server-only/envelope-content/insert-contents-into-envelope-item.ts
@@ -0,0 +1,88 @@
+import { prisma } from '@documenso/prisma';
+import type { EnvelopeContent } from '@prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { getFileServerSide } from '../../universal/upload/get-file.server';
+import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
+import { generatePartialSignedPdf } from '../pdf/generate-partial-signed-pdf';
+
+type RenderContentsOntoPdfOptions = {
+ pdfData: Uint8Array;
+ contents: EnvelopeContent[];
+};
+
+/**
+ * Draw the given contents onto a PDF and return the new bytes.
+ *
+ * Throws `MISSING_CONTENT_IMAGE` if an image content's image cannot be
+ * loaded, since exporting it would silently drop it from the document.
+ */
+export const renderContentsOntoPdf = async ({ pdfData, contents }: RenderContentsOntoPdfOptions) => {
+ if (contents.length === 0) {
+ return pdfData;
+ }
+
+ return await generatePartialSignedPdf({ pdfData, fields: [], contents });
+};
+
+type BakeContentsIntoEnvelopeItemOptions = {
+ envelopeItemId: string;
+};
+
+/**
+ * Render an envelope item's contents into its PDF so that everything after
+ * DRAFT (signing, CSC/TSP, downloads) works from bytes which already contain
+ * them.
+ *
+ * The item's `DocumentData.initialData` is carried over untouched so the
+ * original upload remains downloadable and the seal - which starts from
+ * `initialData` and inserts the contents itself - keeps producing a single
+ * copy of each content.
+ */
+export const insertContentsIntoEnvelopeItem = async ({ envelopeItemId }: BakeContentsIntoEnvelopeItemOptions) => {
+ const envelopeItem = await prisma.envelopeItem.findFirst({
+ where: {
+ id: envelopeItemId,
+ },
+ include: {
+ documentData: true,
+ contents: true,
+ },
+ });
+
+ if (!envelopeItem) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: `Envelope item ${envelopeItemId} not found`,
+ });
+ }
+
+ if (envelopeItem.contents.length === 0) {
+ return;
+ }
+
+ const currentPdf = await getFileServerSide(envelopeItem.documentData);
+
+ const bakedPdf = await renderContentsOntoPdf({
+ pdfData: currentPdf,
+ contents: envelopeItem.contents,
+ });
+
+ const { documentData: bakedDocumentData } = await putPdfFileServerSide(
+ {
+ name: envelopeItem.title,
+ type: 'application/pdf',
+ arrayBuffer: async () => Promise.resolve(bakedPdf),
+ },
+ // Preserve the original document bytes.
+ envelopeItem.documentData.initialData,
+ );
+
+ await prisma.envelopeItem.update({
+ where: {
+ id: envelopeItem.id,
+ },
+ data: {
+ documentDataId: bakedDocumentData.id,
+ },
+ });
+};
diff --git a/packages/lib/server-only/envelope-content/set-envelope-contents.ts b/packages/lib/server-only/envelope-content/set-envelope-contents.ts
new file mode 100644
index 000000000..e105ea647
--- /dev/null
+++ b/packages/lib/server-only/envelope-content/set-envelope-contents.ts
@@ -0,0 +1,362 @@
+import type { EnvelopeContentType, TEnvelopeContentMetaInput } from '@documenso/lib/types/envelope-content-meta';
+import { CONTENT_TYPE_DATA_CONTENT_TYPE, ZEnvelopeContentMetaSchema } from '@documenso/lib/types/envelope-content-meta';
+import { prisma } from '@documenso/prisma';
+import { type DataContent, EnvelopeType } from '@prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
+import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
+import { generateDatabaseId } from '../../universal/id';
+import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
+import { createDocumentAuditLogData, diffContentChanges } from '../../utils/document-audit-logs';
+import { canContentBeChanged, type EnvelopeIdOptions } from '../../utils/envelope';
+import { assertEnvelopeContentSaveWithinLimits, getDataContentIds } from '../../utils/envelope-content';
+import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
+
+export type SetEnvelopeContentsOptions = {
+ userId: number;
+ teamId: number;
+ id: EnvelopeIdOptions;
+ contents: {
+ /**
+ * The ID of an existing content to update. Omit to create a new content.
+ */
+ id?: string | null;
+
+ /**
+ * A temporary client side ID, echoed back so the client can map newly
+ * created contents to their local counterparts.
+ */
+ formId?: string;
+
+ /**
+ * The envelope item the content is placed on. Fixed once the content is
+ * created: an update must send the item the content already belongs to.
+ */
+ envelopeItemId: string;
+
+ contentMeta: TEnvelopeContentMetaInput;
+
+ /**
+ * The ID of an uploaded data content (e.g. an image) to attach to the
+ * content, or null for none.
+ */
+ dataContentId: string | null;
+ }[];
+ requestMetadata: ApiRequestMetadata;
+};
+
+/**
+ * Replace the contents of an envelope with the given list.
+ *
+ * Existing contents not present in the list are deleted, contents with a
+ * matching ID are updated, and the rest are created.
+ *
+ * Data contents referenced by the list are attached. They are shared rather
+ * than copied, and never deleted once no longer referenced.
+ */
+export const setEnvelopeContents = async ({
+ userId,
+ teamId,
+ id,
+ contents,
+ requestMetadata,
+}: SetEnvelopeContentsOptions) => {
+ const { envelopeWhereInput } = await getEnvelopeWhereInput({
+ id,
+ type: null,
+ userId,
+ teamId,
+ });
+
+ const envelope = await prisma.envelope.findFirst({
+ where: envelopeWhereInput,
+ include: {
+ envelopeItems: {
+ select: {
+ id: true,
+ },
+ },
+ contents: true,
+ team: {
+ select: {
+ organisation: {
+ select: {
+ organisationClaim: {
+ select: {
+ envelopeContentCount: true,
+ envelopeContentImageCount: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!envelope) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Envelope not found',
+ });
+ }
+
+ // Contents are part of the authored document, so they follow the same
+ // rules as the file itself (frozen once sent, always editable on templates).
+ if (!canContentBeChanged(envelope)) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: 'Contents can no longer be modified for this envelope',
+ });
+ }
+
+ const existingContents = envelope.contents;
+
+ // The organisation's plan caps how much content an envelope may hold.
+ assertEnvelopeContentSaveWithinLimits({
+ incomingTypes: contents.map((content) => ZEnvelopeContentMetaSchema.parse(content.contentMeta).type),
+ existingTypes: existingContents.map((content) => content.contentMeta.type),
+ claim: envelope.team.organisation.organisationClaim,
+ });
+
+ const removedContents = existingContents.filter(
+ (existingContent) => !contents.some((content) => content.id === existingContent.id),
+ );
+
+ const requestedDataContentIds = getDataContentIds(contents);
+
+ // A data content is an immutable blob which any number of contents may point
+ // at, so it is shared rather than copied. Knowing its ID is all that is
+ // needed to attach it: the IDs are random and unguessable, and attaching one
+ // only lets its bytes be displayed, so there is no ownership check.
+ let requestedDataContents: DataContent[] = [];
+
+ // Verify that the data contents exist.
+ if (requestedDataContentIds.length > 0) {
+ requestedDataContents = await prisma.dataContent.findMany({
+ where: {
+ id: {
+ in: requestedDataContentIds,
+ },
+ },
+ });
+ }
+
+ const linkedContents = contents.map((content) => {
+ const foundEnvelopeItem = envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === content.envelopeItemId);
+
+ if (!foundEnvelopeItem) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: `Envelope item ${content.envelopeItemId} not found`,
+ });
+ }
+
+ const contentMeta = ZEnvelopeContentMetaSchema.parse(content.contentMeta);
+
+ // Only treat the content as existing if it actually belongs to this
+ // envelope, otherwise a new content is created.
+ const persisted = existingContents.find((existingContent) => existingContent.id === content.id);
+
+ // A content stays on the item it was created on. Nothing in the editor
+ // moves contents between items, and allowing it here would let a move
+ // slip through without an audit log since the diff only covers the
+ // content meta and attached image.
+ if (persisted && persisted.envelopeItemId !== content.envelopeItemId) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: `Content ${persisted.id} cannot be moved to a different envelope item`,
+ });
+ }
+
+ return {
+ ...content,
+ contentMeta,
+ dataContentId: resolveDataContentId(content.dataContentId, contentMeta.type, requestedDataContents),
+ _persisted: persisted,
+ };
+ });
+
+ const isAuditLogRequired = envelope.type === EnvelopeType.DOCUMENT;
+
+ const persistedContents = await prisma.$transaction(async (tx) => {
+ // Collected as the contents are written and inserted in one go at the end
+ // of the transaction, rather than a round trip per content.
+ const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
+
+ if (removedContents.length > 0) {
+ await tx.envelopeContent.deleteMany({
+ where: {
+ id: {
+ in: removedContents.map((content) => content.id),
+ },
+ envelopeId: envelope.id,
+ },
+ });
+
+ if (isAuditLogRequired) {
+ auditLogs.push(
+ ...removedContents.map((content) =>
+ createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_DELETED,
+ envelopeId: envelope.id,
+ metadata: requestMetadata,
+ data: {
+ contentId: content.id,
+ contentType: content.contentMeta.type,
+ envelopeItemId: content.envelopeItemId,
+ },
+ }),
+ ),
+ );
+ }
+ }
+
+ const upsertResults = await Promise.all(
+ linkedContents.map(async (content) => {
+ const { dataContentId } = content;
+
+ // Handle updating an existing content.
+ if (content._persisted) {
+ // The editor sends every content on every save, so the row is only
+ // written (and the update only logged) when something about the
+ // content actually changed.
+ const changes = diffContentChanges(content._persisted, {
+ contentMeta: content.contentMeta,
+ dataContentId,
+ });
+
+ if (changes.length === 0) {
+ return {
+ content: {
+ ...content._persisted,
+ formId: content.formId,
+ },
+ auditLog: null,
+ };
+ }
+
+ const updatedContent = await tx.envelopeContent.update({
+ where: {
+ id: content._persisted.id,
+ },
+ data: {
+ contentMeta: content.contentMeta,
+ dataContentId,
+ },
+ });
+
+ let auditLog: CreateDocumentAuditLogDataResponse | null = null;
+
+ if (isAuditLogRequired) {
+ auditLog = createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_UPDATED,
+ envelopeId: envelope.id,
+ metadata: requestMetadata,
+ data: {
+ contentId: updatedContent.id,
+ contentType: updatedContent.contentMeta.type,
+ envelopeItemId: updatedContent.envelopeItemId,
+ changes,
+ },
+ });
+ }
+
+ return {
+ content: {
+ ...updatedContent,
+ formId: content.formId,
+ },
+ auditLog,
+ };
+ }
+
+ // Handle creating a new content.
+ const createdContent = await tx.envelopeContent.create({
+ data: {
+ id: generateDatabaseId('envelope_content'),
+ envelopeId: envelope.id,
+ envelopeItemId: content.envelopeItemId,
+ contentMeta: content.contentMeta,
+ dataContentId,
+ },
+ });
+
+ let auditLog: CreateDocumentAuditLogDataResponse | null = null;
+
+ if (isAuditLogRequired) {
+ auditLog = createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_CREATED,
+ envelopeId: envelope.id,
+ metadata: requestMetadata,
+ data: {
+ contentId: createdContent.id,
+ contentType: createdContent.contentMeta.type,
+ envelopeItemId: createdContent.envelopeItemId,
+ contentMeta: createdContent.contentMeta,
+ dataContentId: createdContent.dataContentId,
+ },
+ });
+ }
+
+ return {
+ content: {
+ ...createdContent,
+ formId: content.formId,
+ },
+ auditLog,
+ };
+ }),
+ );
+
+ const upsertedContents = upsertResults.map((result) => result.content);
+
+ auditLogs.push(...upsertResults.flatMap((result) => (result.auditLog ? [result.auditLog] : [])));
+
+ // Data contents are never deleted. One that a content stops referencing
+ // may still be referenced by another (e.g. a document created from the
+ // same template), and the stored files behind them are never removed
+ // either.
+
+ if (auditLogs.length > 0) {
+ await tx.documentAuditLog.createMany({
+ data: auditLogs,
+ });
+ }
+
+ return upsertedContents;
+ });
+
+ return {
+ contents: persistedContents,
+ };
+};
+
+/**
+ * The data content to attach to a content, or null for none. Throws if the
+ * data content does not exist or is not the type the content type can hold.
+ */
+const resolveDataContentId = (
+ dataContentId: string | null | undefined,
+ contentType: EnvelopeContentType,
+ dataContents: Pick[],
+) => {
+ if (!dataContentId) {
+ return null;
+ }
+
+ const dataContent = dataContents.find((item) => item.id === dataContentId);
+
+ if (!dataContent) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: `Data content ${dataContentId} not found`,
+ });
+ }
+
+ const expectedDataContentType = CONTENT_TYPE_DATA_CONTENT_TYPE[contentType];
+
+ if (!expectedDataContentType || dataContent.metadata.type !== expectedDataContentType) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: `Data content ${dataContent.id} (${dataContent.metadata.type}) cannot be used by a ${contentType} content`,
+ });
+ }
+
+ return dataContent.id;
+};
diff --git a/packages/lib/server-only/envelope-content/upload-envelope-content-image.ts b/packages/lib/server-only/envelope-content/upload-envelope-content-image.ts
new file mode 100644
index 000000000..f5715177e
--- /dev/null
+++ b/packages/lib/server-only/envelope-content/upload-envelope-content-image.ts
@@ -0,0 +1,122 @@
+import { prisma } from '@documenso/prisma';
+import { EnvelopeType } from '@prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
+import { EnvelopeContentType } from '../../types/envelope-content-meta';
+import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
+import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
+import { canContentBeChanged, type EnvelopeIdOptions } from '../../utils/envelope';
+import { createDataContentImage } from '../data-content/create-data-content-image';
+import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
+
+export type UploadEnvelopeContentImageOptions = {
+ userId: number;
+ teamId: number;
+ id: EnvelopeIdOptions;
+ envelopeContentId: string;
+ file: File;
+ requestMetadata: ApiRequestMetadata;
+};
+
+/**
+ * Store an image and attach it to an image content.
+ *
+ * This is the only way a data content comes into being. The content must
+ * already exist, so the client flushes its pending saves before calling
+ * this, and the dialog which drives it keeps the editor blocked until it
+ * settles - so there is never more than one upload in flight.
+ */
+export const uploadEnvelopeContentImage = async ({
+ userId,
+ teamId,
+ id,
+ envelopeContentId,
+ file,
+ requestMetadata,
+}: UploadEnvelopeContentImageOptions) => {
+ const { envelopeWhereInput } = await getEnvelopeWhereInput({
+ id,
+ type: null,
+ userId,
+ teamId,
+ });
+
+ const content = await prisma.envelopeContent.findFirst({
+ where: {
+ id: envelopeContentId,
+ envelope: envelopeWhereInput,
+ },
+ include: {
+ envelope: {
+ select: {
+ id: true,
+ type: true,
+ status: true,
+ },
+ },
+ },
+ });
+
+ if (!content) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Content not found',
+ });
+ }
+
+ if (!canContentBeChanged(content.envelope)) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: 'Contents can no longer be modified for this envelope',
+ });
+ }
+
+ if (content.contentMeta.type !== EnvelopeContentType.IMAGE) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: `A ${content.contentMeta.type} content cannot hold an image`,
+ });
+ }
+
+ const dataContent = await createDataContentImage({ file });
+
+ const updatedContent = await prisma.$transaction(async (tx) => {
+ const updated = await tx.envelopeContent.update({
+ where: {
+ id: content.id,
+ },
+ data: {
+ dataContentId: dataContent.id,
+ },
+ });
+
+ // Templates are not audit logged, matching `setEnvelopeContents`.
+ if (content.envelope.type === EnvelopeType.DOCUMENT) {
+ await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_UPDATED,
+ envelopeId: content.envelope.id,
+ metadata: requestMetadata,
+ data: {
+ contentId: content.id,
+ contentType: content.contentMeta.type,
+ envelopeItemId: content.envelopeItemId,
+ changes: [
+ {
+ type: 'PROPERTY',
+ key: 'dataContentId',
+ from: content.dataContentId,
+ to: dataContent.id,
+ },
+ ],
+ },
+ }),
+ });
+ }
+
+ return updated;
+ });
+
+ return {
+ content: updatedContent,
+ dataContent,
+ };
+};
diff --git a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts
index fb305e311..427f42df2 100644
--- a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts
+++ b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts
@@ -4,7 +4,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
-import type { Envelope, Field, Recipient } from '@prisma/client';
+import type { Envelope, EnvelopeContent, Field, Recipient } from '@prisma/client';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
@@ -64,6 +64,14 @@ type UnsafeReplaceEnvelopeItemPdfResult = {
* otherwise `undefined`.
*/
fields: Field[] | undefined;
+
+ /**
+ * The full list of contents for the envelope after the replacement.
+ *
+ * Only returned when contents were deleted during the replacement,
+ * otherwise `undefined`.
+ */
+ contents: EnvelopeContent[] | undefined;
};
export const UNSAFE_replaceEnvelopeItemPdf = async ({
@@ -95,6 +103,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
});
let didFieldsChange = false;
+ let didContentsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
@@ -114,7 +123,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
// Todo: Audit log if we're updating the title or order.
// Delete fields that reference pages beyond the new PDF's page count.
- const outOfBoundsFields = await tx.field.findMany({
+ const { count: deletedFieldCount } = await tx.field.deleteMany({
where: {
envelopeId: envelope.id,
envelopeItemId,
@@ -122,23 +131,38 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
gt: filePageCount,
},
},
+ });
+
+ if (deletedFieldCount > 0) {
+ didFieldsChange = true;
+ }
+
+ const itemContents = await tx.envelopeContent.findMany({
+ where: {
+ envelopeId: envelope.id,
+ envelopeItemId,
+ },
select: {
id: true,
+ contentMeta: true,
},
});
- const deletedFieldIds = outOfBoundsFields.map((f) => f.id);
+ const outOfBoundsContentIds = itemContents
+ .filter((content) => content.contentMeta.page > filePageCount)
+ .map((content) => content.id);
- if (deletedFieldIds.length > 0) {
- await tx.field.deleteMany({
+ // Delete contents that reference pages beyond the new PDF's page count.
+ if (outOfBoundsContentIds.length > 0) {
+ await tx.envelopeContent.deleteMany({
where: {
id: {
- in: deletedFieldIds,
+ in: outOfBoundsContentIds,
},
},
});
- didFieldsChange = true;
+ didContentsChange = true;
}
if (recipients.length > 0 && placeholders.length > 0) {
@@ -223,8 +247,24 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
}
}
+ let contents: EnvelopeContent[] | undefined;
+
+ if (didContentsChange) {
+ try {
+ contents = await prisma.envelopeContent.findMany({
+ where: {
+ envelopeId: envelope.id,
+ },
+ });
+ } catch (err) {
+ // Do nothing.
+ console.error(err);
+ }
+ }
+
return {
updatedItem: updatedEnvelopeItem,
fields,
+ contents,
};
};
diff --git a/packages/lib/server-only/envelope/duplicate-envelope.ts b/packages/lib/server-only/envelope/duplicate-envelope.ts
index 4770814f8..90d044bce 100644
--- a/packages/lib/server-only/envelope/duplicate-envelope.ts
+++ b/packages/lib/server-only/envelope/duplicate-envelope.ts
@@ -8,6 +8,7 @@ import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
+import { buildEnvelopeContentCopyData } from '../../utils/envelope-content';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
@@ -22,11 +23,17 @@ export interface DuplicateEnvelopeOptions {
duplicateAsTemplate?: boolean;
includeRecipients?: boolean;
includeFields?: boolean;
+ includeContents?: boolean;
};
}
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
- const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
+ const {
+ duplicateAsTemplate = false,
+ includeRecipients = true,
+ includeFields = true,
+ includeContents = true,
+ } = overrides ?? {};
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
id,
@@ -38,6 +45,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
+ id: true,
type: true,
title: true,
userId: true,
@@ -60,6 +68,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
authOptions: true,
visibility: true,
documentMeta: true,
+ contents: true,
recipients: {
select: {
email: true,
@@ -136,7 +145,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
signatureLevel: duplicatedSignatureLevel,
userId,
teamId,
- title: envelope.title + ' (copy)',
+ title: `${envelope.title} (copy)`,
documentMetaId: createdDocumentMeta.id,
authOptions: envelope.authOptions || undefined,
visibility: envelope.visibility,
@@ -217,6 +226,20 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
);
}
+ if (includeContents) {
+ const contentsToCreate = buildEnvelopeContentCopyData({
+ contents: envelope.contents,
+ envelopeId: duplicatedEnvelope.id,
+ envelopeItemIdMap: oldEnvelopeItemToNewEnvelopeItemIdMap,
+ });
+
+ if (contentsToCreate.length > 0) {
+ await prisma.envelopeContent.createMany({
+ data: contentsToCreate,
+ });
+ }
+ }
+
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
diff --git a/packages/lib/server-only/envelope/get-editor-envelope-by-id.ts b/packages/lib/server-only/envelope/get-editor-envelope-by-id.ts
index 4580ce05c..fb40149c0 100644
--- a/packages/lib/server-only/envelope/get-editor-envelope-by-id.ts
+++ b/packages/lib/server-only/envelope/get-editor-envelope-by-id.ts
@@ -60,6 +60,7 @@ export const getEditorEnvelopeById = async ({ id, userId, teamId, type }: GetEdi
},
},
fields: true,
+ contents: true,
team: {
select: {
id: true,
diff --git a/packages/lib/server-only/envelope/get-envelope-by-id.ts b/packages/lib/server-only/envelope/get-envelope-by-id.ts
index d72354018..d452ed5e5 100644
--- a/packages/lib/server-only/envelope/get-envelope-by-id.ts
+++ b/packages/lib/server-only/envelope/get-envelope-by-id.ts
@@ -62,6 +62,7 @@ export const getEnvelopeById = async ({ id, userId, teamId, type }: GetEnvelopeB
},
},
fields: true,
+ contents: true,
team: {
select: {
id: true,
diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts
index 6383bc7f7..5a5e5e577 100644
--- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts
+++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
+import { getContentsMissingImages } from '../../utils/envelope-content';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
@@ -51,6 +52,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
},
},
documentMeta: true,
+ contents: true,
recipients: {
include: {
fields: {
@@ -137,6 +139,15 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
+ // An image content with no image renders nothing, and creating the document
+ // from this template would be rejected at the very end. It is reported here
+ // so the signer is not sent through the whole document first.
+ if (getContentsMissingImages(envelope.contents).length > 0) {
+ throw new AppError('MISSING_CONTENT_IMAGE', {
+ message: 'One or more image contents on this direct template have no image attached',
+ });
+ }
+
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
index 116238f3b..fbe7a5789 100644
--- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
+++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts
@@ -10,6 +10,7 @@ import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
+import { ZEnvelopeContentSchema } from '../../types/envelope-content';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientExpired } from '../../utils/recipients';
@@ -75,6 +76,8 @@ export const ZEnvelopeForSigningResponse = z.object({
})
.array(),
+ contents: ZEnvelopeContentSchema.array(),
+
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
@@ -186,6 +189,7 @@ export const getEnvelopeForRecipientSigning = async ({
},
},
documentMeta: true,
+ contents: true,
recipients: {
include: {
fields: {
diff --git a/packages/lib/server-only/envelope/get-envelopes-by-ids.ts b/packages/lib/server-only/envelope/get-envelopes-by-ids.ts
index e886e3dfe..024324467 100644
--- a/packages/lib/server-only/envelope/get-envelopes-by-ids.ts
+++ b/packages/lib/server-only/envelope/get-envelopes-by-ids.ts
@@ -75,6 +75,7 @@ export const getEnvelopesByIds = async ({ ids, userId, teamId, type }: GetEnvelo
},
},
fields: true,
+ contents: true,
team: {
select: {
id: true,
diff --git a/packages/lib/server-only/organisation/create-organisation.ts b/packages/lib/server-only/organisation/create-organisation.ts
index 8dcd4bd31..06a8c4772 100644
--- a/packages/lib/server-only/organisation/create-organisation.ts
+++ b/packages/lib/server-only/organisation/create-organisation.ts
@@ -197,6 +197,8 @@ export const createOrganisationClaimUpsertData = (
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
+ envelopeContentCount: subscriptionClaim.envelopeContentCount,
+ envelopeContentImageCount: subscriptionClaim.envelopeContentImageCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
diff --git a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
index 130d55109..b17b40baf 100644
--- a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
+++ b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts
@@ -2,12 +2,27 @@ import { type PDF, rgb } from '@libpdf/core';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
+type AddRejectionStampToPdfOptions = {
+ /**
+ * The number of pages to stamp, counted from the start of the document.
+ *
+ * Do not include the certificate and audit log pages.
+ *
+ * Defaults to every page.
+ */
+ pageCount?: number;
+};
+
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
-export async function addRejectionStampToPdf(pdf: PDF, reason: string): Promise {
- const pages = pdf.getPages();
+export async function addRejectionStampToPdf(
+ pdf: PDF,
+ reason: string,
+ { pageCount }: AddRejectionStampToPdfOptions = {},
+): Promise {
+ const pages = pdf.getPages().slice(0, pageCount);
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
res.arrayBuffer(),
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
new file mode 100644
index 000000000..7b5f041ce
--- /dev/null
+++ b/packages/lib/server-only/pdf/generate-partial-signed-pdf.test.ts
@@ -0,0 +1,61 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { PDF } from '@libpdf/core';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+import {
+ EnvelopeContentShapeType,
+ EnvelopeContentType,
+ ZEnvelopeContentMetaSchema,
+} from '../../types/envelope-content-meta';
+import { generatePartialSignedPdf } from './generate-partial-signed-pdf';
+
+/**
+ * The fonts are resolved relative to the working directory of the app.
+ */
+beforeAll(() => {
+ process.chdir(path.join(__dirname, '../../../../apps/remix'));
+});
+
+const examplePdf = readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
+
+const rectangle = {
+ id: 'envelope_content_rect',
+ envelopeId: 'envelope_1',
+ envelopeItemId: 'envelope_item_1',
+ dataContentId: null,
+ contentMeta: ZEnvelopeContentMetaSchema.parse({
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: 1,
+ rotation: 0,
+ positionX: 10,
+ positionY: 10,
+ width: 30,
+ height: 20,
+ fillColor: '#ff0000',
+ fillOpacity: 1,
+ }),
+};
+
+describe('generatePartialSignedPdf', () => {
+ it('produces a valid PDF when there is nothing to draw', async () => {
+ const output = await generatePartialSignedPdf({ pdfData: examplePdf });
+
+ const pdf = await PDF.load(output);
+
+ expect(pdf.getPageCount()).toBe(1);
+ });
+
+ it('draws the contents onto the page', async () => {
+ const output = await generatePartialSignedPdf({ pdfData: examplePdf, contents: [rectangle] });
+
+ // The overlay is added as a new XObject, so the file must grow and must
+ // still be a valid single page PDF.
+ expect(output.byteLength).toBeGreaterThan(examplePdf.byteLength);
+
+ const pdf = await PDF.load(output);
+
+ expect(pdf.getPageCount()).toBe(1);
+ });
+});
diff --git a/packages/lib/server-only/pdf/generate-partial-signed-pdf.ts b/packages/lib/server-only/pdf/generate-partial-signed-pdf.ts
index ad4465229..70bd4b3af 100644
--- a/packages/lib/server-only/pdf/generate-partial-signed-pdf.ts
+++ b/packages/lib/server-only/pdf/generate-partial-signed-pdf.ts
@@ -1,74 +1,34 @@
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { PDF } from '@libpdf/core';
-import { groupBy } from 'remeda';
+import type { EnvelopeContent } from '@prisma/client';
-import { insertFieldInPDFV2 } from './insert-field-in-pdf-v2';
+import { loadContentImages } from '../data-content/load-content-images';
+import { insertPageOverlays } from './insert-page-overlay';
type GeneratePartialSignedPdfOptions = {
pdfData: Uint8Array;
fields: FieldWithSignature[];
+ contents?: EnvelopeContent[];
};
/**
- * Generates a PDF with all currently-inserted fields burned in. Used to serve
- * partially signed envelopes during the `PENDING` window before the seal job
- * has had a chance to produce the final sealed PDF.
+ * Generates a PDF with all currently-inserted fields (and the authored
+ * contents) burned in. Used to serve partially signed envelopes during the
+ * `PENDING` window before the seal job has had a chance to produce the final
+ * sealed PDF.
*
* No PKI signature, no certificate page, no audit log appendix - this is a
* preview of the in-progress envelope, not a final executed document.
*/
-export const generatePartialSignedPdf = async ({ pdfData, fields }: GeneratePartialSignedPdfOptions) => {
+export const generatePartialSignedPdf = async ({ pdfData, fields, contents = [] }: GeneratePartialSignedPdfOptions) => {
const pdfDoc = await PDF.load(pdfData);
pdfDoc.flattenAll();
pdfDoc.upgradeVersion('1.7');
- const fieldsGroupedByPage = groupBy(fields, (field) => field.page);
+ const images = await loadContentImages(contents);
- for (const [pageNumber, pageFields] of Object.entries(fieldsGroupedByPage)) {
- const page = pdfDoc.getPage(Number(pageNumber) - 1);
-
- if (!page) {
- throw new Error(`Page ${pageNumber} does not exist`);
- }
-
- const pageWidth = page.width;
- const pageHeight = page.height;
- const overlayBytes = await insertFieldInPDFV2({
- pageWidth,
- pageHeight,
- fields: pageFields,
- });
-
- const overlayPdf = await PDF.load(overlayBytes);
- const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
-
- let translateX = 0;
- let translateY = 0;
-
- switch (page.rotation) {
- case 90:
- translateX = pageHeight;
- translateY = 0;
- break;
- case 180:
- translateX = pageWidth;
- translateY = pageHeight;
- break;
- case 270:
- translateX = 0;
- translateY = pageWidth;
- break;
- }
-
- page.drawPage(embeddedPage, {
- x: translateX,
- y: translateY,
- rotate: {
- angle: page.rotation,
- },
- });
- }
+ await insertPageOverlays({ pdfDoc, fields, contents, images });
pdfDoc.flattenAll();
diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts b/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts
index ee337c1e9..0f06cf539 100644
--- a/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts
+++ b/packages/lib/server-only/pdf/insert-field-in-pdf-v2.ts
@@ -3,23 +3,69 @@ import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
+import type { EnvelopeContent } from '@prisma/client';
import Konva from 'konva';
+import type { ContentImageMap } from '../../universal/content-renderer/content-renderer';
+import { renderContent } from '../../universal/content-renderer/render-content';
import { renderField } from '../../universal/field-renderer/render-field';
+import { sortContentsForRender } from '../../utils/envelope-content';
import { ensureFontLibrary } from './helpers';
+export type OverlayContent = Pick;
+
type InsertFieldInPDFV2Options = {
pageWidth: number;
pageHeight: number;
fields: FieldWithSignature[];
+
+ /**
+ * The contents to render beneath the fields.
+ */
+ contents?: OverlayContent[];
+
+ /**
+ * The loaded images of the image contents, see `loadContentImages`.
+ */
+ images?: ContentImageMap;
};
-export const insertFieldInPDFV2 = async ({ pageWidth, pageHeight, fields }: InsertFieldInPDFV2Options) => {
+/**
+ * Render the given page's contents and fields into a single page PDF overlay,
+ * to be embedded onto the original page.
+ */
+export const insertFieldInPDFV2 = async ({
+ pageWidth,
+ pageHeight,
+ fields,
+ contents = [],
+ images,
+}: InsertFieldInPDFV2Options) => {
ensureFontLibrary();
let stage: Konva.Stage | null = new Konva.Stage({ width: pageWidth, height: pageHeight });
let layer: Konva.Layer | null = new Konva.Layer();
+ // Render the contents first so they sit beneath the fields, in stacking
+ // order so the last one is on top.
+ for (const content of sortContentsForRender(contents, (content) => content.contentMeta.zIndex)) {
+ renderContent(
+ {
+ renderId: content.id,
+ contentMeta: content.contentMeta,
+ dataContentId: content.dataContentId,
+ },
+ {
+ scale: 1,
+ pageLayer: layer,
+ pageWidth,
+ pageHeight,
+ mode: 'export',
+ images,
+ },
+ );
+ }
+
// Render the fields onto the layer.
for (const field of fields) {
renderField({
diff --git a/packages/lib/server-only/pdf/insert-page-overlay.test.ts b/packages/lib/server-only/pdf/insert-page-overlay.test.ts
new file mode 100644
index 000000000..64ea9910f
--- /dev/null
+++ b/packages/lib/server-only/pdf/insert-page-overlay.test.ts
@@ -0,0 +1,395 @@
+import { readFileSync } from 'node:fs';
+
+import path from 'node:path';
+import { Canvas, Image as SkiaImage } from '@documenso/skia-canvas';
+import { PDF, PdfArray, PdfDict, PdfNumber } from '@libpdf/core';
+import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs';
+import sharp from 'sharp';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+import { EnvelopeContentShapeType, EnvelopeContentType } from '../../types/envelope-content-meta';
+import { insertPageOverlays } from './insert-page-overlay';
+
+/**
+ * A box content covering 10-60% horizontally and 20-45% vertically.
+ */
+const RECT = { positionX: 10, positionY: 20, width: 50, height: 25 };
+
+const STROKE_WIDTH = 2;
+
+type PixelMatcher = (r: number, g: number, b: number) => boolean;
+
+const isRed: PixelMatcher = (r, g, b) => r > 180 && g < 90 && b < 90;
+const isBlue: PixelMatcher = (r, g, b) => r < 90 && g < 90 && b > 180;
+
+type Overlay = Pick[0], 'contents' | 'images'>;
+
+/**
+ * A red stroked rectangle content at `RECT`.
+ */
+const RED_RECTANGLE_OVERLAY: Overlay = {
+ contents: [
+ {
+ id: 'content',
+ contentMeta: {
+ type: EnvelopeContentType.SHAPE,
+ shape: EnvelopeContentShapeType.RECTANGLE,
+ page: 1,
+ zIndex: 0,
+ rotation: 0,
+ ...RECT,
+ fillOpacity: 0,
+ strokeWidth: STROKE_WIDTH,
+ strokeColor: '#ff0000',
+ strokeStyle: 'solid',
+ },
+ dataContentId: null,
+ },
+ ],
+};
+
+/**
+ * A solid blue image twice as wide as it is tall.
+ */
+const IMAGE_SIZE = { width: 200, height: 100 };
+
+const createBlueImageOverlay = async (): Promise => {
+ const png = await sharp({
+ create: {
+ ...IMAGE_SIZE,
+ channels: 4,
+ background: { r: 0, g: 0, b: 255, alpha: 1 },
+ },
+ })
+ .png()
+ .toBuffer();
+
+ return {
+ contents: [
+ {
+ id: 'content',
+ contentMeta: {
+ type: EnvelopeContentType.IMAGE,
+ page: 1,
+ zIndex: 0,
+ rotation: 0,
+ ...RECT,
+ },
+ dataContentId: 'data_blue',
+ },
+ ],
+ images: new Map([['data_blue', new SkiaImage(Buffer.from(png))]]),
+ };
+};
+
+/**
+ * Render at a high resolution so sub point placement errors become several
+ * pixels and cannot hide inside antialiasing.
+ */
+const RENDER_WIDTH = 2400;
+
+const FIXTURE = path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf');
+
+/**
+ * The fonts are resolved relative to the working directory of the app.
+ */
+beforeAll(() => {
+ process.chdir(path.join(__dirname, '../../../../apps/remix'));
+});
+
+type PageSetup = {
+ /**
+ * Create a blank page of this size instead of loading the fixture.
+ */
+ size?: { width: number; height: number };
+ rotation?: 0 | 90 | 180 | 270;
+ cropBox?: [number, number, number, number];
+ mediaBox?: [number, number, number, number];
+
+ /**
+ * Move the MediaBox and Rotate off the page and onto the parent pages node,
+ * so they are inherited rather than defined on the page itself.
+ */
+ inherit?: boolean;
+
+ /**
+ * The contents to burn in, defaulting to the red rectangle.
+ */
+ overlay?: Overlay;
+
+ /**
+ * Which rasterized pixels to measure, defaulting to the red stroke.
+ */
+ matchPixel?: PixelMatcher;
+};
+
+const toPdfArray = (values: number[]) => new PdfArray(values.map((value) => PdfNumber.of(value)));
+
+type CanvasAndContext = {
+ canvas: Canvas | null;
+ context: ReturnType