manipulate and update a crop

pull/8613/head
Ryan Di 5 months ago
parent 71ed96eabb
commit 997fec6c75

@ -441,7 +441,14 @@ import {
getLinkDirectionFromKey,
} from "../element/flowchart";
import type { LocalPoint, Radians } from "../../math";
import { point, pointDistance, vector } from "../../math";
import {
clamp,
point,
pointDistance,
pointRotateRads,
vector,
} from "../../math";
import { cropElement } from "../element/cropElement";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -584,6 +591,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();
@ -3862,6 +3870,28 @@ class App extends React.Component<AppProps, AppState> {
}
if (!isInputLike(event.target)) {
if (
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
this.state.croppingElement
) {
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
@ -6560,6 +6590,12 @@ class App extends React.Component<AppProps, AppState> {
arrowDirection: "origin",
center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
},
crop: {
handleType: false,
isCropping: false,
offset: { x: 0, y: 0 },
complete: false,
},
hit: {
element: null,
allHitElements: [],
@ -6671,12 +6707,29 @@ class App extends React.Component<AppProps, AppState> {
this.device,
);
if (elementWithTransformHandleType != null) {
if (
elementWithTransformHandleType.transformHandleType === "rotation"
) {
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
} else if (this.state.croppingElement) {
this.setState({
croppingElement:
elementWithTransformHandleType.element as ExcalidrawImageElement,
});
pointerDownState.crop.handleType =
elementWithTransformHandleType.transformHandleType;
} else {
this.setState({
resizingElement: elementWithTransformHandleType.element,
});
pointerDownState.resize.handleType =
elementWithTransformHandleType.transformHandleType;
}
}
} else if (selectedElements.length > 1) {
pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
getCommonBounds(selectedElements),
@ -6708,6 +6761,17 @@ class App extends React.Component<AppProps, AppState> {
selectedElements[0],
);
}
} else if (pointerDownState.crop.handleType) {
pointerDownState.crop.isCropping = true;
pointerDownState.crop.offset = tupleToCoors(
getResizeOffsetXY(
pointerDownState.crop.handleType,
selectedElements,
this.scene.getNonDeletedElementsMap(),
pointerDownState.origin.x,
pointerDownState.origin.y,
),
);
} else {
if (this.state.selectedLinearElement) {
const linearElementEditor =
@ -7604,6 +7668,13 @@ class App extends React.Component<AppProps, AppState> {
return true;
}
}
if (pointerDownState.crop.isCropping) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
if (this.maybeHandleCrop(pointerDownState, event)) {
return true;
}
}
const elementsMap = this.scene.getNonDeletedElementsMap();
if (this.state.selectedLinearElement) {
@ -7773,6 +7844,65 @@ class App extends React.Component<AppProps, AppState> {
}
}
// #region drag
if (
selectedElements.length === 1 &&
isImageElement(selectedElements[0]) &&
this.state.croppingElement?.id === selectedElements[0].id &&
selectedElements[0].crop !== null
) {
const crop = selectedElements[0].crop;
const image = selectedElements[0];
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
const instantDragOffset = {
x: pointerCoords.x - lastPointerCoords.x,
y: pointerCoords.y - lastPointerCoords.y,
};
// current offset is based on the element's width and height
const uncroppedWidth = image.widthAtCreation * image.resizedFactorX;
const uncroppedHeight =
image.heightAtCreation * image.resizedFactorY;
const SENSITIVITY_FACTOR = 3;
const adjustedOffset = {
x:
instantDragOffset.x *
(uncroppedWidth / image.naturalWidth) *
SENSITIVITY_FACTOR,
y:
instantDragOffset.y *
(uncroppedHeight / image.naturalHeight) *
SENSITIVITY_FACTOR,
};
const nextCrop = {
...crop,
x: clamp(
crop.x - adjustedOffset.x,
0,
image.naturalWidth - crop.width,
),
y: clamp(
crop.y - adjustedOffset.y,
0,
image.naturalHeight - crop.height,
),
};
mutateElement(image, {
crop: nextCrop,
});
this.lastPointerMoveCoords = pointerCoords;
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.
@ -7906,6 +8036,8 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
}
this.lastPointerMoveCoords = pointerCoords;
return;
}
}
@ -8158,6 +8290,7 @@ class App extends React.Component<AppProps, AppState> {
activeTool,
isResizing,
isRotating,
isCropping,
} = this.state;
this.setState((prevState) => ({
@ -8172,6 +8305,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null,
}));
this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null);
@ -8654,6 +8789,15 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (
isCropping &&
!isResizing &&
((!hitElement && !pointerDownState.crop.isCropping) ||
(hitElement && hitElement !== this.state.croppingElement))
) {
this.finishImageCropping();
}
const pointerStart = this.lastPointerDownEvent;
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
@ -8909,7 +9053,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
@ -9324,7 +9473,18 @@ 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,
widthAtCreation: width,
heightAtCreation: height,
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
resizedFactorX: 1,
resizedFactorY: 1,
});
}
};
@ -9863,6 +10023,46 @@ class App extends React.Component<AppProps, AppState> {
}
};
private maybeHandleCrop = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
): boolean => {
if (pointerDownState.crop.complete) {
return true;
}
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElements(),
this.state,
);
if (selectedElements.length > 1) {
// don't see much sense in allowing multi-crop, that would be weird
return false;
}
const transformHandleType = pointerDownState.crop.handleType;
const pointerCoords = pointerDownState.lastCoords;
const [x, y] = getGridPoint(
pointerCoords.x - pointerDownState.crop.offset.x,
pointerCoords.y - pointerDownState.crop.offset.y,
this.getEffectiveGridSize(),
);
const elementToCrop = selectedElements[0] as ExcalidrawImageElement;
if (transformHandleType) {
cropElement(
elementToCrop,
this.scene.getNonDeletedElementsMap(),
transformHandleType,
x,
y,
);
return true;
}
return false;
};
private maybeHandleResize = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,

@ -0,0 +1,292 @@
import { Point } from "points-on-curve";
import {
type Radians,
point,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
} from "../../math";
import { updateBoundElements } from "./binding";
import { mutateElement } from "./mutateElement";
import { TransformHandleType } from "./transformHandles";
import {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
NonDeleted,
NonDeletedSceneElementsMap,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
// i split out these 'internal' functions so that this functionality can be easily unit tested
const cropElementInternal = (
element: ExcalidrawImageElement,
transformHandle: TransformHandleType,
pointerX: number,
pointerY: number,
) => {
const uncroppedWidth = element.widthAtCreation * element.resizedFactorX;
const uncroppedHeight = element.heightAtCreation * element.resizedFactorY;
const naturalWidthToUncropped = element.naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = element.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 availableTopCropSpace = croppedTop;
const availableLeftCropSpace = croppedLeft;
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
point(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;
const crop = element.crop ?? {
x: 0,
y: 0,
width: element.naturalWidth,
height: element.naturalHeight,
};
if (transformHandle.includes("n")) {
const northBound = element.y - availableTopCropSpace;
const southBound = element.y + element.height;
pointerY = clamp(pointerY, northBound, southBound);
const pointerDeltaY = pointerY - element.y;
nextHeight = element.height - pointerDeltaY;
crop.y =
((pointerDeltaY + croppedTop) / uncroppedHeight) * element.naturalHeight;
crop.height = (nextHeight / uncroppedHeight) * element.naturalHeight;
}
if (transformHandle.includes("s")) {
const northBound = element.y;
const southBound = element.y + (uncroppedHeight - croppedTop);
pointerY = clamp(pointerY, northBound, southBound);
nextHeight = pointerY - element.y;
crop.height = (nextHeight / uncroppedHeight) * element.naturalHeight;
}
if (transformHandle.includes("w")) {
const eastBound = element.x + element.width;
const westBound = element.x - availableLeftCropSpace;
pointerX = clamp(pointerX, westBound, eastBound);
const pointerDeltaX = pointerX - element.x;
nextWidth = element.width - pointerDeltaX;
crop.x =
((pointerDeltaX + croppedLeft) / uncroppedWidth) * element.naturalWidth;
crop.width = (nextWidth / uncroppedWidth) * element.naturalWidth;
}
if (transformHandle.includes("e")) {
const eastBound = element.x + (uncroppedWidth - croppedLeft);
const westBound = element.x;
pointerX = clamp(pointerX, westBound, eastBound);
nextWidth = pointerX - element.x;
crop.width = (nextWidth / uncroppedWidth) * element.naturalWidth;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
);
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
export const cropElement = (
element: ExcalidrawImageElement,
elementsMap: NonDeletedSceneElementsMap,
transformHandle: TransformHandleType,
pointerX: number,
pointerY: number,
) => {
const mutation = cropElementInternal(
element,
transformHandle,
pointerX,
pointerY,
);
mutateElement(element, mutation);
updateBoundElements(element, elementsMap, {
oldSize: { width: element.width, height: element.height },
});
};
// TODO: replace with the refactored resizeSingleElement
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
transformHandle: TransformHandleType,
width: number,
height: number,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = point(x1, y1);
const startBottomRight = point(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]];
}
// 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;
};
export const getUncroppedImageElement = (
image: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (image.crop) {
const width = image.widthAtCreation * image.resizedFactorX;
const height = image.heightAtCreation * image.resizedFactorY;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
image,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(point(x1, y1), point(cx, cy), image.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(point(x2, y1), point(cx, cy), image.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(point(x1, y2), point(cx, cy), image.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-image.crop.x * width) / image.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-image.crop.y * height) / image.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-image.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...image,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return image;
};

@ -696,6 +696,13 @@ export const resizeSingleElement = (
points: rescaledPoints,
};
// TODO: this is not the best approach
updateInternalScale(
element,
eleNewWidth / element.width,
eleNewHeight / element.height,
);
if ("scale" in element && "scale" in stateAtResizeStart) {
mutateElement(element, {
scale: [
@ -750,6 +757,36 @@ export const resizeSingleElement = (
}
};
const updateInternalScale = (
element: NonDeletedExcalidrawElement,
scaleX: number,
scaleY: number,
) => {
if ("type" in element && element.type === "image") {
element = element as ExcalidrawImageElement;
} else {
return;
}
// if the scales happen to be 0 (which is insanely unlikely), it will
// zero out the rolling multiplier and cause weird bugs with cropping.
// if zero is detected, just set the scales to an obnoxiously small number
if (scaleX === 0) {
scaleX = Number.EPSILON;
}
if (scaleY === 0) {
scaleY = Number.EPSILON;
}
scaleX = Math.abs(scaleX);
scaleY = Math.abs(scaleY);
mutateElement(element, {
resizedFactorX: element.resizedFactorX * scaleX,
resizedFactorY: element.resizedFactorY * scaleY,
});
};
export const resizeMultipleElements = (
originalElements: PointerDownState["originalElements"],
selectedElements: readonly NonDeletedExcalidrawElement[],

Loading…
Cancel
Save