From 997fec6c75060df4c2cff2cd529b840426563daa Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Sat, 21 Sep 2024 14:09:47 +0800 Subject: [PATCH] manipulate and update a crop --- packages/excalidraw/components/App.tsx | 216 ++++++++++++- packages/excalidraw/element/cropElement.ts | 292 ++++++++++++++++++ packages/excalidraw/element/resizeElements.ts | 37 +++ 3 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 packages/excalidraw/element/cropElement.ts diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ea86b39437..1a2f8f34ed 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -441,7 +441,14 @@ import { getLinkDirectionFromKey, } from "../element/flowchart"; import type { LocalPoint, Radians } from "../../math"; -import { point, pointDistance, vector } from "../../math"; +import { + clamp, + point, + pointDistance, + pointRotateRads, + vector, +} from "../../math"; +import { cropElement } from "../element/cropElement"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -584,6 +591,7 @@ class App extends React.Component { lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null; + lastPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; animationFrameHandler = new AnimationFrameHandler(); @@ -3862,6 +3870,28 @@ class App extends React.Component { } if (!isInputLike(event.target)) { + if ( + (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && + this.state.croppingElement + ) { + this.finishImageCropping(); + return; + } + + const selectedElements = getSelectedElements( + this.scene.getNonDeletedElementsMap(), + this.state, + ); + + if ( + selectedElements.length === 1 && + isImageElement(selectedElements[0]) && + event.key === KEYS.ENTER + ) { + this.startImageCropping(selectedElements[0]); + return; + } + if ( event.key === KEYS.ESCAPE && this.flowChartCreator.isCreatingChart @@ -6560,6 +6590,12 @@ class App extends React.Component { arrowDirection: "origin", center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 }, }, + crop: { + handleType: false, + isCropping: false, + offset: { x: 0, y: 0 }, + complete: false, + }, hit: { element: null, allHitElements: [], @@ -6671,11 +6707,28 @@ class App extends React.Component { this.device, ); if (elementWithTransformHandleType != null) { - this.setState({ - resizingElement: elementWithTransformHandleType.element, - }); - pointerDownState.resize.handleType = - elementWithTransformHandleType.transformHandleType; + if ( + elementWithTransformHandleType.transformHandleType === "rotation" + ) { + this.setState({ + resizingElement: elementWithTransformHandleType.element, + }); + pointerDownState.resize.handleType = + elementWithTransformHandleType.transformHandleType; + } else if (this.state.croppingElement) { + this.setState({ + croppingElement: + elementWithTransformHandleType.element as ExcalidrawImageElement, + }); + pointerDownState.crop.handleType = + elementWithTransformHandleType.transformHandleType; + } else { + this.setState({ + resizingElement: elementWithTransformHandleType.element, + }); + pointerDownState.resize.handleType = + elementWithTransformHandleType.transformHandleType; + } } } else if (selectedElements.length > 1) { pointerDownState.resize.handleType = getTransformHandleTypeFromCoords( @@ -6708,6 +6761,17 @@ class App extends React.Component { selectedElements[0], ); } + } else if (pointerDownState.crop.handleType) { + pointerDownState.crop.isCropping = true; + pointerDownState.crop.offset = tupleToCoors( + getResizeOffsetXY( + pointerDownState.crop.handleType, + selectedElements, + this.scene.getNonDeletedElementsMap(), + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + ); } else { if (this.state.selectedLinearElement) { const linearElementEditor = @@ -7604,6 +7668,13 @@ class App extends React.Component { return true; } } + if (pointerDownState.crop.isCropping) { + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + if (this.maybeHandleCrop(pointerDownState, event)) { + return true; + } + } const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { @@ -7773,6 +7844,65 @@ class App extends React.Component { } } + // #region drag + if ( + selectedElements.length === 1 && + isImageElement(selectedElements[0]) && + this.state.croppingElement?.id === selectedElements[0].id && + selectedElements[0].crop !== null + ) { + const crop = selectedElements[0].crop; + const image = selectedElements[0]; + + const lastPointerCoords = + this.lastPointerMoveCoords ?? pointerDownState.origin; + + const instantDragOffset = { + x: pointerCoords.x - lastPointerCoords.x, + y: pointerCoords.y - lastPointerCoords.y, + }; + + // current offset is based on the element's width and height + const uncroppedWidth = image.widthAtCreation * image.resizedFactorX; + const uncroppedHeight = + image.heightAtCreation * image.resizedFactorY; + + const SENSITIVITY_FACTOR = 3; + + const adjustedOffset = { + x: + instantDragOffset.x * + (uncroppedWidth / image.naturalWidth) * + SENSITIVITY_FACTOR, + y: + instantDragOffset.y * + (uncroppedHeight / image.naturalHeight) * + SENSITIVITY_FACTOR, + }; + + const nextCrop = { + ...crop, + x: clamp( + crop.x - adjustedOffset.x, + 0, + image.naturalWidth - crop.width, + ), + y: clamp( + crop.y - adjustedOffset.y, + 0, + image.naturalHeight - crop.height, + ), + }; + + mutateElement(image, { + crop: nextCrop, + }); + + this.lastPointerMoveCoords = pointerCoords; + + return; + } + // Snap cache *must* be synchronously popuplated before initial drag, // otherwise the first drag even will not snap, causing a jump before // it snaps to its position if previously snapped already. @@ -7906,6 +8036,8 @@ class App extends React.Component { this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true); } + + this.lastPointerMoveCoords = pointerCoords; return; } } @@ -8158,6 +8290,7 @@ class App extends React.Component { activeTool, isResizing, isRotating, + isCropping, } = this.state; this.setState((prevState) => ({ @@ -8172,6 +8305,8 @@ class App extends React.Component { originSnapOffset: null, })); + this.lastPointerMoveCoords = null; + SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -8654,6 +8789,15 @@ class App extends React.Component { } } + if ( + isCropping && + !isResizing && + ((!hitElement && !pointerDownState.crop.isCropping) || + (hitElement && hitElement !== this.state.croppingElement)) + ) { + this.finishImageCropping(); + } + const pointerStart = this.lastPointerDownEvent; const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; @@ -8909,7 +9053,12 @@ class App extends React.Component { this.store.shouldCaptureIncrement(); } - if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { + if ( + pointerDownState.drag.hasOccurred || + isResizing || + isRotating || + isCropping + ) { // We only allow binding via linear elements, specifically via dragging // the endpoints ("start" or "end"). const linearElements = this.scene @@ -9324,7 +9473,18 @@ class App extends React.Component { const x = imageElement.x + imageElement.width / 2 - width / 2; const y = imageElement.y + imageElement.height / 2 - height / 2; - mutateElement(imageElement, { x, y, width, height }); + mutateElement(imageElement, { + x, + y, + width, + height, + widthAtCreation: width, + heightAtCreation: height, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + resizedFactorX: 1, + resizedFactorY: 1, + }); } }; @@ -9863,6 +10023,46 @@ class App extends React.Component { } }; + private maybeHandleCrop = ( + pointerDownState: PointerDownState, + event: MouseEvent | KeyboardEvent, + ): boolean => { + if (pointerDownState.crop.complete) { + return true; + } + const selectedElements = getSelectedElements( + this.scene.getNonDeletedElements(), + this.state, + ); + + if (selectedElements.length > 1) { + // don't see much sense in allowing multi-crop, that would be weird + return false; + } + + const transformHandleType = pointerDownState.crop.handleType; + const pointerCoords = pointerDownState.lastCoords; + const [x, y] = getGridPoint( + pointerCoords.x - pointerDownState.crop.offset.x, + pointerCoords.y - pointerDownState.crop.offset.y, + this.getEffectiveGridSize(), + ); + + const elementToCrop = selectedElements[0] as ExcalidrawImageElement; + if (transformHandleType) { + cropElement( + elementToCrop, + this.scene.getNonDeletedElementsMap(), + transformHandleType, + x, + y, + ); + return true; + } + + return false; + }; + private maybeHandleResize = ( pointerDownState: PointerDownState, event: MouseEvent | KeyboardEvent, diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts new file mode 100644 index 0000000000..27c0f15c7d --- /dev/null +++ b/packages/excalidraw/element/cropElement.ts @@ -0,0 +1,292 @@ +import { Point } from "points-on-curve"; +import { + type Radians, + point, + pointCenter, + pointRotateRads, + vectorFromPoint, + vectorNormalize, + vectorSubtract, + vectorAdd, + vectorScale, + pointFromVector, + clamp, +} from "../../math"; +import { updateBoundElements } from "./binding"; +import { mutateElement } from "./mutateElement"; +import { TransformHandleType } from "./transformHandles"; +import { + ElementsMap, + ExcalidrawElement, + ExcalidrawImageElement, + NonDeleted, + NonDeletedSceneElementsMap, +} from "./types"; +import { + getElementAbsoluteCoords, + getResizedElementAbsoluteCoords, +} from "./bounds"; + +// i split out these 'internal' functions so that this functionality can be easily unit tested +const cropElementInternal = ( + element: ExcalidrawImageElement, + transformHandle: TransformHandleType, + pointerX: number, + pointerY: number, +) => { + const uncroppedWidth = element.widthAtCreation * element.resizedFactorX; + const uncroppedHeight = element.heightAtCreation * element.resizedFactorY; + + const naturalWidthToUncropped = element.naturalWidth / uncroppedWidth; + const naturalHeightToUncropped = element.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 availableTopCropSpace = croppedTop; + const availableLeftCropSpace = croppedLeft; + + const rotatedPointer = pointRotateRads( + point(pointerX, pointerY), + point(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; + const crop = element.crop ?? { + x: 0, + y: 0, + width: element.naturalWidth, + height: element.naturalHeight, + }; + + if (transformHandle.includes("n")) { + const northBound = element.y - availableTopCropSpace; + const southBound = element.y + element.height; + + pointerY = clamp(pointerY, northBound, southBound); + + const pointerDeltaY = pointerY - element.y; + nextHeight = element.height - pointerDeltaY; + + crop.y = + ((pointerDeltaY + croppedTop) / uncroppedHeight) * element.naturalHeight; + crop.height = (nextHeight / uncroppedHeight) * element.naturalHeight; + } + + if (transformHandle.includes("s")) { + const northBound = element.y; + const southBound = element.y + (uncroppedHeight - croppedTop); + + pointerY = clamp(pointerY, northBound, southBound); + + nextHeight = pointerY - element.y; + crop.height = (nextHeight / uncroppedHeight) * element.naturalHeight; + } + + if (transformHandle.includes("w")) { + const eastBound = element.x + element.width; + const westBound = element.x - availableLeftCropSpace; + + pointerX = clamp(pointerX, westBound, eastBound); + + const pointerDeltaX = pointerX - element.x; + nextWidth = element.width - pointerDeltaX; + + crop.x = + ((pointerDeltaX + croppedLeft) / uncroppedWidth) * element.naturalWidth; + crop.width = (nextWidth / uncroppedWidth) * element.naturalWidth; + } + + if (transformHandle.includes("e")) { + const eastBound = element.x + (uncroppedWidth - croppedLeft); + const westBound = element.x; + + pointerX = clamp(pointerX, westBound, eastBound); + + nextWidth = pointerX - element.x; + crop.width = (nextWidth / uncroppedWidth) * element.naturalWidth; + } + + const newOrigin = recomputeOrigin( + element, + transformHandle, + nextWidth, + nextHeight, + ); + + return { + x: newOrigin[0], + y: newOrigin[1], + width: nextWidth, + height: nextHeight, + crop, + }; +}; + +export const cropElement = ( + element: ExcalidrawImageElement, + elementsMap: NonDeletedSceneElementsMap, + transformHandle: TransformHandleType, + pointerX: number, + pointerY: number, +) => { + const mutation = cropElementInternal( + element, + transformHandle, + pointerX, + pointerY, + ); + + mutateElement(element, mutation); + + updateBoundElements(element, elementsMap, { + oldSize: { width: element.width, height: element.height }, + }); +}; + +// TODO: replace with the refactored resizeSingleElement +const recomputeOrigin = ( + stateAtCropStart: NonDeleted, + transformHandle: TransformHandleType, + width: number, + height: number, +) => { + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtCropStart, + stateAtCropStart.width, + stateAtCropStart.height, + true, + ); + const startTopLeft = point(x1, y1); + const startBottomRight = point(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]]; + } + + // 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; +}; + +export const getUncroppedImageElement = ( + image: ExcalidrawImageElement, + elementsMap: ElementsMap, +) => { + if (image.crop) { + const width = image.widthAtCreation * image.resizedFactorX; + const height = image.heightAtCreation * image.resizedFactorY; + + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + image, + elementsMap, + ); + + const topLeftVector = vectorFromPoint( + pointRotateRads(point(x1, y1), point(cx, cy), image.angle), + ); + const topRightVector = vectorFromPoint( + pointRotateRads(point(x2, y1), point(cx, cy), image.angle), + ); + const topEdgeNormalized = vectorNormalize( + vectorSubtract(topRightVector, topLeftVector), + ); + const bottomLeftVector = vectorFromPoint( + pointRotateRads(point(x1, y2), point(cx, cy), image.angle), + ); + const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector); + const leftEdgeNormalized = vectorNormalize(leftEdgeVector); + + const rotatedTopLeft = vectorAdd( + vectorAdd( + topLeftVector, + vectorScale( + topEdgeNormalized, + (-image.crop.x * width) / image.naturalWidth, + ), + ), + vectorScale( + leftEdgeNormalized, + (-image.crop.y * height) / image.naturalHeight, + ), + ); + + const center = pointFromVector( + vectorAdd( + vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)), + vectorScale(leftEdgeNormalized, height / 2), + ), + ); + + const unrotatedTopLeft = pointRotateRads( + pointFromVector(rotatedTopLeft), + center, + -image.angle as Radians, + ); + + const uncroppedElement: ExcalidrawImageElement = { + ...image, + x: unrotatedTopLeft[0], + y: unrotatedTopLeft[1], + width, + height, + crop: null, + }; + + return uncroppedElement; + } + + return image; +}; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 3f3f8ef1e2..071a30df34 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -696,6 +696,13 @@ export const resizeSingleElement = ( points: rescaledPoints, }; + // TODO: this is not the best approach + updateInternalScale( + element, + eleNewWidth / element.width, + eleNewHeight / element.height, + ); + if ("scale" in element && "scale" in stateAtResizeStart) { mutateElement(element, { scale: [ @@ -750,6 +757,36 @@ export const resizeSingleElement = ( } }; +const updateInternalScale = ( + element: NonDeletedExcalidrawElement, + scaleX: number, + scaleY: number, +) => { + if ("type" in element && element.type === "image") { + element = element as ExcalidrawImageElement; + } else { + return; + } + + // if the scales happen to be 0 (which is insanely unlikely), it will + // zero out the rolling multiplier and cause weird bugs with cropping. + // if zero is detected, just set the scales to an obnoxiously small number + if (scaleX === 0) { + scaleX = Number.EPSILON; + } + if (scaleY === 0) { + scaleY = Number.EPSILON; + } + + scaleX = Math.abs(scaleX); + scaleY = Math.abs(scaleY); + + mutateElement(element, { + resizedFactorX: element.resizedFactorX * scaleX, + resizedFactorY: element.resizedFactorY * scaleY, + }); +}; + export const resizeMultipleElements = ( originalElements: PointerDownState["originalElements"], selectedElements: readonly NonDeletedExcalidrawElement[],