Fix drag multiple elements bug (#2023)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
João Forja 2020-08-26 17:37:44 +01:00 committed by GitHub
parent 4718c31da5
commit e7d186b439
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 5304 additions and 210 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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);
},
);