mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Fix drag multiple elements bug (#2023)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
4718c31da5
commit
e7d186b439
9 changed files with 5304 additions and 210 deletions
|
@ -31,11 +31,12 @@ import {
|
|||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
hitTest,
|
||||
isHittingElementBoundingBoxWithoutHittingElement,
|
||||
} from "../element";
|
||||
import {
|
||||
getElementsWithinSelection,
|
||||
isOverScrollBars,
|
||||
getElementAtPosition,
|
||||
getElementsAtPosition,
|
||||
getElementContainingPosition,
|
||||
getNormalizedZoom,
|
||||
getSelectedElements,
|
||||
|
@ -151,9 +152,11 @@ import throttle from "lodash.throttle";
|
|||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
isSelectedViaGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
isElementInGroup,
|
||||
getSelectedGroupIdForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { Library } from "../data/library";
|
||||
import Scene from "../scene/Scene";
|
||||
|
@ -231,12 +234,16 @@ type PointerDownState = Readonly<{
|
|||
hit: {
|
||||
// The element the pointer is "hitting", is determined on the initial
|
||||
// pointer down event
|
||||
element: ExcalidrawElement | null;
|
||||
element: NonDeleted<ExcalidrawElement> | null;
|
||||
// The elements the pointer is "hitting", is determined on the initial
|
||||
// pointer down event
|
||||
allHitElements: NonDeleted<ExcalidrawElement>[];
|
||||
// This is determined on the initial pointer down event
|
||||
wasAddedToSelection: boolean;
|
||||
// Whether selected element(s) were duplicated, might change during the
|
||||
// pointer interation
|
||||
// pointer interaction
|
||||
hasBeenDuplicated: boolean;
|
||||
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
||||
};
|
||||
drag: {
|
||||
// Might change during the pointer interation
|
||||
|
@ -1713,7 +1720,32 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
return getElementAtPosition(this.scene.getElements(), (element) =>
|
||||
const allHitElements = this.getElementsAtPosition(x, y);
|
||||
if (allHitElements.length > 1) {
|
||||
const elementWithHighestZIndex =
|
||||
allHitElements[allHitElements.length - 1];
|
||||
// If we're hitting element with highest z-index only on its bounding box
|
||||
// while also hitting other element figure, the latter should be considered.
|
||||
return isHittingElementBoundingBoxWithoutHittingElement(
|
||||
elementWithHighestZIndex,
|
||||
this.state,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
? allHitElements[allHitElements.length - 2]
|
||||
: elementWithHighestZIndex;
|
||||
}
|
||||
if (allHitElements.length === 1) {
|
||||
return allHitElements[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getElementsAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawElement>[] {
|
||||
return getElementsAtPosition(this.scene.getElements(), (element) =>
|
||||
hitTest(element, this.state, x, y),
|
||||
);
|
||||
}
|
||||
|
@ -2084,14 +2116,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return;
|
||||
}
|
||||
}
|
||||
const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY);
|
||||
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
if (this.state.elementType === "text") {
|
||||
document.documentElement.style.cursor = isTextElement(hitElement)
|
||||
? CURSOR_TYPE.TEXT
|
||||
: CURSOR_TYPE.CROSSHAIR;
|
||||
} else if (isOverScrollBar) {
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.AUTO;
|
||||
} else if (
|
||||
hitElement ||
|
||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||
scenePointer,
|
||||
selectedElements,
|
||||
)
|
||||
) {
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.MOVE;
|
||||
} else {
|
||||
document.documentElement.style.cursor =
|
||||
hitElement && !isOverScrollBar ? "move" : "";
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.AUTO;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -2370,8 +2415,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
},
|
||||
hit: {
|
||||
element: null,
|
||||
allHitElements: [],
|
||||
wasAddedToSelection: false,
|
||||
hasBeenDuplicated: false,
|
||||
hasHitCommonBoundingBoxOfSelectedElements: this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||
origin,
|
||||
selectedElements,
|
||||
),
|
||||
},
|
||||
drag: {
|
||||
hasOccurred: false,
|
||||
|
@ -2516,13 +2566,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
pointerDownState.origin.y,
|
||||
);
|
||||
|
||||
this.maybeClearSelectionWhenHittingElement(
|
||||
event,
|
||||
pointerDownState.hit.element,
|
||||
// For overlapped elements one position may hit
|
||||
// multiple elements
|
||||
pointerDownState.hit.allHitElements = this.getElementsAtPosition(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
|
||||
// If we click on something
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
const someHitElementIsSelected = pointerDownState.hit.allHitElements.some(
|
||||
(element) => this.isASelectedElement(element),
|
||||
);
|
||||
if (
|
||||
(hitElement === null || !someHitElementIsSelected) &&
|
||||
!event.shiftKey &&
|
||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
|
||||
) {
|
||||
this.clearSelection(hitElement);
|
||||
}
|
||||
|
||||
// If we click on something
|
||||
if (hitElement != null) {
|
||||
// deselect if item is selected
|
||||
// if shift is not clicked, this will always return true
|
||||
|
@ -2542,23 +2605,28 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
return true;
|
||||
}
|
||||
this.setState((prevState) => {
|
||||
return selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: true,
|
||||
|
||||
// Add hit element to selection. At this point if we're not holding
|
||||
// SHIFT the previously selected element(s) were deselected above
|
||||
// (make sure you use setState updater to use latest state)
|
||||
if (
|
||||
!someHitElementIsSelected &&
|
||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
|
||||
) {
|
||||
this.setState((prevState) => {
|
||||
return selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
this.scene.getElements(),
|
||||
);
|
||||
});
|
||||
// TODO: this is strange...
|
||||
this.scene.replaceAllElements(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
);
|
||||
pointerDownState.hit.wasAddedToSelection = true;
|
||||
this.scene.getElements(),
|
||||
);
|
||||
});
|
||||
pointerDownState.hit.wasAddedToSelection = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2571,6 +2639,29 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return false;
|
||||
};
|
||||
|
||||
private isASelectedElement(hitElement: ExcalidrawElement | null): boolean {
|
||||
return hitElement != null && this.state.selectedElementIds[hitElement.id];
|
||||
}
|
||||
|
||||
private isHittingCommonBoundingBoxOfSelectedElements(
|
||||
point: Readonly<{ x: number; y: number }>,
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
): boolean {
|
||||
if (selectedElements.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// How many pixels off the shape boundary we still consider a hit
|
||||
const threshold = 10 / this.state.zoom;
|
||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
||||
return (
|
||||
point.x > x1 - threshold &&
|
||||
point.x < x2 + threshold &&
|
||||
point.y > y1 - threshold &&
|
||||
point.y < y2 + threshold
|
||||
);
|
||||
}
|
||||
|
||||
private handleTextOnPointerDown = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -2852,8 +2943,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
}
|
||||
}
|
||||
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
if (hitElement && this.state.selectedElementIds[hitElement.id]) {
|
||||
const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
|
||||
(element) => this.isASelectedElement(element),
|
||||
);
|
||||
if (
|
||||
hasHitASelectedElement ||
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
|
||||
) {
|
||||
// Marking that click was used for dragging to check
|
||||
// if elements should be deselected on pointerup
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
|
@ -2882,12 +2978,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
const elementsToAppend = [];
|
||||
const groupIdMap = new Map();
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
for (const element of this.scene.getElementsIncludingDeleted()) {
|
||||
if (
|
||||
this.state.selectedElementIds[element.id] ||
|
||||
// case: the state.selectedElementIds might not have been
|
||||
// updated yet by the time this mousemove event is fired
|
||||
(element.id === hitElement.id &&
|
||||
(element.id === hitElement?.id &&
|
||||
pointerDownState.hit.wasAddedToSelection)
|
||||
) {
|
||||
const duplicatedElement = duplicateElement(
|
||||
|
@ -3125,6 +3222,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.actionManager.executeAction(actionFinalize);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLinearElement(draggingElement)) {
|
||||
if (draggingElement!.points.length > 1) {
|
||||
history.resumeRecording();
|
||||
|
@ -3135,6 +3233,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.canvas,
|
||||
window.devicePixelRatio,
|
||||
);
|
||||
|
||||
if (
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
draggingElement &&
|
||||
|
@ -3186,6 +3285,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -3230,35 +3330,111 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
);
|
||||
}
|
||||
|
||||
// If click occurred on already selected element
|
||||
// it is needed to remove selection from other elements
|
||||
// or if SHIFT or META key pressed remove selection
|
||||
// from hitted element
|
||||
//
|
||||
// If click occurred and elements were dragged or some element
|
||||
// was added to selection (on pointerdown phase) we need to keep
|
||||
// selection unchanged
|
||||
// Code below handles selection when element(s) weren't
|
||||
// drag or added to selection on pointer down phase.
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
if (
|
||||
getSelectedGroupIds(this.state).length === 0 &&
|
||||
hitElement &&
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
!pointerDownState.hit.wasAddedToSelection
|
||||
) {
|
||||
if (childEvent.shiftKey) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: false,
|
||||
},
|
||||
}));
|
||||
if (this.state.selectedElementIds[hitElement.id]) {
|
||||
if (isSelectedViaGroup(this.state, hitElement)) {
|
||||
// We want to unselect all groups hitElement is part of
|
||||
// as well as all elements that are part of the groups
|
||||
// hitElement is part of
|
||||
const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds
|
||||
.flatMap((groupId) =>
|
||||
getElementsInGroup(this.scene.getElements(), groupId),
|
||||
)
|
||||
.map((element) => ({ [element.id]: false }))
|
||||
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
|
||||
|
||||
this.setState((_prevState) => ({
|
||||
selectedGroupIds: {
|
||||
..._prevState.selectedElementIds,
|
||||
...hitElement.groupIds
|
||||
.map((gId) => ({ [gId]: false }))
|
||||
.reduce((prev, acc) => ({ ...prev, ...acc }), {}),
|
||||
},
|
||||
selectedElementIds: {
|
||||
..._prevState.selectedElementIds,
|
||||
...idsOfSelectedElementsThatAreInGroups,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// remove element from selection while
|
||||
// keeping prev elements selected
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// add element to selection while
|
||||
// keeping prev elements selected
|
||||
this.setState((_prevState) => ({
|
||||
selectedElementIds: {
|
||||
..._prevState.selectedElementIds,
|
||||
[hitElement!.id]: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.setState((_prevState) => ({
|
||||
selectedElementIds: { [hitElement!.id]: true },
|
||||
}));
|
||||
if (isSelectedViaGroup(this.state, hitElement)) {
|
||||
/*
|
||||
We want to select the group(s) the hit element is in not the particular element.
|
||||
That means we have to deselect elements that are not part of the groups of the
|
||||
hit element, while keeping the elements that are.
|
||||
*/
|
||||
const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds
|
||||
.flatMap((groupId) =>
|
||||
getElementsInGroup(this.scene.getElements(), groupId),
|
||||
)
|
||||
.map((element) => ({ [element.id]: true }))
|
||||
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {});
|
||||
|
||||
this.setState((_prevState) => ({
|
||||
selectedGroupIds: {
|
||||
...hitElement.groupIds
|
||||
.map((gId) => ({ [gId]: true }))
|
||||
.reduce((prevId, acc) => ({ ...prevId, ...acc }), {}),
|
||||
},
|
||||
selectedElementIds: { ...idsOfSelectedElementsThatAreInGroups },
|
||||
}));
|
||||
} else {
|
||||
this.setState((_prevState) => ({
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: { [hitElement!.id]: true },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!this.state.editingLinearElement &&
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
!this.state.isResizing &&
|
||||
((hitElement &&
|
||||
isHittingElementBoundingBoxWithoutHittingElement(
|
||||
hitElement,
|
||||
this.state,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
)) ||
|
||||
(!hitElement &&
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
|
||||
) {
|
||||
// Deselect selected elements
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
}
|
||||
|
||||
if (draggingElement === null) {
|
||||
// if no element is clicked, clear the selection and redraw
|
||||
this.setState({
|
||||
|
@ -3359,17 +3535,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.setState({ suggestedBindings });
|
||||
}
|
||||
|
||||
private maybeClearSelectionWhenHittingElement(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
hitElement: ExcalidrawElement | null,
|
||||
): void {
|
||||
const isHittingASelectedElement =
|
||||
hitElement != null && this.state.selectedElementIds[hitElement.id];
|
||||
|
||||
// clear selection if shift is not clicked
|
||||
if (isHittingASelectedElement || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
private clearSelection(hitElement: ExcalidrawElement | null): void {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
|
@ -3713,5 +3879,4 @@ if (
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue