feat: image cropping (#8613)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-10-22 04:26:52 +08:00 committed by GitHub
parent eb09b48ae6
commit e957c8e9ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2199 additions and 92 deletions

View file

@ -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>

View file

@ -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,

View file

@ -279,6 +279,7 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
].map((action: Action) =>
actionToCommand(

View file

@ -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")}

View file

@ -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 &&

View file

@ -203,6 +203,8 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
});

View file

@ -107,6 +107,7 @@ const getRelevantAppStateProps = (
frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
});
const areEqual = (

View file

@ -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,
);