Compare commits

...
5 Commits
Author SHA1 Message Date
David Nguyen b90aa86e24 fix: test 2026-09-27 22:04:46 +10:00
David Nguyen 3c84d25d5a fix: various improvements 2026-09-27 20:08:47 +10:00
David Nguyen c96ee7c84f fix: correctly deselect settings when changing envelope items 2026-09-26 16:47:27 +10:00
David Nguyen 941f4a1e4c fix: auto show and hide editor action bar 2026-09-26 16:33:03 +10:00
David Nguyen 30a0979573 fix: stuff 2026-09-26 12:23:47 +10:00
17 changed files with 216 additions and 165 deletions
@@ -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;
/**
@@ -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;
@@ -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();
}, [
@@ -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();
}, [
@@ -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;
}
@@ -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,
};
};
@@ -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);
@@ -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(() => {
@@ -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.',
),
}),
),
});