Compare commits

...

3 Commits

Author SHA1 Message Date
Ryan Di
c79893608a feat: experiment with a diff shift locking behavior 2025-07-07 18:09:18 +10:00
Ryan Di
a2cf15db9c fix: setting states correctly for cropping 2025-07-02 15:25:32 +10:00
Ryan Di
8d3195e350 feat: move by uncropped area too 2025-06-18 00:16:44 +10:00
3 changed files with 301 additions and 107 deletions

View File

@ -124,6 +124,11 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
cropPositionMovement: {
enabled: false,
croppingElementId: undefined,
directionLock: null,
},
};
};
@ -249,6 +254,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
cropPositionMovement: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -191,6 +191,7 @@ import {
FlowChartNavigator,
getLinkDirectionFromKey,
cropElement,
getUncroppedImageElement,
wrapText,
isElementLink,
parseElementLinkFromURL,
@ -460,6 +461,7 @@ import type {
} from "../types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Action, ActionResult } from "../actions/types";
import type { GlobalPoint } from "@excalidraw/math";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -6541,6 +6543,44 @@ class App extends React.Component<AppProps, AppState> {
this.clearSelectionIfNotUsingSelection();
this.updateBindingEnabledOnPointerMove(event);
// Check if we're in crop mode and hitting uncropped area - if so, skip selection handling
if (this.state.croppingElementId) {
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (
croppingElement &&
isImageElement(croppingElement) &&
croppingElement.crop
) {
const uncroppedElement = getUncroppedImageElement(
croppingElement,
this.scene.getNonDeletedElementsMap(),
);
const hitUncroppedArea = hitElementItself({
point: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
element: uncroppedElement,
threshold: this.getElementHitThreshold(uncroppedElement),
elementsMap: this.scene.getNonDeletedElementsMap(),
});
if (hitUncroppedArea) {
// Set a dedicated flag for crop position movement
pointerDownState.cropPositionMovement.enabled = true;
pointerDownState.cropPositionMovement.croppingElementId =
croppingElement.id;
// Set isCropping state to true so crop mode UI stays active
this.setState({
isCropping: true,
});
}
}
}
if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
return;
}
@ -6952,6 +6992,11 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: {
hasOccurred: false,
},
cropPositionMovement: {
enabled: false,
croppingElementId: undefined,
directionLock: null,
},
};
}
@ -7176,11 +7221,39 @@ class App extends React.Component<AppProps, AppState> {
return true;
}
if (
this.state.croppingElementId &&
pointerDownState.hit.element?.id !== this.state.croppingElementId
) {
this.finishImageCropping();
if (this.state.croppingElementId) {
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);
if (croppingElement) {
const uncroppedElement = getUncroppedImageElement(
croppingElement as any,
this.scene.getNonDeletedElementsMap(),
);
const hitUncroppedArea = hitElementItself({
point: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
element: uncroppedElement,
threshold: this.getElementHitThreshold(uncroppedElement),
elementsMap: this.scene.getNonDeletedElementsMap(),
});
if (!hitUncroppedArea) {
this.finishImageCropping();
} else {
// ensure the image remains selected so crop handles are rendered
if (
(!this.state.selectedElementIds ||
Object.keys(this.state.selectedElementIds).length === 0) &&
this.state.croppingElementId
) {
this.setState({
selectedElementIds: { [this.state.croppingElementId]: true },
});
}
}
}
}
if (pointerDownState.hit.element) {
@ -7206,6 +7279,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.allHitElements.some((element) =>
this.isASelectedElement(element),
);
if (
(hitElement === null || !someHitElementIsSelected) &&
!event.shiftKey &&
@ -8029,6 +8103,165 @@ class App extends React.Component<AppProps, AppState> {
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
// #region dedicated crop position movement
if (
pointerDownState.cropPositionMovement.enabled &&
pointerDownState.cropPositionMovement.croppingElementId
) {
const croppingElement = pointerDownState.cropPositionMovement
.croppingElementId
? this.scene
.getNonDeletedElementsMap()
.get(pointerDownState.cropPositionMovement.croppingElementId)
: null;
if (
croppingElement &&
isImageElement(croppingElement) &&
croppingElement.crop
) {
const transformHandleType = pointerDownState.resize.handleType;
if (!transformHandleType) {
const crop = croppingElement.crop;
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) {
// calculate total drag offset from the original pointer down position
const totalDragOffset = {
x: pointerCoords.x - pointerDownState.origin.x,
y: pointerCoords.y - pointerDownState.origin.y,
};
// apply shift key constraint for directional movement
const threshold = 20;
let snappingToOrigin = false;
if (event.shiftKey) {
if (!pointerDownState.cropPositionMovement.directionLock) {
if (
Math.abs(totalDragOffset.x) > threshold ||
Math.abs(totalDragOffset.y) > threshold
) {
pointerDownState.cropPositionMovement.directionLock =
Math.abs(totalDragOffset.x) > Math.abs(totalDragOffset.y)
? "x"
: "y";
} else {
// if within threshold and not locked, always snap to origin
snappingToOrigin = true;
}
} else {
// if user moves back within threshold, unlock and snap back
if (
Math.abs(totalDragOffset.x) < threshold &&
Math.abs(totalDragOffset.y) < threshold
) {
pointerDownState.cropPositionMovement.directionLock = null;
snappingToOrigin = true;
}
}
} else {
pointerDownState.cropPositionMovement.directionLock = null;
}
if (snappingToOrigin) {
totalDragOffset.x = 0;
totalDragOffset.y = 0;
}
if (pointerDownState.cropPositionMovement.directionLock === "x") {
totalDragOffset.y = 0;
} else if (
pointerDownState.cropPositionMovement.directionLock === "y"
) {
totalDragOffset.x = 0;
}
// scale the drag offset
const scaledDragOffset = vectorScale(
vector(totalDragOffset.x, totalDragOffset.y),
Math.max(this.state.zoom.value, 2),
);
const elementsMap = this.scene.getNonDeletedElementsMap();
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 scaledDragOffset onto leftEdge and topEdge to decompose
const offsetVector = vector(
vectorDot(scaledDragOffset, topEdge),
vectorDot(scaledDragOffset, leftEdge),
);
// get the original crop from when the drag started
const originalCroppingElement =
pointerDownState.originalElements.get(croppingElement.id) as
| ExcalidrawImageElement
| undefined;
const originalCrop = originalCroppingElement?.crop || crop;
const nextCrop = {
...crop,
x: clamp(
originalCrop.x -
offsetVector[0] * Math.sign(croppingElement.scale[0]),
0,
image.naturalWidth - crop.width,
),
y: clamp(
originalCrop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
),
};
this.scene.mutateElement(croppingElement, {
crop: nextCrop,
});
// set drag occurred flag for consistency
pointerDownState.drag.hasOccurred = true;
return;
}
}
}
}
if (this.state.activeLockedId) {
this.setState({
activeLockedId: null,
@ -8313,96 +8546,6 @@ 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,
),
};
this.scene.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.
@ -8886,6 +9029,10 @@ class App extends React.Component<AppProps, AppState> {
isCropping,
} = this.state;
// Clean up crop position movement flag
const wasCropPositionMovement =
pointerDownState.cropPositionMovement.enabled;
this.setState((prevState) => ({
isResizing: false,
isRotating: false,
@ -9425,17 +9572,46 @@ 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();
if (croppingElementId) {
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(croppingElementId);
if (
croppingElement &&
isImageElement(croppingElement) &&
croppingElement.crop
) {
const uncroppedElement = getUncroppedImageElement(
croppingElement,
this.scene.getNonDeletedElementsMap(),
);
const pointer = pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y);
const hitUncroppedArea = hitElementItself({
point: pointer,
element: uncroppedElement,
threshold: this.getElementHitThreshold(uncroppedElement),
elementsMap: this.scene.getNonDeletedElementsMap(),
});
if (!hitUncroppedArea) {
this.finishImageCropping();
} else {
// ensure the image remains selected so crop handles are rendered
if (
(!this.state.selectedElementIds ||
Object.keys(this.state.selectedElementIds).length === 0) &&
this.state.croppingElementId
) {
this.setState({
selectedElementIds: { [this.state.croppingElementId]: true },
});
}
}
} else {
// fallback: if not in cropping mode or no cropping element, finish cropping
this.finishImageCropping();
}
}
const pointerStart = this.lastPointerDownEvent;
@ -9646,7 +9822,7 @@ class App extends React.Component<AppProps, AppState> {
((hitElement &&
hitElementBoundingBoxOnly(
{
point: pointFrom(
point: pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
@ -10598,6 +10774,8 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
console.log("hi");
const croppingElement = this.scene
.getNonDeletedElementsMap()
.get(this.state.croppingElementId);

View File

@ -444,6 +444,11 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true };
cropPositionMovement: {
croppingElementId?: string;
enabled: boolean;
directionLock: "x" | "y" | null;
};
}
export type SearchMatch = {
@ -797,6 +802,11 @@ export type PointerDownState = Readonly<{
boxSelection: {
hasOccurred: boolean;
};
cropPositionMovement: {
croppingElementId?: string;
enabled: boolean;
directionLock: "x" | "y" | null;
};
}>;
export type UnsubscribeCallback = () => void;