mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-14 01:34:49 +01:00
feat: drag, resize, and rotate after selecting in lasso (#9732)
* feat: drag, resize, and rotate after selecting in lasso * alternative ux: drag with lasso right away * fix: lasso dragging should snap too * fix: alt+cmd getting stuck * test: snapshots * alternatvie: keep lasso drag to only mobile * alternative: drag after selection on PCs * improve mobile dection * add mobile lasso icon * add default selection tool * render according to default selection tool * return to default selection tool after deletion * reset to default tool after clearing out the canvas * return to default tool after eraser toggle * if default lasso, close lasso toggle * finalize to default selection tool * toggle between laser and default selection * return to default selection tool after creation * double click to add text when using default selection tool * set to default selection tool after unlocking tool * paste to center on touch screen * switch to default selection tool after pasting * lint * fix tests * show welcome screen when using default selection tool * fix tests * fix snapshots * fix context menu not opening * prevent potential displacement issue * prevent element jumping during lasso selection * fix dragging on mobile * use same selection icon * fix alt+cmd lasso getting cut off * fix: shortcut handling * lint --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -100,6 +100,7 @@ import {
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
Emitter,
|
||||
isMobile,
|
||||
MINIMUM_ARROW_SIZE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -653,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso" = "selection";
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
this.defaultSelectionTool = this.isMobileOrTablet()
|
||||
? ("lasso" as const)
|
||||
: ("selection" as const);
|
||||
const {
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
@@ -1606,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type === "selection" &&
|
||||
this.state.activeTool.type ===
|
||||
this.defaultSelectionTool &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
@@ -2350,6 +2357,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
const activeTool = scene.appState.activeTool;
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
theme: this.props.theme || scene.appState.theme,
|
||||
@@ -2359,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// with a library install link, which should auto-open the library)
|
||||
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
||||
activeTool:
|
||||
scene.appState.activeTool.type === "image"
|
||||
? { ...scene.appState.activeTool, type: "selection" }
|
||||
activeTool.type === "image" ||
|
||||
activeTool.type === "lasso" ||
|
||||
activeTool.type === "selection"
|
||||
? {
|
||||
...activeTool,
|
||||
type: this.defaultSelectionTool,
|
||||
}
|
||||
: scene.appState.activeTool,
|
||||
isLoading: false,
|
||||
toast: this.state.toast,
|
||||
@@ -2399,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private isMobileOrTablet = (): boolean => {
|
||||
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
const hasCoarsePointer =
|
||||
"matchMedia" in window &&
|
||||
window?.matchMedia("(pointer: coarse)")?.matches;
|
||||
const isTouchMobile = hasTouch && hasCoarsePointer;
|
||||
|
||||
return isMobile || isTouchMobile;
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
@@ -3117,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} else if (data.text) {
|
||||
@@ -3135,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files,
|
||||
position: "cursor",
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -3195,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
}
|
||||
this.setActiveTool({ type: "selection" });
|
||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||
event?.preventDefault();
|
||||
},
|
||||
);
|
||||
@@ -3341,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
},
|
||||
);
|
||||
this.setActiveTool({ type: "selection" });
|
||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(duplicatedElements, {
|
||||
@@ -3587,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...updateActiveTool(
|
||||
this.state,
|
||||
prevState.activeTool.locked
|
||||
? { type: "selection" }
|
||||
? { type: this.defaultSelectionTool }
|
||||
: prevState.activeTool,
|
||||
),
|
||||
locked: !prevState.activeTool.locked,
|
||||
@@ -4500,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.selectionElement &&
|
||||
!this.state.selectedElementsAreBeingDragged
|
||||
) {
|
||||
const shape = findShapeByKey(event.key);
|
||||
const shape = findShapeByKey(event.key, this);
|
||||
if (shape) {
|
||||
if (this.state.activeTool.type !== shape) {
|
||||
trackEvent(
|
||||
@@ -4593,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: "selection" });
|
||||
this.setActiveTool({ type: this.defaultSelectionTool });
|
||||
} else {
|
||||
this.setActiveTool({ type: "laser" });
|
||||
}
|
||||
@@ -5438,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
// we should only be able to double click when mode is selection
|
||||
if (this.state.activeTool.type !== "selection") {
|
||||
if (this.state.activeTool.type !== this.defaultSelectionTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6050,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
hasDeselectedButton ||
|
||||
(this.state.activeTool.type !== "selection" &&
|
||||
this.state.activeTool.type !== "lasso" &&
|
||||
this.state.activeTool.type !== "text" &&
|
||||
this.state.activeTool.type !== "eraser")
|
||||
) {
|
||||
@@ -6212,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(hitElement) ||
|
||||
!(hitElement.startBinding || hitElement.endBinding)
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
selectedElements.length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
if (this.state.activeEmbeddable?.state === "hover") {
|
||||
this.setState({ activeEmbeddable: null });
|
||||
}
|
||||
@@ -6329,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
Object.keys(this.state.selectedElementIds).length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
@@ -6338,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
Object.keys(this.state.selectedElementIds).length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6600,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (this.state.activeTool.type === "lasso") {
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
const hitSelectedElement =
|
||||
pointerDownState.hit.element &&
|
||||
this.isASelectedElement(pointerDownState.hit.element);
|
||||
|
||||
const isMobileOrTablet = this.isMobileOrTablet();
|
||||
|
||||
if (
|
||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
|
||||
!pointerDownState.resize.handleType &&
|
||||
!hitSelectedElement
|
||||
) {
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
|
||||
// block dragging after lasso selection on PCs until the next pointer down
|
||||
// (on mobile or tablet, we want to allow user to drag immediately)
|
||||
pointerDownState.drag.blockDragging = !isMobileOrTablet;
|
||||
}
|
||||
|
||||
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
|
||||
if (
|
||||
isMobileOrTablet &&
|
||||
pointerDownState.hit.element &&
|
||||
!hitSelectedElement
|
||||
) {
|
||||
this.setState((prevState) => {
|
||||
const nextSelectedElementIds: { [id: string]: true } = {
|
||||
...prevState.selectedElementIds,
|
||||
[pointerDownState.hit.element!.id]: true,
|
||||
};
|
||||
|
||||
const previouslySelectedElements: ExcalidrawElement[] = [];
|
||||
|
||||
Object.keys(prevState.selectedElementIds).forEach((id) => {
|
||||
const element = this.scene.getElement(id);
|
||||
element && previouslySelectedElements.push(element);
|
||||
});
|
||||
|
||||
const hitElement = pointerDownState.hit.element!;
|
||||
|
||||
// if hitElement is frame-like, deselect all of its elements
|
||||
// if they are selected
|
||||
if (isFrameLikeElement(hitElement)) {
|
||||
getFrameChildren(previouslySelectedElements, hitElement.id).forEach(
|
||||
(element) => {
|
||||
delete nextSelectedElementIds[element.id];
|
||||
},
|
||||
);
|
||||
} else if (hitElement.frameId) {
|
||||
// if hitElement is in a frame and its frame has been selected
|
||||
// disable selection for the given element
|
||||
if (nextSelectedElementIds[hitElement.frameId]) {
|
||||
delete nextSelectedElementIds[hitElement.id];
|
||||
}
|
||||
} else {
|
||||
// hitElement is neither a frame nor an element in a frame
|
||||
// but since hitElement could be in a group with some frames
|
||||
// this means selecting hitElement will have the frames selected as well
|
||||
// because we want to keep the invariant:
|
||||
// - frames and their elements are not selected at the same time
|
||||
// we deselect elements in those frames that were previously selected
|
||||
|
||||
const groupIds = hitElement.groupIds;
|
||||
const framesInGroups = new Set(
|
||||
groupIds
|
||||
.flatMap((gid) =>
|
||||
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
|
||||
)
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((frame) => frame.id),
|
||||
);
|
||||
|
||||
if (framesInGroups.size > 0) {
|
||||
previouslySelectedElements.forEach((element) => {
|
||||
if (element.frameId && framesInGroups.has(element.frameId)) {
|
||||
// deselect element and groups containing the element
|
||||
delete nextSelectedElementIds[element.id];
|
||||
element.groupIds
|
||||
.flatMap((gid) =>
|
||||
getElementsInGroup(
|
||||
this.scene.getNonDeletedElements(),
|
||||
gid,
|
||||
),
|
||||
)
|
||||
.forEach((element) => {
|
||||
delete nextSelectedElementIds[element.id];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: prevState.editingGroupId,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
prevState,
|
||||
this,
|
||||
),
|
||||
showHyperlinkPopup:
|
||||
hitElement.link || isEmbeddableElement(hitElement)
|
||||
? "info"
|
||||
: false,
|
||||
};
|
||||
});
|
||||
pointerDownState.hit.wasAddedToSelection = true;
|
||||
}
|
||||
} else if (this.state.activeTool.type === "text") {
|
||||
this.handleTextOnPointerDown(event, pointerDownState);
|
||||
} else if (
|
||||
@@ -6984,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
hasOccurred: false,
|
||||
offset: null,
|
||||
origin: { ...origin },
|
||||
blockDragging: false,
|
||||
},
|
||||
eventListeners: {
|
||||
onMove: null,
|
||||
@@ -7059,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
pointerDownState: PointerDownState,
|
||||
): boolean => {
|
||||
if (this.state.activeTool.type === "selection") {
|
||||
if (
|
||||
this.state.activeTool.type === "selection" ||
|
||||
this.state.activeTool.type === "lasso"
|
||||
) {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
@@ -7266,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
if (event.altKey) {
|
||||
// ctrl + alt means we're lasso selecting
|
||||
// ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
|
||||
|
||||
// Close any open dialogs that might interfere with lasso selection
|
||||
if (this.state.openDialog?.name === "elementLinkSelector") {
|
||||
this.setOpenDialog(null);
|
||||
}
|
||||
this.lassoTrail.startPath(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
event.shiftKey,
|
||||
);
|
||||
this.setActiveTool({ type: "lasso", fromSelection: true });
|
||||
return false;
|
||||
}
|
||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||
@@ -7487,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
if (!this.state.activeTool.locked) {
|
||||
this.setState({
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8271,15 +8435,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event.shiftKey &&
|
||||
this.state.selectedLinearElement.elementId ===
|
||||
pointerDownState.hit.element?.id;
|
||||
|
||||
if (
|
||||
(hasHitASelectedElement ||
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
|
||||
!isSelectingPointsInLineEditor &&
|
||||
this.state.activeTool.type !== "lasso"
|
||||
!pointerDownState.drag.blockDragging
|
||||
) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
if (selectedElements.every((element) => element.locked)) {
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every((element) => element.locked)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8300,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// if elements should be deselected on pointerup
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
|
||||
// prevent immediate dragging during lasso selection to avoid element displacement
|
||||
// only allow dragging if we're not in the middle of lasso selection
|
||||
// (on mobile, allow dragging if we hit an element)
|
||||
if (
|
||||
this.state.activeTool.type === "lasso" &&
|
||||
this.lassoTrail.hasCurrentTrail &&
|
||||
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
|
||||
!this.state.activeTool.fromSelection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear lasso trail when starting to drag selected elements with lasso tool
|
||||
// Only clear if we're actually dragging (not during lasso selection)
|
||||
if (
|
||||
this.state.activeTool.type === "lasso" &&
|
||||
selectedElements.length > 0 &&
|
||||
pointerDownState.drag.hasOccurred &&
|
||||
!this.state.activeTool.fromSelection
|
||||
) {
|
||||
this.lassoTrail.endPath();
|
||||
}
|
||||
|
||||
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||
// it would have weird results (stuff jumping all over the screen)
|
||||
// Checking for editingTextElement to avoid jump while editing on mobile #6503
|
||||
@@ -8894,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
): (event: PointerEvent) => void {
|
||||
return withBatchedUpdates((childEvent: PointerEvent) => {
|
||||
this.removePointer(childEvent);
|
||||
pointerDownState.drag.blockDragging = false;
|
||||
if (pointerDownState.eventListeners.onMove) {
|
||||
pointerDownState.eventListeners.onMove.flush();
|
||||
}
|
||||
@@ -9182,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: "selection",
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
@@ -9798,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
newElement: null,
|
||||
suggestedBindings: [],
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
@@ -10092,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState(
|
||||
{
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
}),
|
||||
},
|
||||
() => {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -10465,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event.nativeEvent.pointerType === "pen" &&
|
||||
// always allow if user uses a pen secondary button
|
||||
event.button !== POINTER_BUTTON.SECONDARY)) &&
|
||||
this.state.activeTool.type !== "selection"
|
||||
this.state.activeTool.type !== this.defaultSelectionTool
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user