diff --git a/packages/excalidraw/actions/actionCropEditor.tsx b/packages/excalidraw/actions/actionCropEditor.tsx new file mode 100644 index 000000000000..24b64783b7bb --- /dev/null +++ b/packages/excalidraw/actions/actionCropEditor.tsx @@ -0,0 +1,55 @@ +import { register } from "./register"; +import { cropIcon } from "../components/icons"; +import { StoreAction } from "../store"; +import { ToolButton } from "../components/ToolButton"; +import { t } from "../i18n"; +import { isImageElement } from "../element/typeChecks"; +import type { ExcalidrawImageElement } from "../element/types"; + +export const actionToggleCropEditor = register({ + name: "cropEditor", + label: "helpDialog.cropStart", + icon: cropIcon, + viewMode: true, + trackEvent: { category: "menu" }, + keywords: ["image", "crop"], + perform(elements, appState, _, app) { + const selectedElement = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + })[0] as ExcalidrawImageElement; + + return { + appState: { + ...appState, + isCropping: false, + croppingElementId: selectedElement.id, + }, + storeAction: StoreAction.CAPTURE, + }; + }, + predicate: (elements, appState, _, app) => { + const selectedElements = app.scene.getSelectedElements(appState); + if ( + !appState.croppingElementId && + selectedElements.length === 1 && + isImageElement(selectedElements[0]) + ) { + return true; + } + return false; + }, + PanelComponent: ({ appState, updateData, app }) => { + const label = t("helpDialog.cropStart"); + + return ( + updateData(null)} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index eff5de297bcc..a556bfbeaf5b 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -88,3 +88,5 @@ export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; + +export { actionToggleCropEditor } from "./actionCropEditor"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index bef3dfcacac7..9c5aa6d21511 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -134,7 +134,8 @@ export type ActionName = | "commandPalette" | "autoResize" | "elementStats" - | "searchMenu"; + | "searchMenu" + | "cropEditor"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index cb80c6cd891a..355bfe506313 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -116,6 +116,8 @@ export const getDefaultAppState = (): Omit< objectsSnapModeEnabled: false, userToFollow: null, followedBy: new Set(), + isCropping: false, + croppingElementId: null, searchMatches: [], }; }; @@ -237,6 +239,8 @@ const APP_STATE_STORAGE_CONF = (< objectsSnapModeEnabled: { browser: true, export: false, server: false }, userToFollow: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false }, + isCropping: { browser: false, export: false, server: false }, + croppingElementId: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false }, }); diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts index dc2964b2353b..1cbf39e89ac6 100644 --- a/packages/excalidraw/change.ts +++ b/packages/excalidraw/change.ts @@ -17,13 +17,16 @@ import { hasBoundTextElement, isBindableElement, isBoundToContainer, + isImageElement, isTextElement, } from "./element/typeChecks"; import type { ExcalidrawElement, + ExcalidrawImageElement, ExcalidrawLinearElement, ExcalidrawTextElement, NonDeleted, + Ordered, OrderedExcalidrawElement, SceneElementsMap, } from "./element/types"; @@ -626,6 +629,18 @@ export class AppStateChange implements Change { ); break; + case "croppingElementId": { + const croppingElementId = nextAppState[key]; + const element = + croppingElementId && nextElements.get(croppingElementId); + + if (element && !element.isDeleted) { + visibleDifferenceFlag.value = true; + } else { + nextAppState[key] = null; + } + break; + } case "editingGroupId": const editingGroupId = nextAppState[key]; @@ -756,6 +771,7 @@ export class AppStateChange implements Change { selectedElementIds, editingLinearElementId, selectedLinearElementId, + croppingElementId, ...standaloneProps } = delta as ObservedAppState; @@ -779,7 +795,10 @@ export class AppStateChange implements Change { } } -type ElementPartial = Omit, "seed">; +type ElementPartial = Omit< + ElementUpdate>, + "seed" +>; /** * Elements change is a low level primitive to capture a change between two sets of elements. @@ -1216,6 +1235,18 @@ export class ElementsChange implements Change { }); } + if (isImageElement(element)) { + const _delta = delta as Delta>; + // we want to override `crop` only if modified so that we don't reset + // when undoing/redoing unrelated change + if (_delta.deleted.crop || _delta.inserted.crop) { + Object.assign(directlyApplicablePartial, { + // apply change verbatim + crop: _delta.inserted.crop ?? null, + }); + } + } + if (!flags.containsVisibleDifference) { // strip away fractional as even if it would be different, it doesn't have to result in visible change const { index, ...rest } = directlyApplicablePartial; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index b818d1a23f1e..329be7501508 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -26,6 +26,7 @@ import { trackEvent } from "../analytics"; import { hasBoundTextElement, isElbowArrow, + isImageElement, isLinearElement, isTextElement, } from "../element/typeChecks"; @@ -127,6 +128,11 @@ export const SelectedShapeActions = ({ isLinearElement(targetElements[0]) && !isElbowArrow(targetElements[0]); + const showCropEditorAction = + !appState.croppingElementId && + targetElements.length === 1 && + isImageElement(targetElements[0]); + return (
@@ -245,6 +251,7 @@ export const SelectedShapeActions = ({ {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} {showLineEditorAction && renderAction("toggleLinearEditor")}
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f044a1d03550..fb0c24d91497 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -35,6 +35,7 @@ import { actionToggleElementLock, actionToggleLinearEditor, actionToggleObjectsSnapMode, + actionToggleCropEditor, } from "../actions"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { ActionManager } from "../actions/manager"; @@ -445,7 +446,19 @@ import { } from "../element/flowchart"; import { searchItemInFocusAtom } from "./SearchMenu"; import type { LocalPoint, Radians } from "../../math"; -import { pointFrom, pointDistance, vector } from "../../math"; +import { + clamp, + pointFrom, + pointDistance, + vector, + pointRotateRads, + vectorScale, + vectorFromPoint, + vectorSubtract, + vectorDot, + vectorNormalize, +} from "../../math"; +import { cropElement } from "../element/cropElement"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -589,6 +602,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(); @@ -3924,6 +3938,28 @@ class App extends React.Component { } if (!isInputLike(event.target)) { + if ( + (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && + this.state.croppingElementId + ) { + 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 @@ -4911,7 +4947,7 @@ class App extends React.Component { const selectionShape = getSelectionBoxShape( element, this.scene.getNonDeletedElementsMap(), - this.getElementHitThreshold(), + isImageElement(element) ? 0 : this.getElementHitThreshold(), ); return isPointInShape(pointFrom(x, y), selectionShape); @@ -5140,6 +5176,22 @@ class App extends React.Component { } }; + private startImageCropping = (image: ExcalidrawImageElement) => { + this.store.shouldCaptureIncrement(); + this.setState({ + croppingElementId: image.id, + }); + }; + + private finishImageCropping = () => { + if (this.state.croppingElementId) { + this.store.shouldCaptureIncrement(); + this.setState({ + croppingElementId: null, + }); + } + }; + private handleCanvasDoubleClick = ( event: React.MouseEvent, ) => { @@ -5171,6 +5223,11 @@ class App extends React.Component { } } + if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { + this.startImageCropping(selectedElements[0]); + return; + } + resetCursor(this.interactiveCanvas); let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( @@ -6740,11 +6797,24 @@ 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.croppingElementId) { + pointerDownState.resize.handleType = + elementWithTransformHandleType.transformHandleType; + } else { + this.setState({ + resizingElement: elementWithTransformHandleType.element, + }); + pointerDownState.resize.handleType = + elementWithTransformHandleType.transformHandleType; + } } } else if (selectedElements.length > 1) { pointerDownState.resize.handleType = getTransformHandleTypeFromCoords( @@ -6811,6 +6881,13 @@ class App extends React.Component { pointerDownState.origin.y, ); + if ( + this.state.croppingElementId && + pointerDownState.hit.element?.id !== this.state.croppingElementId + ) { + this.finishImageCropping(); + } + if (pointerDownState.hit.element) { // Early return if pointer is hitting link icon const hitLinkElement = this.getElementLinkAtPosition( @@ -7612,6 +7689,11 @@ class App extends React.Component { pointerDownState: PointerDownState, ) { return withBatchedUpdatesThrottled((event: PointerEvent) => { + const pointerCoords = viewportCoordsToSceneCoords(event, this.state); + const lastPointerCoords = + this.lastPointerMoveCoords ?? pointerDownState.origin; + this.lastPointerMoveCoords = pointerCoords; + // We need to initialize dragOffsetXY only after we've updated // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // event handler should hopefully ensure we're already working with @@ -7634,8 +7716,6 @@ class App extends React.Component { return; } - const pointerCoords = viewportCoordsToSceneCoords(event, this.state); - if (isEraserActive(this.state)) { this.handleEraser(event, pointerDownState, pointerCoords); return; @@ -7672,6 +7752,9 @@ class App extends React.Component { if (pointerDownState.resize.isResizing) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; + if (this.maybeHandleCrop(pointerDownState, event)) { + return true; + } if (this.maybeHandleResize(pointerDownState, event)) { return true; } @@ -7845,6 +7928,96 @@ class App extends React.Component { } } + // #region move crop region + if (this.state.croppingElementId) { + const croppingElement = this.scene + .getNonDeletedElementsMap() + .get(this.state.croppingElementId); + + if ( + croppingElement && + isImageElement(croppingElement) && + croppingElement.crop !== null && + pointerDownState.hit.element === croppingElement + ) { + const crop = croppingElement.crop; + const image = + isInitializedImageElement(croppingElement) && + this.imageCache.get(croppingElement.fileId)?.image; + + if (image && !(image instanceof Promise)) { + const instantDragOffset = vectorScale( + vector( + pointerCoords.x - lastPointerCoords.x, + pointerCoords.y - lastPointerCoords.y, + ), + Math.max(this.state.zoom.value, 2), + ); + + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + croppingElement, + elementsMap, + ); + + const topLeft = vectorFromPoint( + pointRotateRads( + pointFrom(x1, y1), + pointFrom(cx, cy), + croppingElement.angle, + ), + ); + const topRight = vectorFromPoint( + pointRotateRads( + pointFrom(x2, y1), + pointFrom(cx, cy), + croppingElement.angle, + ), + ); + const bottomLeft = vectorFromPoint( + pointRotateRads( + pointFrom(x1, y2), + pointFrom(cx, cy), + croppingElement.angle, + ), + ); + const topEdge = vectorNormalize( + vectorSubtract(topRight, topLeft), + ); + const leftEdge = vectorNormalize( + vectorSubtract(bottomLeft, topLeft), + ); + + // project instantDrafOffset onto leftEdge and topEdge to decompose + const offsetVector = vector( + vectorDot(instantDragOffset, topEdge), + vectorDot(instantDragOffset, leftEdge), + ); + + const nextCrop = { + ...crop, + x: clamp( + crop.x - + offsetVector[0] * Math.sign(croppingElement.scale[0]), + 0, + image.naturalWidth - crop.width, + ), + y: clamp( + crop.y - + offsetVector[1] * Math.sign(croppingElement.scale[1]), + 0, + image.naturalHeight - crop.height, + ), + }; + + mutateElement(croppingElement, { + crop: nextCrop, + }); + + 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. @@ -7978,6 +8151,7 @@ class App extends React.Component { this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true); } + return; } } @@ -8226,15 +8400,18 @@ class App extends React.Component { const { newElement, resizingElement, + croppingElementId, multiElement, activeTool, isResizing, isRotating, + isCropping, } = this.state; this.setState((prevState) => ({ isResizing: false, isRotating: false, + isCropping: false, resizingElement: null, selectionElement: null, frameToHighlight: null, @@ -8244,6 +8421,8 @@ class App extends React.Component { originSnapOffset: null, })); + this.lastPointerMoveCoords = null; + SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -8726,6 +8905,20 @@ class App extends React.Component { } } + // click outside the cropping region to exit + if ( + // not in the cropping mode at all + !croppingElementId || + // in the cropping mode + (croppingElementId && + // not cropping and no hit element + ((!hitElement && !isCropping) || + // hitting something else + (hitElement && hitElement.id !== croppingElementId))) + ) { + this.finishImageCropping(); + } + const pointerStart = this.lastPointerDownEvent; const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; @@ -8981,7 +9174,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 @@ -9195,7 +9393,7 @@ class App extends React.Component { /** * inserts image into elements array and rerenders */ - private insertImageElement = async ( + insertImageElement = async ( imageElement: ExcalidrawImageElement, imageFile: File, showCursorImagePreview?: boolean, @@ -9348,7 +9546,7 @@ class App extends React.Component { } }; - private initializeImageDimensions = ( + initializeImageDimensions = ( imageElement: ExcalidrawImageElement, forceNaturalSize = false, ) => { @@ -9396,7 +9594,13 @@ 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, + crop: null, + }); } }; @@ -9935,6 +10139,83 @@ class App extends React.Component { } }; + private maybeHandleCrop = ( + pointerDownState: PointerDownState, + event: MouseEvent | KeyboardEvent, + ): boolean => { + // to crop, we must already be in the cropping mode, where croppingElement has been set + if (!this.state.croppingElementId) { + return false; + } + + const transformHandleType = pointerDownState.resize.handleType; + const pointerCoords = pointerDownState.lastCoords; + const [x, y] = getGridPoint( + pointerCoords.x - pointerDownState.resize.offset.x, + pointerCoords.y - pointerDownState.resize.offset.y, + this.getEffectiveGridSize(), + ); + + const croppingElement = this.scene + .getNonDeletedElementsMap() + .get(this.state.croppingElementId); + + if ( + transformHandleType && + croppingElement && + isImageElement(croppingElement) + ) { + const croppingAtStateStart = pointerDownState.originalElements.get( + croppingElement.id, + ); + + const image = + isInitializedImageElement(croppingElement) && + this.imageCache.get(croppingElement.fileId)?.image; + + if ( + croppingAtStateStart && + isImageElement(croppingAtStateStart) && + image && + !(image instanceof Promise) + ) { + mutateElement( + croppingElement, + cropElement( + croppingElement, + transformHandleType, + image.naturalWidth, + image.naturalHeight, + x, + y, + event.shiftKey + ? croppingAtStateStart.width / croppingAtStateStart.height + : undefined, + ), + ); + + updateBoundElements( + croppingElement, + this.scene.getNonDeletedElementsMap(), + { + oldSize: { + width: croppingElement.width, + height: croppingElement.height, + }, + }, + ); + + this.setState({ + isCropping: transformHandleType && transformHandleType !== "rotation", + }); + } + + return true; + } + + return false; + }; + private maybeHandleResize = ( pointerDownState: PointerDownState, event: MouseEvent | KeyboardEvent, @@ -9951,7 +10232,9 @@ class App extends React.Component { // Frames cannot be rotated. (selectedFrames.length > 0 && transformHandleType === "rotation") || // Elbow arrows cannot be transformed (resized or rotated). - (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) + (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) || + // Do not resize when in crop mode + this.state.croppingElementId ) { return false; } @@ -10126,6 +10409,8 @@ class App extends React.Component { actionSelectAllElementsInFrame, actionRemoveAllElementsFromFrame, CONTEXT_MENU_SEPARATOR, + actionToggleCropEditor, + CONTEXT_MENU_SEPARATOR, ...options, CONTEXT_MENU_SEPARATOR, actionCopyStyles, diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index e732acfb56c5..9333c8f65444 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -279,6 +279,7 @@ function CommandPaletteInner({ actionManager.actions.increaseFontSize, actionManager.actions.decreaseFontSize, actionManager.actions.toggleLinearEditor, + actionManager.actions.cropEditor, actionLink, ].map((action: Action) => actionToCommand( diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 886c7bb7a16e..577d8f187d9a 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { ]} isOr={false} /> + + , tablerIconProps, ); + +export const cropIcon = createIcon( + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index a8d966e585b8..0aa6e0f498d0 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -258,6 +258,7 @@ const restoreElement = ( status: element.status || "pending", fileId: element.fileId, scale: element.scale || [1, 1], + crop: element.crop ?? null, }); case "line": // @ts-ignore LEGACY type diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts new file mode 100644 index 000000000000..da88df49920b --- /dev/null +++ b/packages/excalidraw/element/cropElement.ts @@ -0,0 +1,587 @@ +import { type Point } from "points-on-curve"; +import { + type Radians, + pointFrom, + pointCenter, + pointRotateRads, + vectorFromPoint, + vectorNormalize, + vectorSubtract, + vectorAdd, + vectorScale, + pointFromVector, + clamp, + isCloseTo, +} from "../../math"; +import type { TransformHandleType } from "./transformHandles"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawImageElement, + ImageCrop, + NonDeleted, +} from "./types"; +import { + getElementAbsoluteCoords, + getResizedElementAbsoluteCoords, +} from "./bounds"; + +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, + 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, + }; +}; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 5775f0eb7456..f773a7a06e8e 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -16,6 +16,7 @@ import { isArrowElement, isElbowArrow, isFrameLikeElement, + isImageElement, isTextElement, } from "./typeChecks"; import { getFontString } from "../utils"; @@ -251,6 +252,14 @@ export const dragNewElement = ({ } if (width !== 0 && height !== 0) { + let imageInitialDimension = null; + if (isImageElement(newElement)) { + imageInitialDimension = { + initialWidth: width, + initialHeight: height, + }; + } + mutateElement( newElement, { @@ -259,6 +268,7 @@ export const dragNewElement = ({ width, height, ...textAutoResize, + ...imageInitialDimension, }, informMutation, ); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index aa02cc1453cf..55aa011f7266 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -477,6 +477,7 @@ export const newImageElement = ( status?: ExcalidrawImageElement["status"]; fileId?: ExcalidrawImageElement["fileId"]; scale?: ExcalidrawImageElement["scale"]; + crop?: ExcalidrawImageElement["crop"]; } & ElementConstructorOpts, ): NonDeleted => { return { @@ -487,6 +488,7 @@ export const newImageElement = ( status: opts.status ?? "pending", fileId: opts.fileId ?? null, scale: opts.scale ?? [1, 1], + crop: opts.crop ?? null, }; }; diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 5fcae5335928..c00586db320a 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -20,7 +20,7 @@ import type { AppState, Device, Zoom } from "../types"; import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { SIDE_RESIZING_THRESHOLD } from "../constants"; -import { isLinearElement } from "./typeChecks"; +import { isImageElement, isLinearElement } from "./typeChecks"; import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; import { pointFrom, @@ -90,7 +90,11 @@ export const resizeTest = ( // do not resize from the sides for linear elements with only two points if (!(isLinearElement(element) && element.points.length <= 2)) { - const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; + const SPACING = isImageElement(element) + ? 0 + : SIDE_RESIZING_THRESHOLD / zoom.value; + const ZOOMED_SIDE_RESIZING_THRESHOLD = + SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( pointFrom(x1 - SPACING, y1 - SPACING), pointFrom(x2 + SPACING, y2 + SPACING), @@ -104,7 +108,7 @@ export const resizeTest = ( pointOnLineSegment( pointFrom(x, y), side as LineSegment, - SPACING, + ZOOMED_SIDE_RESIZING_THRESHOLD, ) ) { return dir as TransformHandleType; diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index ccd68b2828e2..77a83e3e1e01 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -11,6 +11,7 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import { isElbowArrow, isFrameLikeElement, + isImageElement, isLinearElement, } from "./typeChecks"; import { @@ -129,6 +130,7 @@ export const getTransformHandlesFromCoords = ( pointerType: PointerType, omitSides: { [T in TransformHandleType]?: boolean } = {}, margin = 4, + spacing = DEFAULT_TRANSFORM_HANDLE_SPACING, ): TransformHandles => { const size = transformHandleSizes[pointerType]; const handleWidth = size / zoom.value; @@ -140,8 +142,7 @@ export const getTransformHandlesFromCoords = ( const width = x2 - x1; const height = y2 - y1; const dashedLineMargin = margin / zoom.value; - const centeringOffset = - (size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value); + const centeringOffset = (size - spacing * 2) / (2 * zoom.value); const transformHandles: TransformHandles = { nw: omitSides.nw @@ -301,8 +302,10 @@ export const getTransformHandles = ( rotation: true, }; } - const dashedLineMargin = isLinearElement(element) + const margin = isLinearElement(element) ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 + : isImageElement(element) + ? 0 : DEFAULT_TRANSFORM_HANDLE_SPACING; return getTransformHandlesFromCoords( getElementAbsoluteCoords(element, elementsMap, true), @@ -310,7 +313,8 @@ export const getTransformHandles = ( zoom, pointerType, omitSides, - dashedLineMargin, + margin, + isImageElement(element) ? 0 : undefined, ); }; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 5ebf505444a1..c2cce533a641 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -132,6 +132,15 @@ export type IframeData = | { type: "document"; srcdoc: (theme: Theme) => string } ); +export type ImageCrop = { + x: number; + y: number; + width: number; + height: number; + naturalWidth: number; + naturalHeight: number; +}; + export type ExcalidrawImageElement = _ExcalidrawElementBase & Readonly<{ type: "image"; @@ -140,6 +149,8 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase & status: "pending" | "saved" | "error"; /** X and Y scale factors <-1, 1>, used for image axis flipping */ scale: [number, number]; + /** whether an element is cropped */ + crop: ImageCrop | null; }>; export type InitializedExcalidrawImageElement = MarkNonNullable< diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b6c9244f3a7a..d9115cfce66e 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -328,7 +328,9 @@ "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging", "eraserRevert": "Hold Alt to revert the elements marked for deletion", "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.", - "disableSnapping": "Hold CtrlOrCmd to disable snapping" + "disableSnapping": "Hold CtrlOrCmd to disable snapping", + "enterCropEditor": "Double click the image or press ENTER to crop the image", + "leaveCropEditor": "Click outside the image or press ENTER or ESCAPE to finish cropping" }, "canvasError": { "cannotShowPreview": "Cannot show preview", @@ -399,7 +401,9 @@ "zoomToSelection": "Zoom to selection", "toggleElementLock": "Lock/unlock selection", "movePageUpDown": "Move page up/down", - "movePageLeftRight": "Move page left/right" + "movePageLeftRight": "Move page left/right", + "cropStart": "Crop image", + "cropFinish": "Finish image cropping" }, "clearCanvasDialog": { "title": "Clear canvas" diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 7dc84db9967e..f8ca0e366887 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -54,6 +54,7 @@ import oc from "open-color"; import { isElbowArrow, isFrameLikeElement, + isImageElement, isLinearElement, isTextElement, } from "../element/typeChecks"; @@ -62,6 +63,7 @@ import type { ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, + ExcalidrawImageElement, ExcalidrawLinearElement, ExcalidrawTextElement, GroupId, @@ -307,38 +309,42 @@ const renderBindingHighlightForSuggestedPointBinding = ( }); }; +type ElementSelectionBorder = { + angle: number; + x1: number; + y1: number; + x2: number; + y2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + padding?: number; +}; + const renderSelectionBorder = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, - elementProperties: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }, + elementProperties: ElementSelectionBorder, ) => { const { angle, - elementX1, - elementY1, - elementX2, - elementY2, + x1, + y1, + x2, + y2, selectionColors, cx, cy, dashed, activeEmbeddable, } = elementProperties; - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; + const elementWidth = x2 - x1; + const elementHeight = y2 - y1; - const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2; + const padding = + elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2; const linePadding = padding / appState.zoom.value; const lineWidth = 8 / appState.zoom.value; @@ -360,8 +366,8 @@ const renderSelectionBorder = ( context.lineDashOffset = (lineWidth + spaceWidth) * index; strokeRectWithRotation( context, - elementX1 - linePadding, - elementY1 - linePadding, + x1 - linePadding, + y1 - linePadding, elementWidth + linePadding * 2, elementHeight + linePadding * 2, cx, @@ -433,18 +439,17 @@ const renderElementsBoxHighlight = ( ); const getSelectionFromElements = (elements: ExcalidrawElement[]) => { - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(elements); + const [x1, y1, x2, y2] = getCommonBounds(elements); return { angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, + x1, + x2, + y1, + y2, selectionColors: ["rgb(0,118,255)"], dashed: false, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, + cx: x1 + (x2 - x1) / 2, + cy: y1 + (y2 - y1) / 2, activeEmbeddable: false, }; }; @@ -594,6 +599,111 @@ const renderTransformHandles = ( }); }; +const renderCropHandles = ( + context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, + appState: InteractiveCanvasAppState, + croppingElement: ExcalidrawImageElement, + elementsMap: ElementsMap, +): void => { + const [x1, y1, , , cx, cy] = getElementAbsoluteCoords( + croppingElement, + elementsMap, + ); + + const LINE_WIDTH = 3; + const LINE_LENGTH = 20; + + const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value; + const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2; + + const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH; + const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH; + + const HORIZONTAL_LINE_LENGTH = Math.min( + LINE_LENGTH / appState.zoom.value, + HALF_WIDTH, + ); + const VERTICAL_LINE_LENGTH = Math.min( + LINE_LENGTH / appState.zoom.value, + HALF_HEIGHT, + ); + + context.save(); + context.fillStyle = renderConfig.selectionColor; + context.strokeStyle = renderConfig.selectionColor; + context.lineWidth = ZOOMED_LINE_WIDTH; + + const handles: Array< + [ + [number, number], + [number, number], + [number, number], + [number, number], + [number, number], + ] + > = [ + [ + // x, y + [-HALF_WIDTH, -HALF_HEIGHT], + // horizontal line: first start and to + [0, ZOOMED_HALF_LINE_WIDTH], + [HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH], + // vertical line: second start and to + [ZOOMED_HALF_LINE_WIDTH, 0], + [ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH], + ], + [ + [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT], + [ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH], + [ + -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH, + ZOOMED_HALF_LINE_WIDTH, + ], + [0, 0], + [0, VERTICAL_LINE_LENGTH], + ], + [ + [-HALF_WIDTH, HALF_HEIGHT], + [0, -ZOOMED_HALF_LINE_WIDTH], + [HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH], + [ZOOMED_HALF_LINE_WIDTH, 0], + [ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH], + ], + [ + [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT], + [ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH], + [ + -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH, + -ZOOMED_HALF_LINE_WIDTH, + ], + [0, 0], + [0, -VERTICAL_LINE_LENGTH], + ], + ]; + + handles.forEach((handle) => { + const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle; + + context.save(); + context.translate(cx, cy); + context.rotate(croppingElement.angle); + + context.beginPath(); + context.moveTo(x + x1s, y + y1s); + context.lineTo(x + x1t, y + y1t); + context.stroke(); + + context.beginPath(); + context.moveTo(x + x2s, y + y2s); + context.lineTo(x + x2t, y + y2t); + context.stroke(); + context.restore(); + }); + + context.restore(); +}; + const renderTextBox = ( text: NonDeleted, context: CanvasRenderingContext2D, @@ -671,7 +781,7 @@ const _renderInteractiveScene = ({ } // Paint selection element - if (appState.selectionElement) { + if (appState.selectionElement && !appState.isCropping) { try { renderSelectionElement( appState.selectionElement, @@ -783,18 +893,7 @@ const _renderInteractiveScene = ({ // Optimisation for finding quickly relevant element ids const locallySelectedIds = arrayToMap(selectedElements); - const selections: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }[] = []; + const selections: ElementSelectionBorder[] = []; for (const element of elementsMap.values()) { const selectionColors = []; @@ -833,14 +932,17 @@ const _renderInteractiveScene = ({ } if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, elementsMap, true); + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); selections.push({ angle: element.angle, - elementX1, - elementY1, - elementX2, - elementY2, + x1, + y1, + x2, + y2, selectionColors, dashed: !!remoteClients, cx, @@ -848,24 +950,28 @@ const _renderInteractiveScene = ({ activeEmbeddable: appState.activeEmbeddable?.element === element && appState.activeEmbeddable.state === "active", + padding: + element.id === appState.croppingElementId || + isImageElement(element) + ? 0 + : undefined, }); } } const addSelectionForGroupId = (groupId: GroupId) => { const groupElements = getElementsInGroup(elementsMap, groupId); - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(groupElements); + const [x1, y1, x2, y2] = getCommonBounds(groupElements); selections.push({ angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, + x1, + x2, + y1, + y2, selectionColors: [oc.black], dashed: true, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, + cx: x1 + (x2 - x1) / 2, + cy: y1 + (y2 - y1) / 2, activeEmbeddable: false, }); }; @@ -900,7 +1006,9 @@ const _renderInteractiveScene = ({ !appState.viewModeEnabled && showBoundingBox && // do not show transform handles when text is being edited - !isTextElement(appState.editingTextElement) + !isTextElement(appState.editingTextElement) && + // do not show transform handles when image is being cropped + !appState.croppingElementId ) { renderTransformHandles( context, @@ -910,6 +1018,20 @@ const _renderInteractiveScene = ({ selectedElements[0].angle, ); } + + if (appState.croppingElementId && !appState.isCropping) { + const croppingElement = elementsMap.get(appState.croppingElementId); + + if (croppingElement && isImageElement(croppingElement)) { + renderCropHandles( + context, + renderConfig, + appState, + croppingElement, + elementsMap, + ); + } + } } else if (selectedElements.length > 1 && !appState.isRotating) { const dashedLinePadding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 9995c748ac6e..cc0c58c17bd6 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -17,6 +17,7 @@ import { isArrowElement, hasBoundTextElement, isMagicFrameElement, + isImageElement, } from "../element/typeChecks"; import { getElementAbsoluteCoords } from "../element/bounds"; import type { RoughCanvas } from "roughjs/bin/canvas"; @@ -61,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache"; import { getVerticalOffset } from "../fonts"; import { isRightAngleRads } from "../../math"; import { getCornerRadius } from "../shapes"; +import { getUncroppedImageElement } from "../element/cropElement"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original @@ -434,8 +436,22 @@ const drawElementOnCanvas = ( ); context.clip(); } + + const { x, y, width, height } = element.crop + ? element.crop + : { + x: 0, + y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + }; + context.drawImage( img, + x, + y, + width, + height, 0 /* hardcoded for the selection box*/, 0, element.width, @@ -921,14 +937,53 @@ export const renderElement = ( context.imageSmoothingEnabled = false; } - drawElementFromCanvas( - elementWithCanvas, - context, + if ( + element.id === appState.croppingElementId && + isImageElement(elementWithCanvas.element) && + elementWithCanvas.element.crop !== null + ) { + context.save(); + context.globalAlpha = 0.1; + + const uncroppedElementCanvas = generateElementCanvas( + getUncroppedImageElement(elementWithCanvas.element, elementsMap), + allElementsMap, + appState.zoom, + renderConfig, + appState, + ); + + if (uncroppedElementCanvas) { + drawElementFromCanvas( + uncroppedElementCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + } + + context.restore(); + } + + const _elementWithCanvas = generateElementCanvas( + elementWithCanvas.element, + allElementsMap, + appState.zoom, renderConfig, appState, - allElementsMap, ); + if (_elementWithCanvas) { + drawElementFromCanvas( + _elementWithCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + } + // reset context.imageSmoothingEnabled = currentImageSmoothingStatus; } diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index f0bf98967001..f4781c0ce6cb 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -37,6 +37,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getVerticalOffset } from "../fonts"; import { getCornerRadius, isPathALoop } from "../shapes"; +import { getUncroppedWidthAndHeight } from "../element/cropElement"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, @@ -417,12 +418,28 @@ const renderElementToSvg = ( symbol.id = symbolId; const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); - - image.setAttribute("width", "100%"); - image.setAttribute("height", "100%"); image.setAttribute("href", fileData.dataURL); image.setAttribute("preserveAspectRatio", "none"); + if (element.crop) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + + symbol.setAttribute( + "viewBox", + `${ + element.crop.x / (element.crop.naturalWidth / uncroppedWidth) + } ${ + element.crop.y / (element.crop.naturalHeight / uncroppedHeight) + } ${width} ${height}`, + ); + image.setAttribute("width", `${uncroppedWidth}`); + image.setAttribute("height", `${uncroppedHeight}`); + } else { + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + } + symbol.appendChild(image); root.prepend(symbol); diff --git a/packages/excalidraw/store.ts b/packages/excalidraw/store.ts index 8e934ccba695..b3e2713c426a 100644 --- a/packages/excalidraw/store.ts +++ b/packages/excalidraw/store.ts @@ -21,6 +21,7 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => { selectedGroupIds: appState.selectedGroupIds, editingLinearElementId: appState.editingLinearElement?.elementId || null, selectedLinearElementId: appState.selectedLinearElement?.elementId || null, + croppingElementId: appState.croppingElementId, }; Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 3a5e1406585e..afd6bd2b7a75 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -116,6 +116,50 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, }, "separator", + { + "PanelComponent": [Function], + "icon": , + "keywords": [ + "image", + "crop", + ], + "label": "helpDialog.cropStart", + "name": "cropEditor", + "perform": [Function], + "predicate": [Function], + "trackEvent": { + "category": "menu", + }, + "viewMode": true, + }, + "separator", { "icon":