diff options
Diffstat (limited to 'packages/excalidraw/element/cropElement.ts')
| -rw-r--r-- | packages/excalidraw/element/cropElement.ts | 625 |
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), + }; +}; |
