diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index d546bc9d55..da79dcc9d3 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -203,6 +203,8 @@ const getRelevantAppStateProps = ( snapLines: appState.snapLines, zenModeEnabled: appState.zenModeEnabled, editingTextElement: appState.editingTextElement, + isCropping: appState.isCropping, + croppingElement: appState.croppingElement, }); const areEqual = ( diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index ad19afdd80..01ded2988b 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -107,6 +107,8 @@ const getRelevantAppStateProps = ( frameToHighlight: appState.frameToHighlight, editingGroupId: appState.editingGroupId, currentHoveredFontFamily: appState.currentHoveredFontFamily, + isCropping: appState.isCropping, + croppingElement: appState.croppingElement, }); const areEqual = ( diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 5a27a3312d..3eb05a1d78 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -59,6 +59,7 @@ import type { ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, + ExcalidrawImageElement, ExcalidrawLinearElement, ExcalidrawTextElement, GroupId, @@ -591,6 +592,96 @@ const renderTransformHandles = ( }); }; +const renderCropHandles = ( + context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, + appState: InteractiveCanvasAppState, + croppingElement: ExcalidrawImageElement, + elementsMap: ElementsMap, +): void => { + const lineWidth = 3 / appState.zoom.value; + const length = 15 / appState.zoom.value; + + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + croppingElement, + elementsMap, + ); + const halfWidth = + cx - x1 + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + const halfHeight = + cy - y1 + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + + context.save(); + context.fillStyle = renderConfig.selectionColor; + context.strokeStyle = renderConfig.selectionColor; + context.lineWidth = lineWidth; + + const halfLineWidth = lineWidth / 2; + + const handles: Array< + [ + [number, number], + [number, number], + [number, number], + [number, number], + [number, number], + ] + > = [ + [ + // x, y + [-halfWidth, -halfHeight], + // first start and t0 + [0, halfLineWidth], + [length, halfLineWidth], + // second start and to + [halfLineWidth, 0], + [halfLineWidth, length - halfLineWidth], + ], + [ + [halfWidth - halfLineWidth, -halfHeight + halfLineWidth], + [halfLineWidth, 0], + [-length + halfLineWidth, 0], + [0, -halfLineWidth], + [0, length - lineWidth], + ], + [ + [-halfWidth, halfHeight], + [0, -halfLineWidth], + [length, -halfLineWidth], + [halfLineWidth, 0], + [halfLineWidth, -length + halfLineWidth], + ], + [ + [halfWidth - halfLineWidth, halfHeight - halfLineWidth], + [halfLineWidth, 0], + [-length + halfLineWidth, 0], + [0, halfLineWidth], + [0, -length + lineWidth], + ], + ]; + + 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, @@ -898,7 +989,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.croppingElement ) { renderTransformHandles( context, @@ -908,6 +1001,16 @@ const _renderInteractiveScene = ({ selectedElements[0].angle, ); } + + if (appState.isCropping && appState.croppingElement) { + renderCropHandles( + context, + renderConfig, + appState, + 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 9995c748ac..c0fdfb49f4 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,26 @@ const drawElementOnCanvas = ( ); context.clip(); } + + // TODO: check why only croppingElement has the latest update + const { x, y, width, height } = element.crop + ? element.crop + : element === appState.croppingElement && + appState.croppingElement.crop + ? appState.croppingElement.crop + : { + x: 0, + y: 0, + width: element.naturalWidth, + height: element.naturalHeight, + }; + context.drawImage( img, + x, + y, + width, + height, 0 /* hardcoded for the selection box*/, 0, element.width, @@ -921,14 +941,53 @@ export const renderElement = ( context.imageSmoothingEnabled = false; } - drawElementFromCanvas( - elementWithCanvas, - context, + if ( + element.id === appState.croppingElement?.id && + 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/types.ts b/packages/excalidraw/types.ts index 1fb62c9d43..631d250d65 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -176,6 +176,9 @@ export type StaticCanvasAppState = Readonly< gridStep: AppState["gridStep"]; frameRendering: AppState["frameRendering"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; + // Cropping + isCropping: AppState["isCropping"]; + croppingElement: AppState["croppingElement"]; } >; @@ -198,6 +201,9 @@ export type InteractiveCanvasAppState = Readonly< snapLines: AppState["snapLines"]; zenModeEnabled: AppState["zenModeEnabled"]; editingTextElement: AppState["editingTextElement"]; + // Cropping + isCropping: AppState["isCropping"]; + croppingElement: AppState["croppingElement"]; } >; @@ -671,6 +677,12 @@ export type PointerDownState = Readonly<{ // This is a center point of selected elements determined on the initial pointer down event (for rotation only) center: { x: number; y: number }; }; + crop: { + handleType: MaybeTransformHandleType; + isCropping: boolean; + offset: { x: number; y: number }; + complete: boolean; + }; hit: { // The element the pointer is "hitting", is determined on the initial // pointer down event