|
@@ -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;
|