Refactor dragging

This commit is contained in:
Mark Tolmacs
2025-08-29 17:40:10 +02:00
parent df8c98d946
commit 1668a9736b
2 changed files with 180 additions and 245 deletions

View File

@@ -19,7 +19,6 @@ import {
shouldRotateWithDiscreteAngle,
getGridPoint,
invariant,
tupleToCoors,
} from "@excalidraw/common";
import {
@@ -290,207 +289,146 @@ export class LinearElementEditor {
scenePointerY: number,
linearElementEditor: LinearElementEditor,
): Pick<AppState, "suggestedBinding" | "selectedLinearElement"> | null {
if (!linearElementEditor) {
return null;
}
const { elementId } = linearElementEditor;
const elementsMap = app.scene.getNonDeletedElementsMap();
const elements = app.scene.getNonDeletedElements();
const { elbowed, elementId, pointerDownState, selectedPointsIndices } =
linearElementEditor;
const { lastClickedPoint } = pointerDownState;
const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) {
return null;
}
invariant(
selectedPointsIndices,
"There must be selected points in order to drag them",
);
const elbowed = isElbowArrow(element);
invariant(
lastClickedPoint > -1 && selectedPointsIndices.includes(lastClickedPoint),
"There must be a valid lastClickedPoint in order to drag it",
);
if (
elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return null;
}
invariant(element, "Element being dragged must exist in the scene");
const selectedPointsIndices = elbowed
? [
!!linearElementEditor.selectedPointsIndices?.includes(0)
? 0
: undefined,
!!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
? element.points.length - 1
: undefined,
].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
: linearElementEditor.pointerDownState.lastClickedPoint;
invariant(element.points.length > 1, "Element must have at least 2 points");
invariant(
!elbowed ||
selectedPointsIndices?.filter(
(idx) => idx !== 0 && idx !== element.points.length - 1,
).length === 0,
"Only start and end points can be selected for elbow arrows",
);
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
// The adjacent point to the one dragged point
const pivotPoint =
element.points[lastClickedPoint === 0 ? 1 : lastClickedPoint - 1];
const singlePointDragged = selectedPointsIndices.length === 1;
const customLineAngle =
linearElementEditor.customLineAngle ??
determineCustomLinearAngle(pivotPoint, element.points[lastClickedPoint]);
const startIsSelected = selectedPointsIndices.includes(0);
const endIsSelected = selectedPointsIndices.includes(
element.points.length - 1,
);
if (selectedPointsIndices && draggingPoint) {
const elements = app.scene.getNonDeletedElements();
if (
shouldRotateWithDiscreteAngle(event) &&
selectedPointsIndices.length === 1 &&
element.points.length > 1
) {
const selectedIndex = selectedPointsIndices[0];
const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
customLineAngle =
linearElementEditor.customLineAngle ??
Math.atan2(
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
elementsMap,
referencePoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
);
const reference = pointFrom<LocalPoint>(
width + referencePoint[0],
height + referencePoint[1],
);
const deltaX = reference[0] - draggingPoint[0];
const deltaY = reference[1] - draggingPoint[1];
const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
elementsMap,
element,
elements,
app,
);
LinearElementEditor.movePoints(element, app.scene, positions, updates);
} else if (
shouldAllowDraggingPoint(
element,
scenePointerX,
scenePointerY,
selectedPointsIndices,
elementsMap,
app,
)
) {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
elementsMap,
element,
elements,
app,
);
LinearElementEditor.movePoints(element, app.scene, positions, updates);
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
handleBindTextResize(element, app.scene, false);
}
// suggest bindings for first and last point if selected
let suggestedBinding: AppState["suggestedBinding"] = null;
if (isBindingElement(element, false)) {
const firstIndexIsSelected = selectedPointsIndices[0] === 0;
const lastIndexIsSelected =
selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1;
const coords: { x: number; y: number }[] = [];
if (firstIndexIsSelected !== lastIndexIsSelected) {
if (firstIndexIsSelected) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[0],
elementsMap,
),
),
);
}
if (lastIndexIsSelected) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[
selectedPointsIndices[selectedPointsIndices.length - 1]
],
elementsMap,
),
),
);
}
}
if (coords.length) {
suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords(
element,
firstIndexIsSelected && lastIndexIsSelected
? "both"
: firstIndexIsSelected
? "start"
: "end",
app.scene,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
);
}
}
const newLinearElementEditor = {
...linearElementEditor,
selectedPointsIndices,
segmentMidPointHoveredCoords:
lastClickedPoint !== 0 &&
lastClickedPoint !== element.points.length - 1
? this.getPointGlobalCoordinates(
element,
draggingPoint,
elementsMap,
)
: null,
hoverPointIndex:
lastClickedPoint === 0 ||
lastClickedPoint === element.points.length - 1
? lastClickedPoint
: -1,
isDragging: true,
// Determine if point movement should happen and how much
let deltaX = 0;
let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) {
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
elementsMap,
pivotPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
};
);
const target = pointFrom<LocalPoint>(
width + pivotPoint[0],
height + pivotPoint[1],
);
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
} as Pick<AppState, keyof AppState>;
deltaX = target[0] - draggingPoint[0];
deltaY = target[1] - draggingPoint[1];
} else if (
shouldAllowDraggingPoint(
element,
scenePointerX,
scenePointerY,
selectedPointsIndices,
elementsMap,
app,
)
) {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
deltaX = newDraggingPointPosition[0] - draggingPoint[0];
deltaY = newDraggingPointPosition[1] - draggingPoint[1];
}
return null;
// Apply the point movement if needed
if (deltaX || deltaY) {
const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
elementsMap,
element,
elements,
app,
);
LinearElementEditor.movePoints(element, app.scene, positions, updates);
}
// Attached text might need to update if arrow dimensions change
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
handleBindTextResize(element, app.scene, false);
}
// Suggest bindings for first and last point if selected
let suggestedBinding: AppState["suggestedBinding"] = null;
if (isBindingElement(element, false)) {
if (startIsSelected || endIsSelected) {
suggestedBinding = maybeSuggestBindingsForBindingElementAtCoords(
element,
startIsSelected && endIsSelected
? "both"
: startIsSelected
? "start"
: "end",
app.scene,
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
);
}
}
const newLinearElementEditor = {
...linearElementEditor,
selectedPointsIndices,
segmentMidPointHoveredCoords:
lastClickedPoint !== 0 && lastClickedPoint !== element.points.length - 1
? this.getPointGlobalCoordinates(element, draggingPoint, elementsMap)
: null,
hoverPointIndex:
lastClickedPoint === 0 || lastClickedPoint === element.points.length - 1
? lastClickedPoint
: -1,
isDragging: true,
customLineAngle,
};
return {
selectedLinearElement: newLinearElementEditor,
suggestedBinding,
};
}
static handlePointerUp(
@@ -1841,7 +1779,10 @@ export class LinearElementEditor {
x: number,
y: number,
scene: Scene,
): LinearElementEditor {
): Pick<
LinearElementEditor,
"segmentMidPointHoveredCoords" | "pointerDownState"
> {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElement.elementId,
@@ -2133,3 +2074,9 @@ const shouldAllowDraggingPoint = (
return allowDrag;
};
const determineCustomLinearAngle = (
pivotPoint: LocalPoint,
draggedPoint: LocalPoint,
) =>
Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]);

View File

@@ -8787,54 +8787,54 @@ class App extends React.Component<AppProps, AppState> {
!linearElementEditor.pointerDownState.segmentMidpoint.added
) {
return;
}
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
);
if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
} else if (linearElementEditor.pointerDownState.lastClickedPoint > -1) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
);
this.handleDelayedBindModeChange(element, hoveredElement);
}
if (isBindingElement(element)) {
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
elementsMap,
);
const newState = LinearElementEditor.handlePointDragging(
event,
this,
pointerCoords.x,
pointerCoords.y,
linearElementEditor,
);
if (newState) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true;
// NOTE: Optimize setState calls because it
// affects history and performance
if (
newState.suggestedBinding !== this.state.suggestedBinding ||
!isShallowEqual(
newState.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
) ||
newState.selectedLinearElement?.hoverPointIndex !==
this.state.selectedLinearElement?.hoverPointIndex ||
newState.selectedLinearElement?.customLineAngle !==
this.state.selectedLinearElement?.customLineAngle ||
this.state.selectedLinearElement.isDragging !==
newState.selectedLinearElement?.isDragging
) {
this.setState(newState);
this.handleDelayedBindModeChange(element, hoveredElement);
}
return;
const newState = LinearElementEditor.handlePointDragging(
event,
this,
pointerCoords.x,
pointerCoords.y,
linearElementEditor,
);
if (newState) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true;
// NOTE: Optimize setState calls because it
// affects history and performance
if (
newState.suggestedBinding !== this.state.suggestedBinding ||
!isShallowEqual(
newState.selectedLinearElement?.selectedPointsIndices ?? [],
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
) ||
newState.selectedLinearElement?.hoverPointIndex !==
this.state.selectedLinearElement?.hoverPointIndex ||
newState.selectedLinearElement?.customLineAngle !==
this.state.selectedLinearElement?.customLineAngle ||
this.state.selectedLinearElement.isDragging !==
newState.selectedLinearElement?.isDragging
) {
this.setState(newState);
}
return;
}
}
}
@@ -9007,13 +9007,13 @@ class App extends React.Component<AppProps, AppState> {
const nextCrop = {
...crop,
x: clamp(
crop.x -
crop.x +
offsetVector[0] * Math.sign(croppingElement.scale[0]),
0,
image.naturalWidth - crop.width,
),
y: clamp(
crop.y -
crop.y +
offsetVector[1] * Math.sign(croppingElement.scale[1]),
0,
image.naturalHeight - crop.height,
@@ -9316,18 +9316,6 @@ class App extends React.Component<AppProps, AppState> {
linearElementEditor,
)!,
});
// if (isBindingElement(newElement, false)) {
// // When creating a linear element by dragging
// this.setState({
// suggestedBinding: maybeSuggestBindingsForBindingElementAtCoords(
// newElement,
// "end",
// this.scene,
// pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
// ),
// });
// }
} else {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;