Parcourir la source

Fix drag multiple elements bug (#2023)

Co-authored-by: dwelle <luzar.david@gmail.com>
João Forja il y a 4 ans
Parent
commit
e7d186b439

+ 225 - 60
src/components/App.tsx

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

+ 2 - 0
src/constants.ts

@@ -11,6 +11,8 @@ export const CURSOR_TYPE = {
   CROSSHAIR: "crosshair",
   GRABBING: "grabbing",
   POINTER: "pointer",
+  MOVE: "move",
+  AUTO: "",
 };
 export const POINTER_BUTTON = {
   MAIN: 0,

+ 26 - 2
src/element/collision.ts

@@ -48,9 +48,33 @@ export const hitTest = (
   const point: Point = [x, y];
 
   if (isElementSelected(appState, element)) {
-    return doesPointHitElementBoundingBox(element, point, threshold);
+    return isPointHittingElementBoundingBox(element, point, threshold);
   }
 
+  return isHittingElementNotConsideringBoundingBox(element, appState, point);
+};
+
+export const isHittingElementBoundingBoxWithoutHittingElement = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+  x: number,
+  y: number,
+): boolean => {
+  const threshold = 10 / appState.zoom;
+
+  return (
+    !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
+    isPointHittingElementBoundingBox(element, [x, y], threshold)
+  );
+};
+
+const isHittingElementNotConsideringBoundingBox = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+  point: Point,
+): boolean => {
+  const threshold = 10 / appState.zoom;
+
   const check =
     element.type === "text"
       ? isStrictlyInside
@@ -65,7 +89,7 @@ const isElementSelected = (
   element: NonDeleted<ExcalidrawElement>,
 ) => appState.selectedElementIds[element.id];
 
-const doesPointHitElementBoundingBox = (
+const isPointHittingElementBoundingBox = (
   element: NonDeleted<ExcalidrawElement>,
   [x, y]: Point,
   threshold: number,

+ 4 - 1
src/element/index.ts

@@ -26,7 +26,10 @@ export {
   getTransformHandlesFromCoords,
   getTransformHandles,
 } from "./transformHandles";
-export { hitTest } from "./collision";
+export {
+  hitTest,
+  isHittingElementBoundingBoxWithoutHittingElement,
+} from "./collision";
 export {
   resizeTest,
   getCursorForResizingElement,

+ 1 - 1
src/element/linearElementEditor.ts

@@ -171,7 +171,7 @@ export class LinearElementEditor {
     scenePointer: { x: number; y: number },
   ): {
     didAddPoint: boolean;
-    hitElement: ExcalidrawElement | null;
+    hitElement: NonDeleted<ExcalidrawElement> | null;
   } {
     const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
       didAddPoint: false,

+ 13 - 0
src/scene/comparisons.ts

@@ -34,6 +34,8 @@ export const getElementAtPosition = (
 ) => {
   let hitElement = null;
   // We need to to hit testing from front (end of the array) to back (beginning of the array)
+  // because array is ordered from lower z-index to highest and we want element z-index
+  // with higher z-index
   for (let i = elements.length - 1; i >= 0; --i) {
     const element = elements[i];
     if (element.isDeleted) {
@@ -48,6 +50,17 @@ export const getElementAtPosition = (
   return hitElement;
 };
 
+export const getElementsAtPosition = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
+) => {
+  // The parameter elements comes ordered from lower z-index to higher.
+  // We want to preserve that order on the returned array.
+  return elements.filter(
+    (element) => !element.isDeleted && isAtPositionFn(element),
+  );
+};
+
 export const getElementContainingPosition = (
   elements: readonly ExcalidrawElement[],
   x: number,

+ 1 - 0
src/scene/index.ts

@@ -14,5 +14,6 @@ export {
   getElementAtPosition,
   getElementContainingPosition,
   hasText,
+  getElementsAtPosition,
 } from "./comparisons";
 export { getZoomOrigin, getNormalizedZoom } from "./zoom";

Fichier diff supprimé car celui-ci est trop grand
+ 316 - 344
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 469 - 4
src/tests/regressionTests.test.tsx

@@ -1213,13 +1213,478 @@ describe("regression tests", () => {
     expect(h.elements[1].groupIds).toHaveLength(0);
   });
 
-  it("keeps selected element selected when click hits element bounding box but doesn't hit the element", () => {
+  it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
     clickTool("ellipse");
-    mouse.down(0, 0);
+    mouse.down();
     mouse.up(100, 100);
 
-    // click on bounding box but not on element
-    mouse.click(0, 0);
+    // hits bounding box without hitting element
+    mouse.down();
     expect(getSelectedElements().length).toBe(1);
+    mouse.up();
+    expect(getSelectedElements().length).toBe(0);
+  });
+
+  it("switches selected element on pointer down", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    clickTool("ellipse");
+    mouse.down(10, 10);
+    mouse.up(10, 10);
+
+    expect(getSelectedElement().type).toBe("ellipse");
+
+    // pointer down on rectangle
+    mouse.reset();
+    mouse.down();
+
+    expect(getSelectedElement().type).toBe("rectangle");
+  });
+
+  it("can drag element that covers another element, while another elem is selected", () => {
+    clickTool("rectangle");
+    mouse.down(100, 100);
+    mouse.up(200, 200);
+
+    clickTool("rectangle");
+    mouse.reset();
+    mouse.down(100, 100);
+    mouse.up(200, 200);
+
+    clickTool("ellipse");
+    mouse.reset();
+    mouse.down(300, 300);
+    mouse.up(350, 350);
+
+    expect(getSelectedElement().type).toBe("ellipse");
+
+    // pointer down on rectangle
+    mouse.reset();
+    mouse.down(100, 100);
+    mouse.up(200, 200);
+
+    expect(getSelectedElement().type).toBe("rectangle");
+  });
+
+  it("deselects selected element on pointer down when pointer doesn't hit any element", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    expect(getSelectedElements().length).toBe(1);
+
+    // pointer down on space without elements
+    mouse.down(100, 100);
+
+    expect(getSelectedElements().length).toBe(0);
+  });
+
+  it("Drags selected element when hitting only bounding box and keeps element selected", () => {
+    clickTool("ellipse");
+    mouse.down();
+    mouse.up(10, 10);
+
+    const { x: prevX, y: prevY } = getSelectedElement();
+
+    // drag element from point on bounding box that doesn't hit element
+    mouse.reset();
+    mouse.down();
+    mouse.up(25, 25);
+
+    expect(getSelectedElement().x).toEqual(prevX + 25);
+    expect(getSelectedElement().y).toEqual(prevY + 25);
+  });
+
+  it(
+    "given selected element A with lower z-index than unselected element B and given B is partially over A " +
+      "when clicking intersection between A and B " +
+      "B should be selected on pointer up",
+    () => {
+      clickTool("rectangle");
+      // change background color since default is transparent
+      // and transparent elements can't be selected by clicking inside of them
+      clickLabeledElement("Background");
+      clickLabeledElement("#fa5252");
+      mouse.down();
+      mouse.up(1000, 1000);
+
+      // draw ellipse partially over rectangle.
+      // since ellipse was created after rectangle it has an higher z-index.
+      // we don't need to change background color again since change above
+      // affects next drawn elements.
+      clickTool("ellipse");
+      mouse.reset();
+      mouse.down(500, 500);
+      mouse.up(1000, 1000);
+
+      // select rectangle
+      mouse.reset();
+      mouse.click();
+
+      // pointer down on intersection between ellipse and rectangle
+      mouse.down(900, 900);
+      expect(getSelectedElement().type).toBe("rectangle");
+
+      mouse.up();
+      expect(getSelectedElement().type).toBe("ellipse");
+    },
+  );
+
+  it(
+    "given selected element A with lower z-index than unselected element B and given B is partially over A " +
+      "when dragging on intersection between A and B " +
+      "A should be dragged and keep being selected",
+    () => {
+      clickTool("rectangle");
+      // change background color since default is transparent
+      // and transparent elements can't be selected by clicking inside of them
+      clickLabeledElement("Background");
+      clickLabeledElement("#fa5252");
+      mouse.down();
+      mouse.up(1000, 1000);
+
+      // draw ellipse partially over rectangle.
+      // since ellipse was created after rectangle it has an higher z-index.
+      // we don't need to change background color again since change above
+      // affects next drawn elements.
+      clickTool("ellipse");
+      mouse.reset();
+      mouse.down(500, 500);
+      mouse.up(1000, 1000);
+
+      // select rectangle
+      mouse.reset();
+      mouse.click();
+
+      const { x: prevX, y: prevY } = getSelectedElement();
+
+      // pointer down on intersection between ellipse and rectangle
+      mouse.down(900, 900);
+      mouse.up(100, 100);
+
+      expect(getSelectedElement().type).toBe("rectangle");
+      expect(getSelectedElement().x).toEqual(prevX + 100);
+      expect(getSelectedElement().y).toEqual(prevY + 100);
+    },
+  );
+
+  it("deselects group of selected elements on pointer down when pointer doesn't hit any element", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    clickTool("ellipse");
+    mouse.down(100, 100);
+    mouse.up(10, 10);
+
+    // Selects first element without deselecting the second element
+    // Second element is already selected because creating it was our last action
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.click(5, 5);
+    });
+
+    expect(getSelectedElements().length).toBe(2);
+
+    // pointer down on space without elements
+    mouse.reset();
+    mouse.down(500, 500);
+
+    expect(getSelectedElements().length).toBe(0);
+  });
+
+  it("switches from group of selected elements to another element on pointer down", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    clickTool("ellipse");
+    mouse.down(100, 100);
+    mouse.up(100, 100);
+
+    clickTool("diamond");
+    mouse.down(100, 100);
+    mouse.up(100, 100);
+
+    // Selects ellipse without deselecting the diamond
+    // Diamond is already selected because creating it was our last action
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.click(110, 160);
+    });
+
+    expect(getSelectedElements().length).toBe(2);
+
+    // select rectangle
+    mouse.reset();
+    mouse.down();
+
+    expect(getSelectedElement().type).toBe("rectangle");
+  });
+
+  it("deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    clickTool("ellipse");
+    mouse.down(100, 100);
+    mouse.up(10, 10);
+
+    // Selects first element without deselecting the second element
+    // Second element is already selected because creating it was our last action
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.click(5, 5);
+    });
+
+    // pointer down on common bounding box without hitting any of the elements
+    mouse.reset();
+    mouse.down(50, 50);
+    expect(getSelectedElements().length).toBe(2);
+
+    mouse.up();
+    expect(getSelectedElements().length).toBe(0);
+  });
+
+  it(
+    "drags selected elements from point inside common bounding box that doesn't hit any element " +
+      "and keeps elements selected after dragging",
+    () => {
+      clickTool("rectangle");
+      mouse.down();
+      mouse.up(10, 10);
+
+      clickTool("ellipse");
+      mouse.down(100, 100);
+      mouse.up(10, 10);
+
+      // Selects first element without deselecting the second element
+      // Second element is already selected because creating it was our last action
+      mouse.reset();
+      withModifierKeys({ shift: true }, () => {
+        mouse.click(5, 5);
+      });
+
+      expect(getSelectedElements().length).toBe(2);
+
+      const {
+        x: firstElementPrevX,
+        y: firstElementPrevY,
+      } = getSelectedElements()[0];
+      const {
+        x: secondElementPrevX,
+        y: secondElementPrevY,
+      } = getSelectedElements()[1];
+
+      // drag elements from point on common bounding box that doesn't hit any of the elements
+      mouse.reset();
+      mouse.down(50, 50);
+      mouse.up(25, 25);
+
+      expect(getSelectedElements()[0].x).toEqual(firstElementPrevX + 25);
+      expect(getSelectedElements()[0].y).toEqual(firstElementPrevY + 25);
+
+      expect(getSelectedElements()[1].x).toEqual(secondElementPrevX + 25);
+      expect(getSelectedElements()[1].y).toEqual(secondElementPrevY + 25);
+
+      expect(getSelectedElements().length).toBe(2);
+    },
+  );
+
+  it(
+    "given a group of selected elements with an element that is not selected inside the group common bounding box " +
+      "when element that is not selected is clicked " +
+      "should switch selection to not selected element on pointer up",
+    () => {
+      clickTool("rectangle");
+      mouse.down();
+      mouse.up(10, 10);
+
+      clickTool("ellipse");
+      mouse.down(100, 100);
+      mouse.up(100, 100);
+
+      clickTool("diamond");
+      mouse.down(100, 100);
+      mouse.up(100, 100);
+
+      // Selects rectangle without deselecting the diamond
+      // Diamond is already selected because creating it was our last action
+      mouse.reset();
+      withModifierKeys({ shift: true }, () => {
+        mouse.click();
+      });
+
+      // pointer down on ellipse
+      mouse.down(110, 160);
+      expect(getSelectedElements().length).toBe(2);
+
+      mouse.up();
+      expect(getSelectedElement().type).toBe("ellipse");
+    },
+  );
+
+  it(
+    "given a selected element A and a not selected element B with higher z-index than A " +
+      "and given B partialy overlaps A " +
+      "when there's a shift-click on the overlapped section B is added to the selection",
+    () => {
+      clickTool("rectangle");
+      // change background color since default is transparent
+      // and transparent elements can't be selected by clicking inside of them
+      clickLabeledElement("Background");
+      clickLabeledElement("#fa5252");
+      mouse.down();
+      mouse.up(1000, 1000);
+
+      // draw ellipse partially over rectangle.
+      // since ellipse was created after rectangle it has an higher z-index.
+      // we don't need to change background color again since change above
+      // affects next drawn elements.
+      clickTool("ellipse");
+      mouse.reset();
+      mouse.down(500, 500);
+      mouse.up(1000, 1000);
+
+      // select rectangle
+      mouse.reset();
+      mouse.click();
+
+      // click on intersection between ellipse and rectangle
+      withModifierKeys({ shift: true }, () => {
+        mouse.click(900, 900);
+      });
+
+      expect(getSelectedElements().length).toBe(2);
+    },
+  );
+
+  it("shift click on selected element should deselect it on pointer up", () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(10, 10);
+
+    // Rectangle is already selected since creating
+    // it was our last action
+    withModifierKeys({ shift: true }, () => {
+      mouse.down();
+    });
+    expect(getSelectedElements().length).toBe(1);
+
+    withModifierKeys({ shift: true }, () => {
+      mouse.up();
+    });
+    expect(getSelectedElements().length).toBe(0);
   });
 });
+
+it(
+  "given element A and group of elements B and given both are selected " +
+    "when user clicks on B, on pointer up " +
+    "only elements from B should be selected",
+  () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    clickTool("rectangle");
+    mouse.down(10, 10);
+    mouse.up(100, 100);
+
+    clickTool("rectangle");
+    mouse.down(10, 10);
+    mouse.up(100, 100);
+
+    // Select first rectangle while keeping third one selected.
+    // Third rectangle is selected because it was the last element
+    //  to be created.
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.click();
+    });
+
+    // Create group with first and third rectangle
+    withModifierKeys({ ctrl: true }, () => {
+      keyPress("g");
+    });
+
+    expect(getSelectedElements().length).toBe(2);
+    const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
+    expect(selectedGroupIds.length).toBe(1);
+
+    // Select second rectangle without deselecting group
+    withModifierKeys({ shift: true }, () => {
+      mouse.click(110, 110);
+    });
+    expect(getSelectedElements().length).toBe(3);
+
+    // pointer down on first rectangle that is
+    // part of the group
+    mouse.reset();
+    mouse.down();
+    expect(getSelectedElements().length).toBe(3);
+
+    // should only deselect on pointer up
+    mouse.up();
+    expect(getSelectedElements().length).toBe(2);
+    const newSelectedGroupIds = Object.keys(h.state.selectedGroupIds);
+    expect(newSelectedGroupIds.length).toBe(1);
+  },
+);
+
+it(
+  "given element A and group of elements B and given both are selected " +
+    "when user shift-clicks on B, on pointer up " +
+    "only element A should be selected",
+  () => {
+    clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    clickTool("rectangle");
+    mouse.down(10, 10);
+    mouse.up(100, 100);
+
+    clickTool("rectangle");
+    mouse.down(10, 10);
+    mouse.up(100, 100);
+
+    // Select first rectangle while keeping third one selected.
+    // Third rectangle is selected because it was the last element
+    //  to be created.
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.click();
+    });
+
+    // Create group with first and third rectangle
+    withModifierKeys({ ctrl: true }, () => {
+      keyPress("g");
+    });
+
+    expect(getSelectedElements().length).toBe(2);
+    const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
+    expect(selectedGroupIds.length).toBe(1);
+
+    // Select second rectangle without deselecting group
+    withModifierKeys({ shift: true }, () => {
+      mouse.click(110, 110);
+    });
+    expect(getSelectedElements().length).toBe(3);
+
+    // pointer down o first rectangle that is
+    // part of the group
+    mouse.reset();
+    withModifierKeys({ shift: true }, () => {
+      mouse.down();
+    });
+    expect(getSelectedElements().length).toBe(3);
+    withModifierKeys({ shift: true }, () => {
+      mouse.up();
+    });
+    expect(getSelectedElements().length).toBe(1);
+  },
+);

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff