fix: improve transformer edge cases

This commit is contained in:
David Nguyen
2026-10-04 12:54:52 +11:00
parent 5281194ad5
commit f5bd580dbf
10 changed files with 659 additions and 193 deletions
@@ -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) {
@@ -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);
@@ -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);
@@ -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.
@@ -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)[],
@@ -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 });
});
});
@@ -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 });
@@ -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.
+136 -11
View File
@@ -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);
});
});
+201 -20
View File
@@ -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);
};