Fix drag multiple elements bug (#2023)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
4718c31da5
commit
e7d186b439
@ -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;
|
||||
|
@ -11,6 +11,8 @@ export const CURSOR_TYPE = {
|
||||
CROSSHAIR: "crosshair",
|
||||
GRABBING: "grabbing",
|
||||
POINTER: "pointer",
|
||||
MOVE: "move",
|
||||
AUTO: "",
|
||||
};
|
||||
export const POINTER_BUTTON = {
|
||||
MAIN: 0,
|
||||
|
@ -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,
|
||||
|
@ -26,7 +26,10 @@ export {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
} from "./transformHandles";
|
||||
export { hitTest } from "./collision";
|
||||
export {
|
||||
hitTest,
|
||||
isHittingElementBoundingBoxWithoutHittingElement,
|
||||
} from "./collision";
|
||||
export {
|
||||
resizeTest,
|
||||
getCursorForResizingElement,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
@ -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);
|
||||
},
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user