mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
eb09b48ae6
commit
e957c8e9ee
36 changed files with 2199 additions and 92 deletions
|
@ -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 (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
|
@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
|
|||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showCropEditorAction && renderAction("cropEditor")}
|
||||
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
@ -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<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
@ -589,6 +602,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | 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<AppProps, AppState> {
|
|||
}
|
||||
|
||||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
}
|
||||
};
|
||||
|
||||
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<HTMLCanvasElement>,
|
||||
) => {
|
||||
|
@ -5171,6 +5223,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
// #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<AppProps, AppState> {
|
|||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -8226,15 +8400,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
originSnapOffset: null,
|
||||
}));
|
||||
|
||||
this.lastPointerMoveCoords = null;
|
||||
|
||||
SnapCache.setReferenceSnapPoints(null);
|
||||
SnapCache.setVisibleGaps(null);
|
||||
|
||||
|
@ -8726,6 +8905,20 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
/**
|
||||
* 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<AppProps, AppState> {
|
|||
}
|
||||
};
|
||||
|
||||
private initializeImageDimensions = (
|
||||
initializeImageDimensions = (
|
||||
imageElement: ExcalidrawImageElement,
|
||||
forceNaturalSize = false,
|
||||
) => {
|
||||
|
@ -9396,7 +9594,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||
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<AppProps, AppState> {
|
|||
}
|
||||
};
|
||||
|
||||
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<AppProps, AppState> {
|
|||
// 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<AppProps, AppState> {
|
|||
actionSelectAllElementsInFrame,
|
||||
actionRemoveAllElementsFromFrame,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleCropEditor,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
...options,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionCopyStyles,
|
||||
|
|
|
@ -279,6 +279,7 @@ function CommandPaletteInner({
|
|||
actionManager.actions.increaseFontSize,
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionManager.actions.cropEditor,
|
||||
actionLink,
|
||||
].map((action: Action) =>
|
||||
actionToCommand(
|
||||
|
|
|
@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropStart")}
|
||||
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropFinish")}
|
||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
|
|
|
@ -100,6 +100,14 @@ const getHints = ({
|
|||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (appState.croppingElementId) {
|
||||
return t("hints.leaveCropEditor");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
return t("hints.enterCropEditor");
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.selectionElement &&
|
||||
|
|
|
@ -203,6 +203,8 @@ const getRelevantAppStateProps = (
|
|||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingTextElement: appState.editingTextElement,
|
||||
isCropping: appState.isCropping,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ const getRelevantAppStateProps = (
|
|||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
currentHoveredFontFamily: appState.currentHoveredFontFamily,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
|
|
@ -2147,3 +2147,12 @@ export const upIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const cropIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
|
||||
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue