mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 18:54:00 +10:00
fix: improve transformer edge cases
This commit is contained in:
+13
-3
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user