mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 08:14:31 +10:00
Compare commits
5
Commits
94793c7cce
...
b90aa86e24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90aa86e24 | ||
|
|
3c84d25d5a | ||
|
|
c96ee7c84f | ||
|
|
941f4a1e4c | ||
|
|
30a0979573 |
@@ -37,12 +37,6 @@ export type EnvelopeCanvasSelectionKind = 'field' | 'content';
|
||||
export type EnvelopeCanvasSelection = {
|
||||
kind: EnvelopeCanvasSelectionKind;
|
||||
groups: Konva.Group[];
|
||||
|
||||
/**
|
||||
* Whether the selection was made programmatically (e.g. on creation) rather
|
||||
* than by the user.
|
||||
*/
|
||||
isAuto: boolean;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
|
||||
+2
-8
@@ -65,13 +65,8 @@ type SyncEditorSelectionToCanvasOptions = {
|
||||
*/
|
||||
selectedGroups: Konva.Group[];
|
||||
|
||||
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[], options?: { isAuto?: boolean }) => void;
|
||||
select: (kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => void;
|
||||
clear: () => void;
|
||||
|
||||
/**
|
||||
* Whether a selection made by this sync counts as automatic.
|
||||
*/
|
||||
isAuto: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -92,7 +87,6 @@ export const syncEditorSelectionToCanvas = ({
|
||||
selectedGroups,
|
||||
select,
|
||||
clear,
|
||||
isAuto,
|
||||
}: SyncEditorSelectionToCanvasOptions) => {
|
||||
const isSingleSelection = selectedGroups.length === 1;
|
||||
|
||||
@@ -106,7 +100,7 @@ export const syncEditorSelectionToCanvas = ({
|
||||
const groupToSelect = layer.findOne(`#${editorFormId}`);
|
||||
|
||||
if (groupToSelect instanceof Konva.Group) {
|
||||
select(kind, [groupToSelect], { isAuto });
|
||||
select(kind, [groupToSelect]);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
+1
-4
@@ -345,15 +345,12 @@ export const useEnvelopeCanvasContentsLayer = ({
|
||||
selectedGroups,
|
||||
select: selection.select,
|
||||
clear: selection.clear,
|
||||
isAuto: false,
|
||||
});
|
||||
}
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
// Contents may have changed in a way which affects the transformer, e.g.
|
||||
// an image attached to the selected content locks its ratio.
|
||||
selection.refreshTransformerConfig();
|
||||
selection.refreshTransformer();
|
||||
|
||||
layer.batchDraw();
|
||||
}, [
|
||||
|
||||
+1
-4
@@ -376,8 +376,6 @@ export const useEnvelopeCanvasFieldsLayer = ({
|
||||
selection.select('field', liveSelectedGroups);
|
||||
}
|
||||
|
||||
// Newly created fields are auto selected, which suppresses the action bar
|
||||
// so it can't intercept the next placement click.
|
||||
syncEditorSelectionToCanvas({
|
||||
layer,
|
||||
kind: 'field',
|
||||
@@ -386,12 +384,11 @@ export const useEnvelopeCanvasFieldsLayer = ({
|
||||
selectedGroups,
|
||||
select: selection.select,
|
||||
clear: selection.clear,
|
||||
isAuto: true,
|
||||
});
|
||||
|
||||
applyPageItemsVisibility();
|
||||
|
||||
selection.refreshTransformerConfig();
|
||||
selection.refreshTransformer();
|
||||
|
||||
layer.batchDraw();
|
||||
}, [
|
||||
|
||||
+17
-1
@@ -98,6 +98,14 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the pointer was actually dragged, as opposed to a plain click
|
||||
* which leaves the rectangle without an area.
|
||||
*/
|
||||
const isMarqueeDrawn = () => {
|
||||
return selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0;
|
||||
};
|
||||
|
||||
const onWindowPointerUp = () => {
|
||||
stopTrackingWindow();
|
||||
|
||||
@@ -111,6 +119,14 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
|
||||
selectionRectangle.visible(false);
|
||||
});
|
||||
|
||||
// A plain click is not a selection, the click handler below deals with
|
||||
// it. Selecting here would also match anything whose bounding box merely
|
||||
// contains the point (e.g. a diagonal line), and since this runs after
|
||||
// other window listeners it would override e.g. a content being placed.
|
||||
if (!isMarqueeDrawn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectRef.current(selectionRectangle.getClientRect());
|
||||
};
|
||||
|
||||
@@ -158,7 +174,7 @@ export const useEnvelopeCanvasMarquee = ({ onSelect, onEmptyClick }: UseEnvelope
|
||||
|
||||
stage.on('click.marquee tap.marquee', (e) => {
|
||||
// A marquee drag just finished, the selection was handled on mouse up.
|
||||
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
|
||||
if (isMarqueeDrawn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+17
-13
@@ -21,10 +21,6 @@ const EMPTY_GROUPS: Konva.Group[] = [];
|
||||
/** How far past a resize handle you can still grab it, in screen pixels. */
|
||||
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
|
||||
|
||||
type SelectOptions = {
|
||||
isAuto?: boolean;
|
||||
};
|
||||
|
||||
type UseEnvelopeCanvasSelectionOptions = {
|
||||
/**
|
||||
* Resolve the transformer configuration for a selection.
|
||||
@@ -72,11 +68,16 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
const selectionRef = useLatestRef(selection);
|
||||
|
||||
/**
|
||||
* Re-resolve the transformer configuration for the current selection, for
|
||||
* when the selected items change in a way which affects it (e.g. an image
|
||||
* being attached to a selected content) without the selection changing.
|
||||
* Re-sync the transformer after the items change without the selection
|
||||
* changing: re-resolve its configuration (e.g. an image attached to the
|
||||
* selected content locks its ratio) and raise it back above the items.
|
||||
*
|
||||
* The handles straddle the selected item's edges, so any item stacked above
|
||||
* the transformer hides part of them and takes their clicks. Rendering
|
||||
* appends new items on top and the contents stacking raises every content,
|
||||
* so each reconcile calls this afterwards.
|
||||
*/
|
||||
const refreshTransformerConfig = useCallback(() => {
|
||||
const refreshTransformer = useCallback(() => {
|
||||
const transformer = transformerRef.current;
|
||||
|
||||
if (!transformer) {
|
||||
@@ -84,6 +85,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
}
|
||||
|
||||
applyTransformerConfig(transformer, selectionRef.current);
|
||||
transformer.moveToTop();
|
||||
transformer.forceUpdate();
|
||||
}, [applyTransformerConfig]);
|
||||
|
||||
@@ -101,12 +103,15 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
nextSelection.groups[0].moveToTop();
|
||||
}
|
||||
|
||||
// Above the item just raised, so its handles stay visible and clickable.
|
||||
transformer?.moveToTop();
|
||||
|
||||
setSelectionState(nextSelection);
|
||||
onChangeRef.current(nextSelection);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[], options?: SelectOptions) => {
|
||||
(kind: EnvelopeCanvasSelectionKind, nodes: Konva.Node[]) => {
|
||||
const groupName = ENVELOPE_CANVAS_GROUP_NAMES[kind];
|
||||
|
||||
const groups = nodes.filter(
|
||||
@@ -117,7 +122,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
Boolean(node.getParent()),
|
||||
);
|
||||
|
||||
applySelection(groups.length > 0 ? { kind, groups, isAuto: Boolean(options?.isAuto) } : null);
|
||||
applySelection(groups.length > 0 ? { kind, groups } : null);
|
||||
},
|
||||
[applySelection],
|
||||
);
|
||||
@@ -169,7 +174,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
.find(`.${ENVELOPE_CANVAS_GROUP_NAMES[currentSelection.kind]}`)
|
||||
.filter((group) => selectedIds.has(group.id()));
|
||||
|
||||
select(currentSelection.kind, groups, { isAuto: currentSelection.isAuto });
|
||||
select(currentSelection.kind, groups);
|
||||
},
|
||||
[select],
|
||||
);
|
||||
@@ -190,7 +195,6 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
keepRatio: false,
|
||||
borderStroke: selectionColor,
|
||||
anchorStroke: selectionColor,
|
||||
shouldOverdrawWholeArea: true,
|
||||
ignoreStroke: true,
|
||||
flipEnabled: false,
|
||||
anchorStyleFunc: (anchor) => {
|
||||
@@ -247,7 +251,7 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U
|
||||
toggle,
|
||||
clear,
|
||||
isSelected,
|
||||
refreshTransformerConfig,
|
||||
refreshTransformer,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+11
-1
@@ -83,7 +83,7 @@ type EnvelopeEditorContentDragDropProps = {
|
||||
};
|
||||
|
||||
export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: EnvelopeEditorContentDragDropProps) => {
|
||||
const { envelope, editorContents } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorContents, setIsPlacingItem } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
@@ -96,6 +96,16 @@ export const EnvelopeEditorContentDragDrop = ({ selectedEnvelopeItemId }: Envelo
|
||||
|
||||
const [selectedContent, setSelectedContent] = useState<ContentDragDropItem | null>(null);
|
||||
|
||||
// Let the canvas know a content is being placed, so it can get its selection
|
||||
// out of the way of the placement click.
|
||||
useEffect(() => {
|
||||
setIsPlacingItem(selectedContent !== null);
|
||||
|
||||
return () => {
|
||||
setIsPlacingItem(false);
|
||||
};
|
||||
}, [selectedContent, setIsPlacingItem]);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const [isContentWithinBounds, setIsContentWithinBounds] = useState(false);
|
||||
|
||||
+11
-1
@@ -94,12 +94,22 @@ export const EnvelopeEditorFieldDragDrop = ({
|
||||
selectedRecipientId,
|
||||
selectedEnvelopeItemId,
|
||||
}: EnvelopeEditorFieldDragDropProps) => {
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, isTemplate, getRecipientColorKey, setIsPlacingItem } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
||||
// Let the canvas know a field is being placed, so it can get its selection
|
||||
// out of the way of the placement click.
|
||||
useEffect(() => {
|
||||
setIsPlacingItem(selectedField !== null);
|
||||
|
||||
return () => {
|
||||
setIsPlacingItem(false);
|
||||
};
|
||||
}, [selectedField, setIsPlacingItem]);
|
||||
|
||||
const { isWithinPageBounds, getPage } = useDocumentElement();
|
||||
|
||||
const isFieldsDisabled = useMemo(() => {
|
||||
|
||||
+25
-2
@@ -14,6 +14,7 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import type Konva from 'konva';
|
||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||
import { CopyPlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import {
|
||||
EnvelopeCanvasActionBar,
|
||||
@@ -54,7 +55,7 @@ const getNodeSelectionKind = (node: Konva.Node): EnvelopeCanvasSelectionKind | n
|
||||
|
||||
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { t } = useLingui();
|
||||
const { envelope, editorFields, editorContents, selectedEditorTab } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, editorContents, selectedEditorTab, isPlacingItem } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
@@ -136,6 +137,28 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
onEmptyClick: selection.clear,
|
||||
});
|
||||
|
||||
/**
|
||||
* Picking a field or content from the palette drops the selection and any
|
||||
* pending creation, so nothing floating over the page sits under the
|
||||
* placement click.
|
||||
*
|
||||
* The action bar and pending menus are DOM elements above the canvas, so a
|
||||
* placement click landing on one would press its button as well as placing
|
||||
* the item, and by then the button acts on the item which was just placed
|
||||
* (e.g. removing it).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isPlacingItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
fields.clearPending();
|
||||
contents.clearPending();
|
||||
selection.clear();
|
||||
|
||||
pageLayer.current?.batchDraw();
|
||||
}, [isPlacingItem]);
|
||||
|
||||
/**
|
||||
* Initialize the Konva page canvas and all fields, contents and interactions.
|
||||
*/
|
||||
@@ -234,7 +257,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
return (
|
||||
<>
|
||||
{currentSelection?.kind === 'field' && (
|
||||
<EnvelopeCanvasActionBar nodes={currentSelection.groups} hidden={isTransforming || currentSelection.isAuto}>
|
||||
<EnvelopeCanvasActionBar nodes={currentSelection.groups} hidden={isTransforming}>
|
||||
<EnvelopeCanvasFieldActionButtons
|
||||
selectedFieldFormIds={currentSelection.groups.map((group) => group.id())}
|
||||
onDuplicate={fields.duplicateSelected}
|
||||
|
||||
@@ -246,6 +246,21 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Deselect a field or content which is not on the current envelope item
|
||||
* when the item changes, otherwise its settings stay open in the sidebar for
|
||||
* something which is no longer on screen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (editorFields.selectedField && editorFields.selectedField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
editorFields.setSelectedField(null);
|
||||
}
|
||||
|
||||
if (editorContents.selectedContent && editorContents.selectedContent.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
editorContents.setSelectedContent(null);
|
||||
}
|
||||
}, [currentEnvelopeItem?.id]);
|
||||
|
||||
const onDetectClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createDataContentImage } from '@documenso/lib/server-only/data-content/create-data-content-image';
|
||||
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';
|
||||
@@ -25,6 +25,7 @@ test.describe.configure({
|
||||
});
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
const logoPngBuffer = fs.readFileSync(path.join(__dirname, '../../../../../packages/assets/logo.png'));
|
||||
|
||||
/**
|
||||
* Set the content allowances on the organisation that owns the seeded team.
|
||||
@@ -76,8 +77,8 @@ const createEnvelope = async (request: APIRequestContext, authToken: string) =>
|
||||
* `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.
|
||||
* Image contents are given a real image, since sending rejects an image
|
||||
* content without one and decodes it to draw it into the PDF.
|
||||
*/
|
||||
const buildAndDistributeEnvelopeWithContents = async ({
|
||||
request,
|
||||
@@ -144,20 +145,8 @@ const buildAndDistributeEnvelopeWithContents = async ({
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
const dataContent = await createDataContentImage({
|
||||
file: { name: 'logo.png', arrayBuffer: async () => new Uint8Array(logoPngBuffer).buffer as ArrayBuffer },
|
||||
});
|
||||
|
||||
return dataContent.id;
|
||||
|
||||
@@ -49,10 +49,7 @@ test('a selected field survives zooming', async ({ page }) => {
|
||||
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 });
|
||||
|
||||
// A freshly placed field is selected, with its action bar showing.
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
await expect(root.getByTestId('envelope-canvas-action-bar')).toBeVisible();
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ type EnvelopeEditorProviderValue = {
|
||||
selectedEditorTab: EnvelopeEditorTab;
|
||||
setSelectedEditorTab: (tab: EnvelopeEditorTab) => void;
|
||||
|
||||
/**
|
||||
* Whether a field or content has been picked from the palette and is
|
||||
* waiting to be placed on the page.
|
||||
*
|
||||
* The canvas drops its selection while this is set, so nothing floating
|
||||
* over the page (the transformer, the action bar) sits under the
|
||||
* placement click.
|
||||
*/
|
||||
isPlacingItem: boolean;
|
||||
setIsPlacingItem: (isPlacingItem: boolean) => void;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => Promise<TEditorEnvelope>;
|
||||
autosaveError: boolean;
|
||||
@@ -159,6 +170,8 @@ export const EnvelopeEditorProvider = ({
|
||||
|
||||
const [selectedEditorTab, setSelectedEditorTab] = useState<EnvelopeEditorTab>('fields');
|
||||
|
||||
const [isPlacingItem, setIsPlacingItem] = useState(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
|
||||
/**
|
||||
@@ -655,6 +668,8 @@ export const EnvelopeEditorProvider = ({
|
||||
editorRecipients,
|
||||
selectedEditorTab,
|
||||
setSelectedEditorTab,
|
||||
isPlacingItem,
|
||||
setIsPlacingItem,
|
||||
autosaveError,
|
||||
flushAutosave,
|
||||
isAutosaving,
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { assertEnvelopeContentSaveWithinLimits } from '../../utils/envelope-content';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type SetEnvelopeContentsOptions = {
|
||||
@@ -38,8 +38,15 @@ export type SetEnvelopeContentsOptions = {
|
||||
contentMeta: TEnvelopeContentMetaInput;
|
||||
|
||||
/**
|
||||
* The ID of an uploaded data content (e.g. an image) to attach to the
|
||||
* content, or null for none.
|
||||
* The ID of a data content (e.g. an image) to attach to the content, or
|
||||
* null for none.
|
||||
*
|
||||
* This ID must already be attached to the content, or be null to remove an image.
|
||||
*
|
||||
* This ID parameter is only here to allow users to duplicate data content without
|
||||
* reuploading it.
|
||||
*
|
||||
* To attach a new image you must go through trpc.envelope.content.uploadImage
|
||||
*/
|
||||
dataContentId: string | null;
|
||||
}[];
|
||||
@@ -51,9 +58,6 @@ export type SetEnvelopeContentsOptions = {
|
||||
*
|
||||
* 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,
|
||||
@@ -77,7 +81,16 @@ export const setEnvelopeContents = async ({
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
contents: true,
|
||||
contents: {
|
||||
include: {
|
||||
dataContent: {
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
@@ -109,7 +122,14 @@ export const setEnvelopeContents = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const existingContents = envelope.contents;
|
||||
const existingContents = envelope.contents.map(({ dataContent: _dataContent, ...content }) => content);
|
||||
|
||||
// Create a map of all the existing data contents on this envelope.
|
||||
const attachedDataContents = new Map(
|
||||
envelope.contents.flatMap((content) =>
|
||||
content.dataContent ? [[content.dataContent.id, content.dataContent]] : [],
|
||||
),
|
||||
);
|
||||
|
||||
// The organisation's plan caps how much content an envelope may hold.
|
||||
assertEnvelopeContentSaveWithinLimits({
|
||||
@@ -122,25 +142,6 @@ export const setEnvelopeContents = async ({
|
||||
(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);
|
||||
|
||||
@@ -169,7 +170,7 @@ export const setEnvelopeContents = async ({
|
||||
return {
|
||||
...content,
|
||||
contentMeta,
|
||||
dataContentId: resolveDataContentId(content.dataContentId, contentMeta.type, requestedDataContents),
|
||||
dataContentId: resolveDataContentId(content.dataContentId, contentMeta.type, attachedDataContents),
|
||||
_persisted: persisted,
|
||||
};
|
||||
});
|
||||
@@ -331,18 +332,22 @@ export const setEnvelopeContents = async ({
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* data content is not already attached to the envelope or is not the type the
|
||||
* content type can hold.
|
||||
*
|
||||
* Deliberately reports an unattached data content as not found rather than
|
||||
* forbidden, so the response does not reveal whether the ID exists.
|
||||
*/
|
||||
const resolveDataContentId = (
|
||||
dataContentId: string | null | undefined,
|
||||
contentType: EnvelopeContentType,
|
||||
dataContents: Pick<DataContent, 'id' | 'metadata'>[],
|
||||
attachedDataContents: Map<string, Pick<DataContent, 'id' | 'metadata'>>,
|
||||
) => {
|
||||
if (!dataContentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataContent = dataContents.find((item) => item.id === dataContentId);
|
||||
const dataContent = attachedDataContents.get(dataContentId);
|
||||
|
||||
if (!dataContent) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
|
||||
@@ -27,6 +27,10 @@ type ContentGroupPosition = {
|
||||
* Draws a box content type's own nodes inside the group, after the shared
|
||||
* frame (group, position, hover outline) has been set up.
|
||||
*
|
||||
* Called on every render, and again on every tick of a resize with the box
|
||||
* at its current size, so it must lay everything out from `geometry` rather
|
||||
* than assume the size is settled.
|
||||
*
|
||||
* `meta` is any box content; the renderer narrows it to its own type via
|
||||
* `assertContentMetaType`.
|
||||
*/
|
||||
@@ -67,13 +71,61 @@ export const renderBoxContent = (
|
||||
rotation: getContentRotation(meta),
|
||||
});
|
||||
|
||||
renderChildren({ contentGroup, meta, geometry });
|
||||
/**
|
||||
* Lay the content out at a box: the type's own nodes, then the shared hover
|
||||
* outline around them.
|
||||
*/
|
||||
const layoutBox = (box: Rect) => {
|
||||
renderChildren({ contentGroup, meta, geometry: box });
|
||||
|
||||
createContentHoverInteraction(contentGroup, content, options, geometry.width, geometry.height);
|
||||
createContentHoverInteraction(contentGroup, content, options, box.width, box.height);
|
||||
};
|
||||
|
||||
layoutBox(geometry);
|
||||
|
||||
bindBoxContentResize(contentGroup, layoutBox);
|
||||
|
||||
return contentGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep a box content's strokes and text unscaled while it is being resized.
|
||||
*
|
||||
* The transformer resizes by scaling the group, which scales everything in
|
||||
* it: strokes thicken unevenly and dash patterns stretch until the gesture
|
||||
* ends and the box is re-rendered from its written back size. So on every
|
||||
* transform tick the group scale is folded into the box size, the content is
|
||||
* laid out again at that size and the scale is reset, as the multi item
|
||||
* fields do.
|
||||
*
|
||||
* The write back reads the bounds size multiplied by the group scale, which
|
||||
* folding the scale in early leaves unchanged.
|
||||
*/
|
||||
const bindBoxContentResize = (contentGroup: Konva.Group, layoutBox: (box: Rect) => void) => {
|
||||
contentGroup.off('transform.contentBox');
|
||||
|
||||
contentGroup.on('transform.contentBox', () => {
|
||||
const boundsNode = contentGroup.findOne(`.${CONTENT_BOUNDS_NODE_NAME}`);
|
||||
|
||||
if (!boundsNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const box: Rect = {
|
||||
x: contentGroup.x(),
|
||||
y: contentGroup.y(),
|
||||
width: boundsNode.width() * contentGroup.scaleX(),
|
||||
height: boundsNode.height() * contentGroup.scaleY(),
|
||||
};
|
||||
|
||||
contentGroup.scale({ x: 1, y: 1 });
|
||||
|
||||
layoutBox(box);
|
||||
|
||||
contentGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert the Konva group of a content at the given position.
|
||||
*
|
||||
|
||||
@@ -79,7 +79,7 @@ export const renderTextContentElement = (content: ContentToRender, options: Rend
|
||||
fill: color,
|
||||
} satisfies Partial<Konva.TextConfig>);
|
||||
|
||||
const hitRect = upsertContentHitRect(contentGroup, content, geometry.width, geometry.height);
|
||||
upsertContentHitRect(contentGroup, content, geometry.width, geometry.height);
|
||||
|
||||
// A persistent dashed outline marks the text box bounds while editing,
|
||||
// matching the image placeholder treatment, since the text itself
|
||||
@@ -109,74 +109,5 @@ export const renderTextContentElement = (content: ContentToRender, options: Rend
|
||||
|
||||
// Keep the text above the outline so the dashes never cross the glyphs.
|
||||
contentText.moveToTop();
|
||||
|
||||
contentGroup.off('transform.contentText');
|
||||
contentGroup.off('transformend.contentText');
|
||||
|
||||
// The transformer resizes by scaling the group. Counter-scale the text so it
|
||||
// keeps its real size while the box visibly resizes, laying it out against
|
||||
// the scaled bounds in unscaled units. Mirrors the generic text field.
|
||||
contentGroup.on('transform.contentText', () => {
|
||||
const groupScaleX = contentGroup.scaleX();
|
||||
const groupScaleY = contentGroup.scaleY();
|
||||
|
||||
contentText.scaleX(1 / groupScaleX);
|
||||
contentText.scaleY(1 / groupScaleY);
|
||||
|
||||
const scaledWidth = hitRect.width() * groupScaleX;
|
||||
const scaledHeight = hitRect.height() * groupScaleY;
|
||||
|
||||
// During the active transform use the crop bounds (content box only).
|
||||
contentText.x(0);
|
||||
contentText.y(0);
|
||||
contentText.width(scaledWidth);
|
||||
contentText.height(scaledHeight);
|
||||
contentText.wrap('word');
|
||||
|
||||
contentGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
|
||||
// Once the gesture settles, recalculate the overflow layout against the new
|
||||
// box dimensions. The page renderer subsequently re-renders from the
|
||||
// persisted geometry, which resets the group scale.
|
||||
contentGroup.on('transformend.contentText', () => {
|
||||
const groupScaleX = contentGroup.scaleX();
|
||||
const groupScaleY = contentGroup.scaleY();
|
||||
|
||||
contentText.scaleX(1);
|
||||
contentText.scaleY(1);
|
||||
|
||||
const newWidth = hitRect.width() * groupScaleX;
|
||||
const newHeight = hitRect.height() * groupScaleY;
|
||||
|
||||
const newOverflowLayout = calculateOverflowLayout({
|
||||
overflowMode: CONTENT_TEXT_OVERFLOW_MODE,
|
||||
isLabel: false,
|
||||
textToRender,
|
||||
fontSize,
|
||||
fontFamily: konvaTextFontFamily,
|
||||
lineHeight,
|
||||
letterSpacing,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
baseX: 0,
|
||||
baseY: 0,
|
||||
baseWidth: newWidth,
|
||||
baseHeight: newHeight,
|
||||
groupX: contentGroup.x(),
|
||||
groupY: contentGroup.y(),
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
});
|
||||
|
||||
contentText.x(newOverflowLayout.x);
|
||||
contentText.y(newOverflowLayout.y);
|
||||
contentText.width(newOverflowLayout.width);
|
||||
contentText.height(newOverflowLayout.height);
|
||||
contentText.wrap(newOverflowLayout.wrap);
|
||||
contentText.verticalAlign(newOverflowLayout.verticalAlign);
|
||||
|
||||
contentGroup.getLayer()?.batchDraw();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,9 @@ export const ZSetEnvelopeContentsRequestSchema = z.object({
|
||||
dataContentId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The id of an uploaded data content (e.g. an image) to attach, or null for none.'),
|
||||
.describe(
|
||||
'The id of a data content (e.g. an image) already attached to a content on this envelope, or null for none. New images are attached via the upload image route.',
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user