From f5bd580dbf5a4e4872141e6aee3a2960ad96bc74 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 4 Oct 2026 12:54:52 +1100 Subject: [PATCH] fix: improve transformer edge cases --- .../canvas/use-envelope-canvas-selection.ts | 16 +- .../envelope-content-images.spec.ts | 97 +++++++- .../envelope-contents.spec.ts | 109 +++++++++ packages/app-tests/e2e/fixtures/contents.ts | 33 +++ .../content-renderer/content-geometry.ts | 4 +- .../content-image-box.test.ts | 127 +++++----- .../content-renderer/content-image-box.ts | 90 ++----- .../content-renderer/render-image-content.ts | 8 +- .../lib/universal/konva/transformer.test.ts | 147 +++++++++++- packages/lib/universal/konva/transformer.ts | 221 ++++++++++++++++-- 10 files changed, 659 insertions(+), 193 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-selection.ts b/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-selection.ts index 372821f83..39c396b2e 100644 --- a/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-selection.ts +++ b/apps/remix/app/components/general/envelope-editor/canvas/use-envelope-canvas-selection.ts @@ -2,6 +2,7 @@ import { useLatestRef } from '@documenso/lib/client-only/hooks/use-latest-ref'; import type { TransformerSelectionConfig } from '@documenso/lib/universal/konva/transformer'; import { boundTransformerBoxToPage, + boundTransformerBoxToPageKeepingRatio, DEFAULT_TRANSFORMER_SELECTION_CONFIG, } from '@documenso/lib/universal/konva/transformer'; import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors'; @@ -209,9 +210,18 @@ export const useEnvelopeCanvasSelection = ({ getTransformerConfig, onChange }: U // so pin the resize to the stage's own size. const stage = layer.getStage(); - const bounded = stage - ? boundTransformerBoxToPage(newBox, { width: stage.width(), height: stage.height() }) - : newBox; + if (!stage) { + return newBox; + } + + const page = { width: stage.width(), height: stage.height() }; + + // A ratio locked resize has to shrink both axes together, so it is + // bounded about its fixed corner rather than edge by edge. Read via + // the ref since the transformer is still being constructed here. + const bounded = transformerRef.current?.keepRatio() + ? boundTransformerBoxToPageKeepingRatio(oldBox, newBox, page) + : boundTransformerBoxToPage(oldBox, newBox, page); // Enforce minimum size if (bounded.width < 30 || bounded.height < 20) { diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts index e826abf8a..5d85f59e9 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-content-images.spec.ts @@ -8,10 +8,12 @@ import { getContentGroupsForPage, getPageCanvas, getPageSize, + interactWithCanvasPastActionBar, placeContentOnPdf, selectEditorTab, type TestImageFile, uploadImage, + waitForContentSelection, waitForContentsAutosave, waitForUploadToLand, } from '../fixtures/contents'; @@ -120,8 +122,8 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { 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. + // Upload a wide 2:1 image. The box keeps its width and takes the image's + // ratio for its height. const wide = await createImageFile('wide-logo.png', 400, 200); await uploadImage(root, wide); @@ -134,6 +136,7 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { expect(withWide.visibleChildren).toContain('content-image'); expect(withWide.visibleChildren).not.toContain('content-placeholder-rect'); + expect(withWide.rect.width).toBeCloseTo(placeholder.rect.width, 0); expect(withWide.rect.width / withWide.rect.height).toBeCloseTo(2, 1); await waitForContentsAutosave(surface); @@ -150,8 +153,8 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { 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. + // Replacing with a tall image keeps the width again and grows the height + // to the new ratio, since there is room below the box on the page. const tall = await createImageFile('tall-logo.jpg', 100, 300, 'jpeg'); await uploadImage(root, tall); @@ -161,9 +164,9 @@ const runUploadReplaceRemoveFlow = async (surface: TEnvelopeEditorSurface) => { const [withTall] = await getContentGroupsForPage(root); + expect(withTall.rect.width).toBeCloseTo(withWide.rect.width, 0); 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); + expect(withTall.rect.height).toBeGreaterThan(withWide.rect.height); await waitForContentsAutosave(surface); @@ -245,11 +248,11 @@ const assertActionBarUploadPersisted = async ( 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); + // The image's pixel size is irrelevant: the box keeps its 15% width and + // the height follows the square ratio in page points, i.e. 15% of the + // page width expressed as a percentage of the page height. + expect(Number(meta.width)).toBeCloseTo(15, 0); + expect(Number(meta.height)).toBeCloseTo((15 * pageSize.width) / pageSize.height, 0); expect(Number(meta.positionX) + Number(meta.width)).toBeLessThanOrEqual(100); expect(Number(meta.positionY) + Number(meta.height)).toBeLessThanOrEqual(100); }; @@ -343,9 +346,81 @@ const runSendGuardFlow = async (surface: TEnvelopeEditorSurface) => { await expect(root.getByRole('button', { name: 'Send', exact: true })).toBeVisible(); }; +// --- Ratio locked resize is pinned to the page --- + +const runRatioResizePinnedToPageFlow = async (surface: TEnvelopeEditorSurface) => { + const externalId = `e2e-content-images-resize-${nanoid()}`; + const root = surface.root; + + await openContentsTab(surface, externalId); + + await placeContentOnPdf(root, 'Image', { x: 200, y: 200 }); + + // A 2:1 image, so the box is ratio locked from the corners once attached. + await uploadImage(root, await createImageFile('wide-logo.png', 400, 200)); + await waitForUploadToLand(root); + + 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); + + await waitForContentSelection(root, [group.id]); + + // Drag the bottom right anchor well past the page's bottom right corner. + 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 assertRatioResizePinnedToPage = async (surface: TEnvelopeEditorSurface, externalId: string) => { + const envelope = await findEnvelopeWithContents(surface, externalId); + + expect(envelope.contents).toHaveLength(1); + + const meta = getMeta(envelope.contents[0].contentMeta); + const { width: pageWidth, height: pageHeight } = await getPageSize(surface.root); + + const right = Number(meta.positionX) + Number(meta.width); + const bottom = Number(meta.positionY) + Number(meta.height); + + // The box stayed on the page, growing until one axis hit the edge, and + // kept the image's 2:1 ratio in page points while doing so. + expect(right).toBeLessThanOrEqual(100.001); + expect(bottom).toBeLessThanOrEqual(100.001); + expect(Math.max(right, bottom)).toBeCloseTo(100, 1); + + const widthPoints = (Number(meta.width) / 100) * pageWidth; + const heightPoints = (Number(meta.height) / 100) * pageHeight; + + expect(widthPoints / heightPoints).toBeCloseTo(2, 1); +}; + // --- Tests --- test.describe('document editor', () => { + test('a ratio locked resize is pinned to the page bounds', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { externalId } = await runRatioResizePinnedToPageFlow(surface); + + await assertRatioResizePinnedToPage(surface, externalId); + }); + test('upload, replace and remove a content image', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); 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 index 009a2ad06..955ad4399 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-contents.spec.ts @@ -19,6 +19,7 @@ import { getFieldCountForPage, getPageCanvas, getPageSize, + getTransformerAnchorPosition, interactWithCanvasPastActionBar, placeContentOnPdf, selectContentOnCanvas, @@ -526,6 +527,107 @@ const assertResizePinnedToPage = async (surface: TEnvelopeEditorSurface, externa expect(Number(meta.positionY) + Number(meta.height)).toBeCloseTo(100, 3); }; +// --- Rotated resize is pinned to the page --- + +const runRotatedResizePinnedToPageFlow = async (surface: TEnvelopeEditorSurface) => { + const externalId = `e2e-contents-rotated-resize-${nanoid()}`; + const root = surface.root; + + await openContentsTab(surface, externalId); + + // Near the bottom right so the rotated box has little room to grow into. + await placeContentOnPdf(root, 'Rectangle', { x: 400, y: 500 }); + + const [group] = await getContentGroupsForPage(root); + + await waitForContentSelection(root, [group.id]); + + // Rotate by dragging the rotater anchor a quarter turn clockwise around + // the box, so the corners no longer line up with the page edges. + const rotater = await getTransformerAnchorPosition(root, 'rotater'); + const bottomRight = await getTransformerAnchorPosition(root, 'bottom-right'); + + await interactWithCanvasPastActionBar(root, async () => { + await root.mouse.move(rotater.x, rotater.y); + await root.mouse.down(); + await root.mouse.move(bottomRight.x + 40, rotater.y + 60, { steps: 10 }); + await root.mouse.up(); + }); + + // Then drag a corner anchor far past the page's bottom right corner. + const canvas = await getPageCanvas(root).boundingBox(); + + if (!canvas) { + throw new Error('Canvas bounding box not available'); + } + + const corner = await getTransformerAnchorPosition(root, 'bottom-right'); + + await interactWithCanvasPastActionBar(root, async () => { + await root.mouse.move(corner.x, corner.y); + await root.mouse.down(); + await root.mouse.move(canvas.x + canvas.width + 300, canvas.y + canvas.height + 300, { steps: 10 }); + await root.mouse.up(); + }); + + await waitForContentsAutosave(surface); + + return { externalId }; +}; + +const assertRotatedResizePinnedToPage = async (surface: TEnvelopeEditorSurface, externalId: string) => { + const envelope = await findEnvelopeWithContents(surface, externalId); + + expect(envelope.contents).toHaveLength(1); + + const meta = getMeta(envelope.contents[0].contentMeta); + const page = await getPageSize(surface.root); + + const rotation = Number(meta.rotation); + + // The box did rotate, otherwise the axis aligned bound would have covered this. + expect(rotation).toBeGreaterThan(5); + expect(rotation).toBeLessThan(355); + + // Every corner of the rotated footprint is on the page. The box rotates + // about its top left corner, matching the content renderer. + const originX = (Number(meta.positionX) / 100) * page.width; + const originY = (Number(meta.positionY) / 100) * page.height; + const width = (Number(meta.width) / 100) * page.width; + const height = (Number(meta.height) / 100) * page.height; + + const radians = (rotation * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + const corners = [ + [0, 0], + [width, 0], + [0, height], + [width, height], + ].map(([x, y]) => ({ x: originX + x * cos - y * sin, y: originY + x * sin + y * cos })); + + // A point of slack for the pixel to percent round trip. + for (const corner of corners) { + expect(corner.x).toBeGreaterThanOrEqual(-1); + expect(corner.x).toBeLessThanOrEqual(page.width + 1); + expect(corner.y).toBeGreaterThanOrEqual(-1); + expect(corner.y).toBeLessThanOrEqual(page.height + 1); + } + + // And the drag was not simply refused: the box did grow until it met an + // edge, so at least one corner sits on the page boundary. + const isOnEdge = corners.some( + (corner) => + Math.abs(corner.x) < 2 || + Math.abs(corner.x - page.width) < 2 || + Math.abs(corner.y) < 2 || + Math.abs(corner.y - page.height) < 2, + ); + + expect(isOnEdge).toBe(true); +}; + // --- Selecting brings to front --- const runSelectBringsToFrontFlow = async (surface: TEnvelopeEditorSurface) => { @@ -622,6 +724,13 @@ test.describe('document editor', () => { await assertResizePinnedToPage(surface, externalId); }); + test('resizing a rotated content is pinned to the page bounds', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const { externalId } = await runRotatedResizePinnedToPageFlow(surface); + + await assertRotatedResizePinnedToPage(surface, externalId); + }); + test('selecting a content brings it to the front', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const { externalId } = await runSelectBringsToFrontFlow(surface); diff --git a/packages/app-tests/e2e/fixtures/contents.ts b/packages/app-tests/e2e/fixtures/contents.ts index 574235152..a526a0a16 100644 --- a/packages/app-tests/e2e/fixtures/contents.ts +++ b/packages/app-tests/e2e/fixtures/contents.ts @@ -226,6 +226,39 @@ export const waitForContentSelection = async (root: Page, contentIds: string[], .toEqual([...contentIds].sort()); }; +/** + * The screen position of one of the selection's transformer anchors, e.g. + * `bottom-right` or `rotater`, so it can be grabbed with the mouse. + */ +export const getTransformerAnchorPosition = async (root: Page, anchorName: string, pageNumber = 1) => { + const canvasBox = await getPageCanvas(root).boundingBox(); + + if (!canvasBox) { + throw new Error('Canvas bounding box not available'); + } + + const anchor = await root.evaluate( + ({ anchorName, 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}`); + const node = stage?.findOne(`.${anchorName}`); + + if (!node) { + throw new Error(`The selection has no ${anchorName} anchor`); + } + + const rect = node.getClientRect(); + + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, + { anchorName, pageNumber }, + ); + + return { x: canvasBox.x + anchor.x, y: canvasBox.y + anchor.y }; +}; + /** * Open the settings dialog and set the external ID used to find the envelope * in the database. diff --git a/packages/lib/universal/content-renderer/content-geometry.ts b/packages/lib/universal/content-renderer/content-geometry.ts index db61c8de4..a5b9f65a4 100644 --- a/packages/lib/universal/content-renderer/content-geometry.ts +++ b/packages/lib/universal/content-renderer/content-geometry.ts @@ -190,8 +190,8 @@ type ContentTransformerConfigOptions = { * type. Multi-selections rotate around a shared pivot, which cannot be * represented in the per content metadata. * - A single image content with an image attached resizes from the - * corners with its ratio locked, since its box always has the image's - * shape. Placeholders resize freely until an image is attached. + * corners only, with its ratio locked, so the image is never stretched. + * Placeholders resize freely from every anchor until an image is attached. */ export const getContentTransformerConfig = ( types: (EnvelopeContentType | undefined)[], diff --git a/packages/lib/universal/content-renderer/content-image-box.test.ts b/packages/lib/universal/content-renderer/content-image-box.test.ts index 3fe0dea2d..30f0de60e 100644 --- a/packages/lib/universal/content-renderer/content-image-box.test.ts +++ b/packages/lib/universal/content-renderer/content-image-box.test.ts @@ -7,91 +7,74 @@ import { CONTENT_IMAGE_DEFAULT_SIZE, resolveAttachedImageBox } from './content-i */ const PAGE = { width: 612, height: 792 }; -const toPercentWidth = (points: number) => (points / PAGE.width) * 100; const toPercentHeight = (points: number) => (points / PAGE.height) * 100; - -const defaultBox = (positionX = 10, positionY = 10) => ({ - positionX, - positionY, - ...CONTENT_IMAGE_DEFAULT_SIZE, -}); +const toPercentWidth = (points: number) => (points / PAGE.width) * 100; describe('resolveAttachedImageBox', () => { - describe('with the default box', () => { - it('sizes the box to the image at one point per pixel', () => { - const box = resolveAttachedImageBox({ image: { width: 200, height: 100 }, page: PAGE, box: defaultBox() }); + it('keeps the box width and sets the height from the image ratio', () => { + const box = { positionX: 10, positionY: 20, width: 50, height: 25 }; - expect(box.positionX).toBe(10); - expect(box.positionY).toBe(10); - expect(box.width).toBeCloseTo(toPercentWidth(200)); - expect(box.height).toBeCloseTo(toPercentHeight(100)); - }); + const result = resolveAttachedImageBox({ image: { width: 200, height: 100 }, page: PAGE, box }); - it('caps wide images at 80% of the page width, preserving the ratio', () => { - const box = resolveAttachedImageBox({ image: { width: 2048, height: 512 }, page: PAGE, box: defaultBox() }); - - expect(box.width).toBeCloseTo(80); - expect(box.height).toBeCloseTo(toPercentHeight(0.8 * PAGE.width * (512 / 2048))); - }); - - it('caps tall images at 80% of the page height, preserving the ratio', () => { - const box = resolveAttachedImageBox({ image: { width: 500, height: 2000 }, page: PAGE, box: defaultBox() }); - - expect(box.height).toBeCloseTo(80); - expect(box.width).toBeCloseTo(toPercentWidth(0.8 * PAGE.height * (500 / 2000))); - }); - - it('shifts the box into the page instead of clipping it', () => { - const box = resolveAttachedImageBox({ - image: { width: 200, height: 100 }, - page: PAGE, - box: defaultBox(90, 95), - }); - - expect(box.width).toBeCloseTo(toPercentWidth(200)); - expect(box.height).toBeCloseTo(toPercentHeight(100)); - expect(box.positionX + box.width).toBeCloseTo(100); - expect(box.positionY + box.height).toBeCloseTo(100); - }); + // 50% of 612 is 306 points wide, so a 2:1 image is 153 points tall. + expect(result.positionX).toBe(10); + expect(result.positionY).toBe(20); + expect(result.width).toBe(50); + expect(result.height).toBeCloseTo(toPercentHeight(153)); }); - describe('with a box the author has sized', () => { - // 50% x 25% of US Letter is 306 x 198 points. - const customBox = { positionX: 10, positionY: 20, width: 50, height: 25 }; + it('grows a tall image past the box height rather than shrinking its width', () => { + const box = { positionX: 10, positionY: 20, width: 50, height: 25 }; - it('tightens the box around a wide image, centered where it would have rendered', () => { - const box = resolveAttachedImageBox({ image: { width: 200, height: 100 }, page: PAGE, box: customBox }); + const result = resolveAttachedImageBox({ image: { width: 100, height: 200 }, page: PAGE, box }); - // The image fills the width (306 x 153) with 22.5 points spare above and below. - expect(box.width).toBeCloseTo(50); - expect(box.height).toBeCloseTo(toPercentHeight(153)); - expect(box.positionX).toBeCloseTo(10); - expect(box.positionY).toBeCloseTo(20 + toPercentHeight(22.5)); - }); - - it('tightens the box around a tall image', () => { - const box = resolveAttachedImageBox({ image: { width: 100, height: 200 }, page: PAGE, box: customBox }); - - // The image fills the height (99 x 198) with 103.5 points spare either side. - expect(box.height).toBeCloseTo(25); - expect(box.width).toBeCloseTo(toPercentWidth(99)); - expect(box.positionY).toBeCloseTo(20); - expect(box.positionX).toBeCloseTo(10 + toPercentWidth(103.5)); - }); - - it('never grows beyond the box the author drew', () => { - const box = resolveAttachedImageBox({ image: { width: 4000, height: 4000 }, page: PAGE, box: customBox }); - - expect(box.width).toBeLessThanOrEqual(customBox.width); - expect(box.height).toBeLessThanOrEqual(customBox.height); - expect(box.positionX).toBeGreaterThanOrEqual(customBox.positionX); - expect(box.positionY).toBeGreaterThanOrEqual(customBox.positionY); - }); + // 306 points wide, so a 1:2 image is 612 points tall, which still fits + // below 20% of the page. + expect(result.width).toBe(50); + expect(result.height).toBeCloseTo(toPercentHeight(612)); + expect(result.positionY + result.height).toBeLessThanOrEqual(100); }); - it('leaves the box untouched for a degenerate image', () => { + it('applies the same rule to a box still at its default size', () => { + const box = { positionX: 10, positionY: 10, ...CONTENT_IMAGE_DEFAULT_SIZE }; + + const result = resolveAttachedImageBox({ image: { width: 400, height: 200 }, page: PAGE, box }); + + // The default 15% width is kept, the image's native size is irrelevant. + expect(result.width).toBe(CONTENT_IMAGE_DEFAULT_SIZE.width); + expect(result.height).toBeCloseTo(toPercentHeight(0.15 * PAGE.width * 0.5)); + }); + + it('shrinks the box, ratio intact, when the height would run off the page', () => { + const box = { positionX: 10, positionY: 80, width: 50, height: 10 }; + + const result = resolveAttachedImageBox({ image: { width: 100, height: 200 }, page: PAGE, box }); + + // Only 20% of the page (158.4 points) is left below the box, so the image + // is 158.4 tall and 79.2 wide. + expect(result.positionY).toBe(80); + expect(result.positionY + result.height).toBeCloseTo(100); + expect(result.height).toBeCloseTo(20); + expect(result.width).toBeCloseTo(toPercentWidth(79.2)); + expect(result.width).toBeLessThan(box.width); + }); + + it('caps the width to the page when the box already runs past the right edge', () => { + const box = { positionX: 90, positionY: 10, width: 30, height: 10 }; + + const result = resolveAttachedImageBox({ image: { width: 200, height: 100 }, page: PAGE, box }); + + expect(result.positionX).toBe(90); + expect(result.positionX + result.width).toBeCloseTo(100); + expect(result.height).toBeCloseTo(toPercentHeight(0.1 * PAGE.width * 0.5)); + }); + + it('leaves the box untouched for a degenerate image or box', () => { const box = { positionX: 10, positionY: 20, width: 50, height: 25 }; expect(resolveAttachedImageBox({ image: { width: 0, height: 0 }, page: PAGE, box })).toEqual(box); + expect( + resolveAttachedImageBox({ image: { width: 100, height: 100 }, page: PAGE, box: { ...box, width: 0 } }), + ).toEqual({ ...box, width: 0 }); }); }); diff --git a/packages/lib/universal/content-renderer/content-image-box.ts b/packages/lib/universal/content-renderer/content-image-box.ts index c99076465..1c1c76f5c 100644 --- a/packages/lib/universal/content-renderer/content-image-box.ts +++ b/packages/lib/universal/content-renderer/content-image-box.ts @@ -1,43 +1,17 @@ -import { clamp } from 'remeda'; - import type { PercentageBox, Size } from '../../utils/geometry'; -import { toPercentage, toPixelRect } from '../../utils/geometry'; -import { calculateContainFit } from './content-renderer'; +import { toPercentage, toPixels } from '../../utils/geometry'; /** * The size of an image content when dropped from the palette, as a * percentage of the page. - * - * Defined in page units rather than screen pixels so "still the default size" - * is a stable comparison across zoom levels and reloads. */ export const CONTENT_IMAGE_DEFAULT_SIZE = { width: 15, height: 10 } as const; -/** - * The largest fraction of the page width or height an image is auto sized to. - */ -const CONTENT_IMAGE_MAX_PAGE_FRACTION = 0.8; - -/** - * How image pixels map to page points when auto sizing: one pixel is one - * point at scale 1. - */ -const CONTENT_IMAGE_PIXELS_PER_POINT = 1; - /** * A box content's geometry as a percentage of the page. */ type ContentBox = PercentageBox; -/** - * Whether a box is still the size it was dropped at, i.e. the author has not - * resized it. Only the transformer changes a content's size, so equality is - * sufficient. - */ -export const isContentImageDefaultSize = (box: Size) => { - return box.width === CONTENT_IMAGE_DEFAULT_SIZE.width && box.height === CONTENT_IMAGE_DEFAULT_SIZE.height; -}; - type ResolveAttachedImageBoxOptions = { /** * The intrinsic image size in pixels. @@ -56,63 +30,37 @@ type ResolveAttachedImageBoxOptions = { }; /** - * The box an image content takes when an image is attached to it, - * so the box always has the image's shape: + * The box an image content takes when an image is attached to it, so the box + * has the image's shape. * - * - A box the author has not resized is sized to the image at its native - * size, capped to a fraction of the page and shifted to stay within it. - * - A box the author has sized is tightened around the image fitted within - * it, so it never grows beyond what was drawn but sheds any letterboxing. + * The box keeps its width and its top left corner, and its height follows the + * image's ratio. If that runs past the right or bottom of the page, the box + * is shrunk (ratio intact) until it fits. */ export const resolveAttachedImageBox = ({ image, page, box }: ResolveAttachedImageBoxOptions): ContentBox => { - if (image.width <= 0 || image.height <= 0 || page.width <= 0 || page.height <= 0) { + if (image.width <= 0 || image.height <= 0 || page.width <= 0 || page.height <= 0 || box.width <= 0) { return box; } - if (isContentImageDefaultSize(box)) { - return autoSizeBox({ image, page, box }); - } - - return tightenBox({ image, page, box }); -}; - -const autoSizeBox = ({ image, page, box }: ResolveAttachedImageBoxOptions): ContentBox => { - const maxWidth = page.width * CONTENT_IMAGE_MAX_PAGE_FRACTION; - const maxHeight = page.height * CONTENT_IMAGE_MAX_PAGE_FRACTION; - const ratio = image.height / image.width; - let width = Math.min(image.width / CONTENT_IMAGE_PIXELS_PER_POINT, maxWidth); + // The box's own width may already run past the page, e.g. a box dropped at + // the edge, so the width is capped to the room beside it as well. + const availableWidth = toPixels(100 - box.positionX, page.width); + const availableHeight = toPixels(100 - box.positionY, page.height); + + let width = Math.min(toPixels(box.width, page.width), availableWidth); let height = width * ratio; - if (height > maxHeight) { - height = maxHeight; + if (height > availableHeight) { + height = availableHeight; width = height / ratio; } - const widthPercent = toPercentage(width, page.width); - const heightPercent = toPercentage(height, page.height); - return { - positionX: clampPosition(box.positionX, widthPercent), - positionY: clampPosition(box.positionY, heightPercent), - width: widthPercent, - height: heightPercent, + positionX: box.positionX, + positionY: box.positionY, + width: toPercentage(width, page.width), + height: toPercentage(height, page.height), }; }; - -const tightenBox = ({ image, page, box }: ResolveAttachedImageBoxOptions): ContentBox => { - const fit = calculateContainFit(image, toPixelRect(box, page)); - - return { - positionX: box.positionX + toPercentage(fit.x, page.width), - positionY: box.positionY + toPercentage(fit.y, page.height), - width: toPercentage(fit.width, page.width), - height: toPercentage(fit.height, page.height), - }; -}; - -/** - * Shift a position so the given extent stays within the page. - */ -const clampPosition = (position: number, extent: number) => clamp(position, { min: 0, max: 100 - extent }); diff --git a/packages/lib/universal/content-renderer/render-image-content.ts b/packages/lib/universal/content-renderer/render-image-content.ts index cdb56c42a..094e04221 100644 --- a/packages/lib/universal/content-renderer/render-image-content.ts +++ b/packages/lib/universal/content-renderer/render-image-content.ts @@ -17,9 +17,11 @@ import { /** * Render an image content. * - * The image is fitted within the content box preserving its aspect ratio. - * The full box remains the content's bounds and clickable area, so resizing - * and selection behave the same as other box contents. + * The image is fitted within the content box preserving its aspect ratio + * (CSS `object-fit: contain`). The box takes the image's shape when the image + * is attached (see `resolveAttachedImageBox`) and the transformer only + * resizes it from the corners with that ratio kept, so in practice the image + * fills the box; the fit is a safety net for boxes which do not match. * * Until an image is attached (or while it is loading) a dashed placeholder * is shown instead, except when exporting. diff --git a/packages/lib/universal/konva/transformer.test.ts b/packages/lib/universal/konva/transformer.test.ts index 583ace7bc..20fcdf799 100644 --- a/packages/lib/universal/konva/transformer.test.ts +++ b/packages/lib/universal/konva/transformer.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { boundTransformerBoxToPage } from './transformer'; +import { boundTransformerBoxToPage, boundTransformerBoxToPageKeepingRatio, type TransformerBox } from './transformer'; const PAGE = { width: 800, height: 1000 }; -const box = (x: number, y: number, width: number, height: number, rotation = 0) => ({ +const box = (x: number, y: number, width: number, height: number, rotation = 0): TransformerBox => ({ x, y, width, @@ -12,22 +12,51 @@ const box = (x: number, y: number, width: number, height: number, rotation = 0) rotation, }); +/** + * The absolute corners of a box, rotated about its origin like Konva does. + */ +const cornersOf = ({ x, y, width, height, rotation }: TransformerBox) => { + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + + return [ + [0, 0], + [width, 0], + [0, height], + [width, height], + ].map(([lx, ly]) => ({ x: x + lx * cos - ly * sin, y: y + lx * sin + ly * cos })); +}; + +const expectOnPage = (b: TransformerBox) => { + for (const corner of cornersOf(b)) { + expect(corner.x).toBeGreaterThanOrEqual(-1e-6); + expect(corner.x).toBeLessThanOrEqual(PAGE.width + 1e-6); + expect(corner.y).toBeGreaterThanOrEqual(-1e-6); + expect(corner.y).toBeLessThanOrEqual(PAGE.height + 1e-6); + } +}; + +const expectCornerClose = (a: { x: number; y: number }, b: { x: number; y: number }) => { + expect(a.x).toBeCloseTo(b.x, 6); + expect(a.y).toBeCloseTo(b.y, 6); +}; + describe('boundTransformerBoxToPage', () => { it('leaves a box within the page untouched', () => { const inside = box(100, 100, 200, 150); - expect(boundTransformerBoxToPage(inside, PAGE)).toEqual(inside); + expect(boundTransformerBoxToPage(box(100, 100, 180, 140), inside, PAGE)).toEqual(inside); }); it('pins the right edge to the page while a right anchor is dragged past it', () => { // Dragging the right edge from 700 to 900 stops at the page edge. - const bounded = boundTransformerBoxToPage(box(500, 100, 400, 150), PAGE); + const bounded = boundTransformerBoxToPage(box(500, 100, 200, 150), box(500, 100, 400, 150), PAGE); expect(bounded).toEqual(box(500, 100, 300, 150)); }); it('pins the bottom edge to the page', () => { - const bounded = boundTransformerBoxToPage(box(100, 900, 200, 300), PAGE); + const bounded = boundTransformerBoxToPage(box(100, 900, 200, 80), box(100, 900, 200, 300), PAGE); expect(bounded).toEqual(box(100, 900, 200, 100)); }); @@ -35,27 +64,123 @@ describe('boundTransformerBoxToPage', () => { it('pins the left edge to the page, keeping the right edge where it was', () => { // Dragging the left anchor to x = -50 pins it at 0 and shrinks the width // so the opposite edge stays put at 250. - const bounded = boundTransformerBoxToPage(box(-50, 100, 300, 150), PAGE); + const bounded = boundTransformerBoxToPage(box(50, 100, 200, 150), box(-50, 100, 300, 150), PAGE); expect(bounded).toEqual(box(0, 100, 250, 150)); }); it('pins the top edge to the page, keeping the bottom edge where it was', () => { - const bounded = boundTransformerBoxToPage(box(100, -30, 200, 180), PAGE); + const bounded = boundTransformerBoxToPage(box(100, 20, 200, 130), box(100, -30, 200, 180), PAGE); expect(bounded).toEqual(box(100, 0, 200, 150)); }); it('pins the axes independently, so the other axis still follows the mouse', () => { // Only the width overflows; the height change is kept as is. - const bounded = boundTransformerBoxToPage(box(600, 100, 300, 400), PAGE); + const bounded = boundTransformerBoxToPage(box(600, 100, 150, 200), box(600, 100, 300, 400), PAGE); expect(bounded).toEqual(box(600, 100, 200, 400)); }); - it('does not bound rotated boxes, whose edges are not axis aligned', () => { - const rotated = box(700, 100, 300, 150, 45); + it('keeps every corner of a rotated box on the page, anchored on the fixed corner', () => { + // A box rotated 45 degrees near the right edge, with its bottom right + // anchor dragged outwards so the far corners leave the page. + // At 45 degrees the top right corner heads up and right, so a wide + // enough box pushes it past the right edge of the page. + const rotation = Math.PI / 4; + const old = box(500, 300, 100, 60, rotation); + const proposed = box(500, 300, 600, 100, rotation); - expect(boundTransformerBoxToPage(rotated, PAGE)).toEqual(rotated); + expect(cornersOf(proposed).some((corner) => corner.x > PAGE.width)).toBe(true); + + const bounded = boundTransformerBoxToPage(old, proposed, PAGE); + + expectOnPage(bounded); + expect(bounded.rotation).toBe(rotation); + + // The top left corner, which was not dragged, has not moved, and the + // box was clamped rather than passed through. + expectCornerClose(cornersOf(bounded)[0], cornersOf(proposed)[0]); + expect(bounded.width).toBeLessThan(proposed.width); + }); + + it('anchors a rotated box on its bottom right when the top left anchor is dragged', () => { + const rotation = Math.PI / 6; + const old = box(400, 400, 100, 60, rotation); + + // Grow the box backwards from the bottom right, which stays fixed, far + // enough that the new top left leaves the page. + const fixed = cornersOf(old)[3]; + const proposed = box( + fixed.x - 900 * Math.cos(rotation) + 600 * Math.sin(rotation), + fixed.y - 900 * Math.sin(rotation) - 600 * Math.cos(rotation), + 900, + 600, + rotation, + ); + + const bounded = boundTransformerBoxToPage(old, proposed, PAGE); + + expectOnPage(bounded); + expectCornerClose(cornersOf(bounded)[3], fixed); + }); +}); + +describe('boundTransformerBoxToPageKeepingRatio', () => { + // A 2:1 box with its top left corner at (500, 100). + const old = box(500, 100, 200, 100); + + it('leaves a box within the page untouched', () => { + const grown = box(500, 100, 240, 120); + + expect(boundTransformerBoxToPageKeepingRatio(old, grown, PAGE)).toEqual(grown); + }); + + it('scales the box down about its top left corner when the bottom right anchor leaves the page', () => { + // The right edge would land at 900, 100 past the page. Only 300 is + // available from x = 500, so the box becomes 300 x 150. + const bounded = boundTransformerBoxToPageKeepingRatio(old, box(500, 100, 400, 200), PAGE); + + expect(bounded.x).toBeCloseTo(500); + expect(bounded.y).toBeCloseTo(100); + expect(bounded.width).toBeCloseTo(300); + expect(bounded.height).toBeCloseTo(150); + }); + + it('scales the box down about its bottom right corner when the top left anchor leaves the page', () => { + // The top left anchor is dragged to (-100, -200) with the bottom right + // fixed at (700, 200). 700 is available horizontally and 200 vertically, + // so the height binds: 200 tall, 400 wide, pinned to the fixed corner. + const bounded = boundTransformerBoxToPageKeepingRatio(old, box(-100, -200, 800, 400), PAGE); + + expect(bounded.x).toBeCloseTo(300); + expect(bounded.y).toBeCloseTo(0); + expect(bounded.width).toBeCloseTo(400); + expect(bounded.height).toBeCloseTo(200); + }); + + it('binds on whichever axis has less room', () => { + // From (500, 100) there is 300 horizontally and 900 vertically. A 1:1 + // box of 500 is limited by the width. + const square = box(500, 100, 100, 100); + + const bounded = boundTransformerBoxToPageKeepingRatio(square, box(500, 100, 500, 500), PAGE); + + expect(bounded.width).toBeCloseTo(300); + expect(bounded.height).toBeCloseTo(300); + }); + + it('keeps every corner of a rotated box on the page while preserving the ratio', () => { + const rotation = Math.PI / 4; + const old = box(500, 300, 200, 100, rotation); + const proposed = box(500, 300, 800, 400, rotation); + + const bounded = boundTransformerBoxToPageKeepingRatio(old, proposed, PAGE); + + expectOnPage(bounded); + expect(bounded.rotation).toBe(rotation); + expect(bounded.width / bounded.height).toBeCloseTo(2); + expectCornerClose(cornersOf(bounded)[0], cornersOf(proposed)[0]); + expect(bounded.width).toBeLessThan(proposed.width); }); }); diff --git a/packages/lib/universal/konva/transformer.ts b/packages/lib/universal/konva/transformer.ts index 9f6bf781f..44454bc4e 100644 --- a/packages/lib/universal/konva/transformer.ts +++ b/packages/lib/universal/konva/transformer.ts @@ -67,32 +67,213 @@ export type TransformerBox = { rotation: number; }; +type Page = { width: number; height: number }; + +type Point = { x: number; y: number }; + /** - * Pin a resize to the page so nothing can be stretched past its edges. - * - * Each edge is clamped independently: an anchor dragged past the page keeps - * its edge on the page boundary while the opposite edge stays where it is, - * and the other axis still follows the mouse. The mouse itself is free to - * leave the page. + * The corner of a box, as a fraction of its width and height from its + * origin: the top left is (0, 0) and the bottom right is (1, 1). */ -export const boundTransformerBoxToPage = ( - box: TransformerBox, - page: { width: number; height: number }, -): TransformerBox => { - if (box.rotation !== 0) { - return box; +type Corner = { u: number; v: number }; + +/** + * The absolute position of a corner of a box. + * + * Konva rotates a node about its origin, `(x, y)` less any `offset`. Content + * groups set no offset, so their origin, and so the pivot, is the top left + * corner, matching `positionX`/`positionY` in the content meta. The box the + * transformer hands to `boundBoxFunc` is built the same way, with the + * rotation in radians. + */ +const getBoxCorner = (box: TransformerBox, corner: Corner): Point => { + const cos = Math.cos(box.rotation); + const sin = Math.sin(box.rotation); + + const localX = corner.u * box.width; + const localY = corner.v * box.height; + + return { + x: box.x + localX * cos - localY * sin, + y: box.y + localX * sin + localY * cos, + }; +}; + +/** + * Whichever corner moved least between the two boxes, i.e. the one the + * resize is anchored on. A drag of the bottom right anchor leaves the top + * left in place, and so on. + */ +const getFixedCorner = (oldBox: TransformerBox, newBox: TransformerBox): Corner => { + const corners: Corner[] = [ + { u: 0, v: 0 }, + { u: 1, v: 0 }, + { u: 0, v: 1 }, + { u: 1, v: 1 }, + ]; + + let fixedCorner = corners[0]; + let smallestMovement = Number.POSITIVE_INFINITY; + + for (const corner of corners) { + const before = getBoxCorner(oldBox, corner); + const after = getBoxCorner(newBox, corner); + + const movement = Math.hypot(after.x - before.x, after.y - before.y); + + if (movement < smallestMovement) { + smallestMovement = movement; + fixedCorner = corner; + } } - const left = Math.max(0, box.x); - const top = Math.max(0, box.y); - const right = Math.min(page.width, box.x + box.width); - const bottom = Math.min(page.height, box.y + box.height); + return fixedCorner; +}; + +/** + * The box which spans `width` along its local X axis and `height` along its + * local Y axis from the given fixed corner, rotated like `box`. + */ +const buildBoxFromCorner = ( + box: TransformerBox, + fixedCorner: Corner, + fixedPoint: Point, + width: number, + height: number, +): TransformerBox => { + const cos = Math.cos(box.rotation); + const sin = Math.sin(box.rotation); + + // Walk back from the fixed corner to the origin along the local axes. + const localX = -fixedCorner.u * width; + const localY = -fixedCorner.v * height; return { ...box, - x: left, - y: top, - width: right - left, - height: bottom - top, + x: fixedPoint.x + localX * cos - localY * sin, + y: fixedPoint.y + localX * sin + localY * cos, + width, + height, }; }; + +/** + * How far a box may extend from a fixed corner along each of its local axes + * before any corner leaves the page, as a fraction of its current extent. + * + * Each axis is checked on its own: the far corner along that axis, and the + * corner diagonally opposite the fixed one, must both stay on the page. For + * a rotated box each local axis moves both page axes at once, so the limit + * is the tightest of the four page edges. + */ +const getAxisLimits = (box: TransformerBox, fixedCorner: Corner, fixedPoint: Point, page: Page) => { + const cos = Math.cos(box.rotation); + const sin = Math.sin(box.rotation); + + // The direction each local axis points in, away from the fixed corner. + const directionX = { x: cos * (fixedCorner.u === 0 ? 1 : -1), y: sin * (fixedCorner.u === 0 ? 1 : -1) }; + const directionY = { x: -sin * (fixedCorner.v === 0 ? 1 : -1), y: cos * (fixedCorner.v === 0 ? 1 : -1) }; + + /** + * The largest `t` such that `point + t * direction` stays on the page. + */ + const limitAlong = (point: Point, direction: Point) => { + let limit = Number.POSITIVE_INFINITY; + + if (direction.x > 0) { + limit = Math.min(limit, (page.width - point.x) / direction.x); + } else if (direction.x < 0) { + limit = Math.min(limit, -point.x / direction.x); + } + + if (direction.y > 0) { + limit = Math.min(limit, (page.height - point.y) / direction.y); + } else if (direction.y < 0) { + limit = Math.min(limit, -point.y / direction.y); + } + + return limit; + }; + + return { + directionX, + directionY, + limitAlong, + }; +}; + +/** + * Pin a resize to the page so nothing can be stretched past its edges, with + * each axis clamped on its own: an anchor dragged past the page stops that + * axis at the edge while the other still follows the mouse. + * + * Works for rotated boxes too, by shrinking the box along its own axes from + * the corner not being dragged until every corner is on the page. + */ +export const boundTransformerBoxToPage = ( + oldBox: TransformerBox, + newBox: TransformerBox, + page: Page, +): TransformerBox => { + if (newBox.width <= 0 || newBox.height <= 0) { + return newBox; + } + + const fixedCorner = getFixedCorner(oldBox, newBox); + const fixedPoint = getBoxCorner(newBox, fixedCorner); + + const { directionX, directionY, limitAlong } = getAxisLimits(newBox, fixedCorner, fixedPoint, page); + + // Shrink the width first, then the height from the corner the width now + // ends at, so the far corner is checked against the clamped width. + const width = Math.min(newBox.width, limitAlong(fixedPoint, directionX)); + + const widthEnd = { x: fixedPoint.x + directionX.x * width, y: fixedPoint.y + directionX.y * width }; + + const height = Math.min(newBox.height, limitAlong(fixedPoint, directionY), limitAlong(widthEnd, directionY)); + + return buildBoxFromCorner(newBox, fixedCorner, fixedPoint, width, height); +}; + +/** + * Pin a ratio locked resize to the page. + * + * Clamping one axis on its own would break the ratio, and Konva would undo + * the clamp on the next mouse move anyway since it recomputes the box from + * the anchor's distance to the opposite corner. Instead the box is scaled + * down about its fixed corner until every corner is on the page. + */ +export const boundTransformerBoxToPageKeepingRatio = ( + oldBox: TransformerBox, + newBox: TransformerBox, + page: Page, +): TransformerBox => { + if (newBox.width <= 0 || newBox.height <= 0) { + return newBox; + } + + const fixedCorner = getFixedCorner(oldBox, newBox); + const fixedPoint = getBoxCorner(newBox, fixedCorner); + + const { directionX, directionY, limitAlong } = getAxisLimits(newBox, fixedCorner, fixedPoint, page); + + // The diagonal from the fixed corner to the opposite one, in page units + // per unit of scale, which every corner must stay inside the page along. + const diagonal = { + x: directionX.x * newBox.width + directionY.x * newBox.height, + y: directionX.y * newBox.width + directionY.y * newBox.height, + }; + + const scale = Math.min( + 1, + limitAlong(fixedPoint, directionX) / newBox.width, + limitAlong(fixedPoint, directionY) / newBox.height, + limitAlong(fixedPoint, diagonal), + ); + + if (scale === 1) { + return newBox; + } + + return buildBoxFromCorner(newBox, fixedCorner, fixedPoint, newBox.width * scale, newBox.height * scale); +};