feat: allow inner-drag-selecting with cmd/ctrl (#3603)
* feat: allow inner-drag-selecting with cmd/ctrl * don't use cursor when pressing cmd/ctrl * ensure we reset deselected groups * add tests * add docs * couple fixes around group selection
This commit is contained in:
parent
f4e10c93e1
commit
6d40039f08
@ -259,6 +259,7 @@ export type PointerDownState = Readonly<{
|
|||||||
hasBeenDuplicated: boolean;
|
hasBeenDuplicated: boolean;
|
||||||
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
||||||
};
|
};
|
||||||
|
withCmdOrCtrl: boolean;
|
||||||
drag: {
|
drag: {
|
||||||
// Might change during the pointer interation
|
// Might change during the pointer interation
|
||||||
hasOccurred: boolean;
|
hasOccurred: boolean;
|
||||||
@ -2260,11 +2261,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} else if (isOverScrollBar) {
|
} else if (isOverScrollBar) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||||
} else if (
|
} else if (
|
||||||
hitElement ||
|
// if using cmd/ctrl, we're not dragging
|
||||||
this.isHittingCommonBoundingBoxOfSelectedElements(
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
scenePointer,
|
(hitElement ||
|
||||||
selectedElements,
|
this.isHittingCommonBoundingBoxOfSelectedElements(
|
||||||
)
|
scenePointer,
|
||||||
|
selectedElements,
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||||
} else {
|
} else {
|
||||||
@ -2542,6 +2545,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
origin,
|
origin,
|
||||||
|
withCmdOrCtrl: event[KEYS.CTRL_OR_CMD],
|
||||||
originInGrid: tupleToCoors(
|
originInGrid: tupleToCoors(
|
||||||
getGridPoint(origin.x, origin.y, this.state.gridSize),
|
getGridPoint(origin.x, origin.y, this.state.gridSize),
|
||||||
),
|
),
|
||||||
@ -2746,6 +2750,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (hitElement != null) {
|
if (hitElement != null) {
|
||||||
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
||||||
if (event[KEYS.CTRL_OR_CMD]) {
|
if (event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||||
|
pointerDownState.hit.wasAddedToSelection = true;
|
||||||
|
}
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
...editGroupForSelectedElement(prevState, hitElement),
|
...editGroupForSelectedElement(prevState, hitElement),
|
||||||
previousSelectedElementIds: this.state.selectedElementIds,
|
previousSelectedElementIds: this.state.selectedElementIds,
|
||||||
@ -3184,7 +3191,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getElements(),
|
this.scene.getElements(),
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
if (selectedElements.length > 0) {
|
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||||
|
// it would have weird results (stuff jumping all over the screen)
|
||||||
|
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
|
||||||
const [dragX, dragY] = getGridPoint(
|
const [dragX, dragY] = getGridPoint(
|
||||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||||
@ -3326,11 +3335,25 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (this.state.elementType === "selection") {
|
if (this.state.elementType === "selection") {
|
||||||
const elements = this.scene.getElements();
|
const elements = this.scene.getElements();
|
||||||
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
|
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
|
||||||
this.setState({
|
if (pointerDownState.withCmdOrCtrl && pointerDownState.hit.element) {
|
||||||
selectedElementIds: {},
|
this.setState((prevState) =>
|
||||||
selectedGroupIds: {},
|
selectGroupsForSelectedElements(
|
||||||
editingGroupId: null,
|
{
|
||||||
});
|
...prevState,
|
||||||
|
selectedElementIds: {
|
||||||
|
[pointerDownState.hit.element!.id]: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.scene.getElements(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
selectedElementIds: {},
|
||||||
|
selectedGroupIds: {},
|
||||||
|
editingGroupId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const elementsWithinSelection = getElementsWithinSelection(
|
const elementsWithinSelection = getElementsWithinSelection(
|
||||||
elements,
|
elements,
|
||||||
@ -3346,6 +3369,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
return map;
|
return map;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
|
...(pointerDownState.hit.element
|
||||||
|
? {
|
||||||
|
// if using ctrl/cmd, select the hitElement only if we
|
||||||
|
// haven't box-selected anything else
|
||||||
|
[pointerDownState.hit.element
|
||||||
|
.id]: !elementsWithinSelection.length,
|
||||||
|
}
|
||||||
|
: null),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
this.scene.getElements(),
|
this.scene.getElements(),
|
||||||
@ -3610,12 +3641,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} else {
|
} else {
|
||||||
// remove element from selection while
|
// remove element from selection while
|
||||||
// keeping prev elements selected
|
// keeping prev elements selected
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) =>
|
||||||
selectedElementIds: {
|
selectGroupsForSelectedElements(
|
||||||
...prevState.selectedElementIds,
|
{
|
||||||
[hitElement!.id]: false,
|
...prevState,
|
||||||
},
|
selectedElementIds: {
|
||||||
}));
|
...prevState.selectedElementIds,
|
||||||
|
[hitElement!.id]: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.scene.getElements(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// add element to selection while
|
// add element to selection while
|
||||||
|
@ -67,10 +67,14 @@ export const selectGroupsForSelectedElements = (
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): AppState => {
|
): AppState => {
|
||||||
let nextAppState = { ...appState };
|
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
if (!selectedElements.length) {
|
||||||
|
return { ...nextAppState, editingGroupId: null };
|
||||||
|
}
|
||||||
|
|
||||||
for (const selectedElement of selectedElements) {
|
for (const selectedElement of selectedElements) {
|
||||||
let groupIds = selectedElement.groupIds;
|
let groupIds = selectedElement.groupIds;
|
||||||
if (appState.editingGroupId) {
|
if (appState.editingGroupId) {
|
||||||
|
@ -57,6 +57,7 @@ export class API {
|
|||||||
width = 100,
|
width = 100,
|
||||||
height = width,
|
height = width,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
|
groupIds = [],
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
type: T;
|
type: T;
|
||||||
@ -66,6 +67,7 @@ export class API {
|
|||||||
width?: number;
|
width?: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
|
groupIds?: string[];
|
||||||
// generic element props
|
// generic element props
|
||||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||||
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||||
@ -152,6 +154,9 @@ export class API {
|
|||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
element.isDeleted = isDeleted;
|
element.isDeleted = isDeleted;
|
||||||
}
|
}
|
||||||
|
if (groupIds) {
|
||||||
|
element.groupIds = groupIds;
|
||||||
|
}
|
||||||
return element as any;
|
return element as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -122,6 +122,9 @@ export class Pointer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// incremental (moving by deltas)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
move(dx: number, dy: number) {
|
move(dx: number, dy: number) {
|
||||||
if (dx !== 0 || dy !== 0) {
|
if (dx !== 0 || dy !== 0) {
|
||||||
this.clientX += dx;
|
this.clientX += dx;
|
||||||
@ -150,6 +153,39 @@ export class Pointer {
|
|||||||
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
|
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// absolute coords
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
moveTo(x: number, y: number) {
|
||||||
|
this.clientX = x;
|
||||||
|
this.clientY = y;
|
||||||
|
fireEvent.pointerMove(GlobalTestState.canvas, this.getEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
downAt(x = this.clientX, y = this.clientY) {
|
||||||
|
this.clientX = x;
|
||||||
|
this.clientY = y;
|
||||||
|
fireEvent.pointerDown(GlobalTestState.canvas, this.getEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
upAt(x = this.clientX, y = this.clientY) {
|
||||||
|
this.clientX = x;
|
||||||
|
this.clientY = y;
|
||||||
|
fireEvent.pointerUp(GlobalTestState.canvas, this.getEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
clickAt(x: number, y: number) {
|
||||||
|
this.downAt(x, y);
|
||||||
|
this.upAt();
|
||||||
|
}
|
||||||
|
|
||||||
|
doubleClickAt(x: number, y: number) {
|
||||||
|
this.moveTo(x, y);
|
||||||
|
fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
select(
|
select(
|
||||||
/** if multiple elements supplied, they're shift-selected */
|
/** if multiple elements supplied, they're shift-selected */
|
||||||
elements: ExcalidrawElement | ExcalidrawElement[],
|
elements: ExcalidrawElement | ExcalidrawElement[],
|
||||||
|
@ -10,6 +10,9 @@ import ExcalidrawApp from "../excalidraw-app";
|
|||||||
import * as Renderer from "../renderer/renderScene";
|
import * as Renderer from "../renderer/renderScene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { Keyboard, Pointer } from "./helpers/ui";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
@ -23,6 +26,130 @@ beforeEach(() => {
|
|||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
const assertSelectedIds = (ids: string[]) => {
|
||||||
|
expect(
|
||||||
|
getSelectedElements(h.app.getSceneElements(), h.state).map((el) => el.id),
|
||||||
|
).toEqual(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("inner box-selection", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
});
|
||||||
|
it("selecting elements visually nested inside another", async () => {
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
backgroundColor: "red",
|
||||||
|
fillStyle: "solid",
|
||||||
|
});
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
const rect3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
h.elements = [rect1, rect2, rect3];
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
mouse.downAt(40, 40);
|
||||||
|
mouse.moveTo(290, 290);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
assertSelectedIds([rect2.id, rect3.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting grouped elements visually nested inside another", async () => {
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
backgroundColor: "red",
|
||||||
|
fillStyle: "solid",
|
||||||
|
});
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
const rect3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
h.elements = [rect1, rect2, rect3];
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
mouse.downAt(40, 40);
|
||||||
|
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
assertSelectedIds([rect2.id, rect3.id]);
|
||||||
|
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting & deselecting grouped elements visually nested inside another", async () => {
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
backgroundColor: "red",
|
||||||
|
fillStyle: "solid",
|
||||||
|
});
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
const rect3 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 150,
|
||||||
|
y: 150,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
h.elements = [rect1, rect2, rect3];
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
mouse.downAt(rect2.x - 20, rect2.x - 20);
|
||||||
|
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
|
||||||
|
assertSelectedIds([rect2.id, rect3.id]);
|
||||||
|
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||||
|
mouse.moveTo(rect2.x - 10, rect2.y - 10);
|
||||||
|
assertSelectedIds([rect1.id]);
|
||||||
|
expect(h.state.selectedGroupIds).toEqual({});
|
||||||
|
mouse.up();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("selection element", () => {
|
describe("selection element", () => {
|
||||||
it("create selection element on pointer down", async () => {
|
it("create selection element on pointer down", async () => {
|
||||||
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
const { getByToolName, container } = await render(<ExcalidrawApp />);
|
||||||
|
@ -56,9 +56,8 @@ const populateElements = (
|
|||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
groupIds,
|
||||||
});
|
});
|
||||||
// @ts-ignore
|
|
||||||
element.groupIds = groupIds;
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
selectedElementIds[element.id] = true;
|
selectedElementIds[element.id] = true;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user