Compare commits

...

1 Commits

Author SHA1 Message Date
Ryan Di
8d3195e350 feat: move by uncropped area too 2025-06-18 00:16:44 +10:00
2 changed files with 256 additions and 66 deletions

View File

@ -191,6 +191,7 @@ import {
FlowChartNavigator, FlowChartNavigator,
getLinkDirectionFromKey, getLinkDirectionFromKey,
cropElement, cropElement,
getUncroppedImageElement,
wrapText, wrapText,
isElementLink, isElementLink,
parseElementLinkFromURL, parseElementLinkFromURL,
@ -6541,7 +6542,50 @@ class App extends React.Component<AppProps, AppState> {
this.clearSelectionIfNotUsingSelection(); this.clearSelectionIfNotUsingSelection();
this.updateBindingEnabledOnPointerMove(event); this.updateBindingEnabledOnPointerMove(event);
if (this.handleSelectionOnPointerDown(event, pointerDownState)) { // Check if we're in crop mode and hitting uncropped area - if so, skip selection handling
let skipSelectionHandling = false;
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(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
element: uncroppedElement,
threshold: this.getElementHitThreshold(uncroppedElement),
elementsMap: this.scene.getNonDeletedElementsMap(),
});
if (hitUncroppedArea) {
skipSelectionHandling = true;
// 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 (
!skipSelectionHandling &&
this.handleSelectionOnPointerDown(event, pointerDownState)
) {
return; return;
} }
@ -6952,6 +6996,9 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: { boxSelection: {
hasOccurred: false, hasOccurred: false,
}, },
cropPositionMovement: {
enabled: false,
},
}; };
} }
@ -7206,6 +7253,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.allHitElements.some((element) => pointerDownState.hit.allHitElements.some((element) =>
this.isASelectedElement(element), this.isASelectedElement(element),
); );
if ( if (
(hitElement === null || !someHitElementIsSelected) && (hitElement === null || !someHitElementIsSelected) &&
!event.shiftKey && !event.shiftKey &&
@ -8029,6 +8077,105 @@ class App extends React.Component<AppProps, AppState> {
} }
const pointerCoords = viewportCoordsToSceneCoords(event, this.state); 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 crop = croppingElement.crop;
const image =
isInitializedImageElement(croppingElement) &&
this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) {
const dragOffset = vectorScale(
vector(
pointerCoords.x - pointerDownState.lastCoords.x,
pointerCoords.y - pointerDownState.lastCoords.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 dragOffset onto leftEdge and topEdge to decompose
const offsetVector = vector(
vectorDot(dragOffset, topEdge),
vectorDot(dragOffset, 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,
});
// Update last coords for next move and set drag occurred flag
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
// @ts-ignore - we need to set this for proper shift direction locking
pointerDownState.drag.hasOccurred = true;
return;
}
}
}
// #endregion dedicated crop position movement
if (this.state.activeLockedId) { if (this.state.activeLockedId) {
this.setState({ this.setState({
activeLockedId: null, activeLockedId: null,
@ -8322,8 +8469,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
croppingElement && croppingElement &&
isImageElement(croppingElement) && isImageElement(croppingElement) &&
croppingElement.crop !== null && croppingElement.crop !== null
pointerDownState.hit.element === croppingElement
) { ) {
const crop = croppingElement.crop; const crop = croppingElement.crop;
const image = const image =
@ -8331,74 +8477,107 @@ class App extends React.Component<AppProps, AppState> {
this.imageCache.get(croppingElement.fileId)?.image; this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) { if (image && !(image instanceof Promise)) {
const instantDragOffset = vectorScale( // Check if we're hitting either the cropped element or the uncropped area
vector( const hitCroppedElement =
pointerCoords.x - lastPointerCoords.x, pointerDownState.hit.element === croppingElement;
pointerCoords.y - lastPointerCoords.y, const uncroppedElement = getUncroppedImageElement(
),
Math.max(this.state.zoom.value, 2),
);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement, croppingElement,
elementsMap, elementsMap,
); );
const hitUncroppedArea =
!hitCroppedElement &&
hitElementItself({
point: pointFrom(pointerCoords.x, pointerCoords.y),
element: uncroppedElement,
threshold: this.getElementHitThreshold(uncroppedElement),
elementsMap,
});
const topLeft = vectorFromPoint( if (hitCroppedElement || hitUncroppedArea) {
pointRotateRads( const instantDragOffset = vectorScale(
pointFrom(x1, y1), vector(
pointFrom(cx, cy), pointerCoords.x - lastPointerCoords.x,
croppingElement.angle, pointerCoords.y - lastPointerCoords.y,
), ),
); Math.max(this.state.zoom.value, 2),
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 // Apply shift key constraint for directional movement
const offsetVector = vector( let constrainedDragOffset = instantDragOffset;
vectorDot(instantDragOffset, topEdge), if (event.shiftKey) {
vectorDot(instantDragOffset, leftEdge), const absX = Math.abs(instantDragOffset[0]);
); const absY = Math.abs(instantDragOffset[1]);
const nextCrop = { if (absX > absY) {
...crop, // Horizontal movement only
x: clamp( constrainedDragOffset = vector(instantDragOffset[0], 0);
crop.x - } else {
offsetVector[0] * Math.sign(croppingElement.scale[0]), // Vertical movement only
0, constrainedDragOffset = vector(0, instantDragOffset[1]);
image.naturalWidth - crop.width, }
), }
y: clamp(
crop.y -
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
),
};
this.scene.mutateElement(croppingElement, { const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
crop: nextCrop, croppingElement,
}); elementsMap,
);
return; 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 instantDragOffset onto leftEdge and topEdge to decompose
const offsetVector = vector(
vectorDot(constrainedDragOffset, topEdge),
vectorDot(constrainedDragOffset, 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;
}
} }
} }
} }
@ -8886,10 +9065,15 @@ class App extends React.Component<AppProps, AppState> {
isCropping, isCropping,
} = this.state; } = this.state;
// Clean up crop position movement flag
const wasCropPositionMovement =
pointerDownState.cropPositionMovement.enabled;
this.setState((prevState) => ({ this.setState((prevState) => ({
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
isCropping: false, // Keep isCropping true if we were doing crop position movement
isCropping: wasCropPositionMovement,
resizingElement: null, resizingElement: null,
selectionElement: null, selectionElement: null,
frameToHighlight: null, frameToHighlight: null,
@ -9430,8 +9614,10 @@ class App extends React.Component<AppProps, AppState> {
!croppingElementId || !croppingElementId ||
// in the cropping mode // in the cropping mode
(croppingElementId && (croppingElementId &&
// not cropping and no hit element // not cropping and no hit element (but not doing crop position movement)
((!hitElement && !isCropping) || ((!hitElement &&
!isCropping &&
!pointerDownState.cropPositionMovement.enabled) ||
// hitting something else // hitting something else
(hitElement && hitElement.id !== croppingElementId))) (hitElement && hitElement.id !== croppingElementId)))
) { ) {

View File

@ -797,6 +797,10 @@ export type PointerDownState = Readonly<{
boxSelection: { boxSelection: {
hasOccurred: boolean; hasOccurred: boolean;
}; };
cropPositionMovement: {
croppingElementId?: string;
enabled: boolean;
};
}>; }>;
export type UnsubscribeCallback = () => void; export type UnsubscribeCallback = () => void;