aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/cropElement.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/element/cropElement.ts')
-rw-r--r--packages/excalidraw/element/cropElement.ts625
1 files changed, 625 insertions, 0 deletions
diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts
new file mode 100644
index 0000000..b109802
--- /dev/null
+++ b/packages/excalidraw/element/cropElement.ts
@@ -0,0 +1,625 @@
+import { type Point } from "points-on-curve";
+import {
+ type Radians,
+ pointFrom,
+ pointCenter,
+ pointRotateRads,
+ vectorFromPoint,
+ vectorNormalize,
+ vectorSubtract,
+ vectorAdd,
+ vectorScale,
+ pointFromVector,
+ clamp,
+ isCloseTo,
+} from "@excalidraw/math";
+import type { TransformHandleType } from "./transformHandles";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ ImageCrop,
+ NonDeleted,
+} from "./types";
+import {
+ getElementAbsoluteCoords,
+ getResizedElementAbsoluteCoords,
+} from "./bounds";
+
+export const MINIMAL_CROP_SIZE = 10;
+
+export const cropElement = (
+ element: ExcalidrawImageElement,
+ transformHandle: TransformHandleType,
+ naturalWidth: number,
+ naturalHeight: number,
+ pointerX: number,
+ pointerY: number,
+ widthAspectRatio?: number,
+) => {
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+
+ const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
+ const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
+
+ const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
+ const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
+
+ /**
+ * uncropped width
+ * *––––––––––––––––––––––––*
+ * | (x,y) (natural) |
+ * | *–––––––* |
+ * | |///////| height | uncropped height
+ * | *–––––––* |
+ * | width (natural) |
+ * *––––––––––––––––––––––––*
+ */
+
+ const rotatedPointer = pointRotateRads(
+ pointFrom(pointerX, pointerY),
+ pointFrom(element.x + element.width / 2, element.y + element.height / 2),
+ -element.angle as Radians,
+ );
+
+ pointerX = rotatedPointer[0];
+ pointerY = rotatedPointer[1];
+
+ let nextWidth = element.width;
+ let nextHeight = element.height;
+
+ let crop: ImageCrop | null = element.crop ?? {
+ x: 0,
+ y: 0,
+ width: naturalWidth,
+ height: naturalHeight,
+ naturalWidth,
+ naturalHeight,
+ };
+
+ const previousCropHeight = crop.height;
+ const previousCropWidth = crop.width;
+
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+
+ let changeInHeight = pointerY - element.y;
+ let changeInWidth = pointerX - element.x;
+
+ if (transformHandle.includes("n")) {
+ nextHeight = clamp(
+ element.height - changeInHeight,
+ MINIMAL_CROP_SIZE,
+ isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
+ );
+ }
+
+ if (transformHandle.includes("s")) {
+ changeInHeight = pointerY - element.y - element.height;
+ nextHeight = clamp(
+ element.height + changeInHeight,
+ MINIMAL_CROP_SIZE,
+ isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
+ );
+ }
+
+ if (transformHandle.includes("e")) {
+ changeInWidth = pointerX - element.x - element.width;
+
+ nextWidth = clamp(
+ element.width + changeInWidth,
+ MINIMAL_CROP_SIZE,
+ isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
+ );
+ }
+
+ if (transformHandle.includes("w")) {
+ nextWidth = clamp(
+ element.width - changeInWidth,
+ MINIMAL_CROP_SIZE,
+ isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
+ );
+ }
+
+ const updateCropWidthAndHeight = (crop: ImageCrop) => {
+ crop.height = nextHeight * naturalHeightToUncropped;
+ crop.width = nextWidth * naturalWidthToUncropped;
+ };
+
+ updateCropWidthAndHeight(crop);
+
+ const adjustFlipForHandle = (
+ handle: TransformHandleType,
+ crop: ImageCrop,
+ ) => {
+ updateCropWidthAndHeight(crop);
+ if (handle.includes("n")) {
+ if (!isFlippedByY) {
+ crop.y += previousCropHeight - crop.height;
+ }
+ }
+ if (handle.includes("s")) {
+ if (isFlippedByY) {
+ crop.y += previousCropHeight - crop.height;
+ }
+ }
+ if (handle.includes("e")) {
+ if (isFlippedByX) {
+ crop.x += previousCropWidth - crop.width;
+ }
+ }
+ if (handle.includes("w")) {
+ if (!isFlippedByX) {
+ crop.x += previousCropWidth - crop.width;
+ }
+ }
+ };
+
+ switch (transformHandle) {
+ case "n": {
+ if (widthAspectRatio) {
+ const distanceToLeft = croppedLeft + element.width / 2;
+ const distanceToRight =
+ uncroppedWidth - croppedLeft - element.width / 2;
+
+ const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.x += (previousCropWidth - crop.width) / 2;
+ }
+
+ break;
+ }
+ case "s": {
+ if (widthAspectRatio) {
+ const distanceToLeft = croppedLeft + element.width / 2;
+ const distanceToRight =
+ uncroppedWidth - croppedLeft - element.width / 2;
+
+ const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.x += (previousCropWidth - crop.width) / 2;
+ }
+
+ break;
+ }
+ case "w": {
+ if (widthAspectRatio) {
+ const distanceToTop = croppedTop + element.height / 2;
+ const distanceToBottom =
+ uncroppedHeight - croppedTop - element.height / 2;
+
+ const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.y += (previousCropHeight - crop.height) / 2;
+ }
+
+ break;
+ }
+ case "e": {
+ if (widthAspectRatio) {
+ const distanceToTop = croppedTop + element.height / 2;
+ const distanceToBottom =
+ uncroppedHeight - croppedTop - element.height / 2;
+
+ const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.y += (previousCropHeight - crop.height) / 2;
+ }
+
+ break;
+ }
+ case "ne": {
+ if (widthAspectRatio) {
+ if (changeInWidth > -changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? uncroppedHeight - croppedTop
+ : croppedTop + element.height;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? croppedLeft + element.width
+ : uncroppedWidth - croppedLeft;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "nw": {
+ if (widthAspectRatio) {
+ if (changeInWidth < changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? uncroppedHeight - croppedTop
+ : croppedTop + element.height;
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? uncroppedWidth - croppedLeft
+ : croppedLeft + element.width;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "se": {
+ if (widthAspectRatio) {
+ if (changeInWidth > changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? croppedTop + element.height
+ : uncroppedHeight - croppedTop;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? croppedLeft + element.width
+ : uncroppedWidth - croppedLeft;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "sw": {
+ if (widthAspectRatio) {
+ if (-changeInWidth > changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? croppedTop + element.height
+ : uncroppedHeight - croppedTop;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? uncroppedWidth - croppedLeft
+ : croppedLeft + element.width;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ default:
+ break;
+ }
+
+ const newOrigin = recomputeOrigin(
+ element,
+ transformHandle,
+ nextWidth,
+ nextHeight,
+ !!widthAspectRatio,
+ );
+
+ // reset crop to null if we're back to orig size
+ if (
+ isCloseTo(crop.width, crop.naturalWidth) &&
+ isCloseTo(crop.height, crop.naturalHeight)
+ ) {
+ crop = null;
+ }
+
+ return {
+ x: newOrigin[0],
+ y: newOrigin[1],
+ width: nextWidth,
+ height: nextHeight,
+ crop,
+ };
+};
+
+const recomputeOrigin = (
+ stateAtCropStart: NonDeleted<ExcalidrawElement>,
+ transformHandle: TransformHandleType,
+ width: number,
+ height: number,
+ shouldMaintainAspectRatio?: boolean,
+) => {
+ const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+ stateAtCropStart,
+ stateAtCropStart.width,
+ stateAtCropStart.height,
+ true,
+ );
+ const startTopLeft = pointFrom(x1, y1);
+ const startBottomRight = pointFrom(x2, y2);
+ const startCenter: any = pointCenter(startTopLeft, startBottomRight);
+
+ const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
+ getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
+ const newBoundsWidth = newBoundsX2 - newBoundsX1;
+ const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
+ // Calculate new topLeft based on fixed corner during resize
+ let newTopLeft = [...startTopLeft] as [number, number];
+
+ if (["n", "w", "nw"].includes(transformHandle)) {
+ newTopLeft = [
+ startBottomRight[0] - Math.abs(newBoundsWidth),
+ startBottomRight[1] - Math.abs(newBoundsHeight),
+ ];
+ }
+ if (transformHandle === "ne") {
+ const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+ newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
+ }
+ if (transformHandle === "sw") {
+ const topRight = [startBottomRight[0], startTopLeft[1]];
+ newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
+ }
+
+ if (shouldMaintainAspectRatio) {
+ if (["s", "n"].includes(transformHandle)) {
+ newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
+ }
+ if (["e", "w"].includes(transformHandle)) {
+ newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
+ }
+ }
+
+ // adjust topLeft to new rotation point
+ const angle = stateAtCropStart.angle;
+ const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
+ const newCenter: Point = [
+ newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+ newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
+ ];
+ const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+ newTopLeft = pointRotateRads(
+ rotatedTopLeft,
+ rotatedNewCenter,
+ -angle as Radians,
+ );
+
+ const newOrigin = [...newTopLeft];
+ newOrigin[0] += stateAtCropStart.x - newBoundsX1;
+ newOrigin[1] += stateAtCropStart.y - newBoundsY1;
+
+ return newOrigin;
+};
+
+// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
+export const getUncroppedImageElement = (
+ element: ExcalidrawImageElement,
+ elementsMap: ElementsMap,
+) => {
+ if (element.crop) {
+ const { width, height } = getUncroppedWidthAndHeight(element);
+
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+
+ const topLeftVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
+ );
+ const topRightVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
+ );
+ const topEdgeNormalized = vectorNormalize(
+ vectorSubtract(topRightVector, topLeftVector),
+ );
+ const bottomLeftVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
+ );
+ const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
+ const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
+
+ const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
+
+ const rotatedTopLeft = vectorAdd(
+ vectorAdd(
+ topLeftVector,
+ vectorScale(
+ topEdgeNormalized,
+ (-cropX * width) / element.crop.naturalWidth,
+ ),
+ ),
+ vectorScale(
+ leftEdgeNormalized,
+ (-cropY * height) / element.crop.naturalHeight,
+ ),
+ );
+
+ const center = pointFromVector(
+ vectorAdd(
+ vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
+ vectorScale(leftEdgeNormalized, height / 2),
+ ),
+ );
+
+ const unrotatedTopLeft = pointRotateRads(
+ pointFromVector(rotatedTopLeft),
+ center,
+ -element.angle as Radians,
+ );
+
+ const uncroppedElement: ExcalidrawImageElement = {
+ ...element,
+ x: unrotatedTopLeft[0],
+ y: unrotatedTopLeft[1],
+ width,
+ height,
+ crop: null,
+ };
+
+ return uncroppedElement;
+ }
+
+ return element;
+};
+
+export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
+ if (element.crop) {
+ const width =
+ element.width / (element.crop.width / element.crop.naturalWidth);
+ const height =
+ element.height / (element.crop.height / element.crop.naturalHeight);
+
+ return {
+ width,
+ height,
+ };
+ }
+
+ return {
+ width: element.width,
+ height: element.height,
+ };
+};
+
+const adjustCropPosition = (
+ crop: ImageCrop,
+ scale: ExcalidrawImageElement["scale"],
+) => {
+ let cropX = crop.x;
+ let cropY = crop.y;
+
+ const flipX = scale[0] === -1;
+ const flipY = scale[1] === -1;
+
+ if (flipX) {
+ cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
+ }
+
+ if (flipY) {
+ cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
+ }
+
+ return {
+ cropX,
+ cropY,
+ };
+};
+
+export const getFlipAdjustedCropPosition = (
+ element: ExcalidrawImageElement,
+ natural = false,
+) => {
+ const crop = element.crop;
+ if (!crop) {
+ return null;
+ }
+
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+
+ let cropX = crop.x;
+ let cropY = crop.y;
+
+ if (isFlippedByX) {
+ cropX = crop.naturalWidth - crop.width - crop.x;
+ }
+
+ if (isFlippedByY) {
+ cropY = crop.naturalHeight - crop.height - crop.y;
+ }
+
+ if (natural) {
+ return {
+ x: cropX,
+ y: cropY,
+ };
+ }
+
+ const { width, height } = getUncroppedWidthAndHeight(element);
+
+ return {
+ x: cropX / (crop.naturalWidth / width),
+ y: cropY / (crop.naturalHeight / height),
+ };
+};