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:
Ryan Di
2025-08-20 08:03:02 +10:00
committed by GitHub
parent c6f8ef9ad2
commit b4903a7eab
10 changed files with 353 additions and 107 deletions

View File

@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari = export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1; !isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS = export const isIOS =
/iPad|iPhone/.test(navigator.platform) || /iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+ // iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document); (navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test // keeping function so it can be mocked in test
export const isBrave = () => export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave"; (navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent.toLowerCase(),
) ||
/android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;

View File

@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog, pasteDialog: appState.pasteDialog,
activeTool: activeTool:
appState.activeTool.type === "image" appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" } ? { ...appState.activeTool, type: app.defaultSelectionTool }
: appState.activeTool, : appState.activeTool,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool", name: "toggleEraserTool",
label: "toolBar.eraser", label: "toolBar.eraser",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) { if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.defaultSelectionTool,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso", label: "toolBar.lasso",
icon: LassoIcon, icon: LassoIcon,
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"]; let activeTool: AppState["activeTool"];

View File

@@ -298,7 +298,9 @@ export const actionDeleteSelected = register({
elements: nextElements, elements: nextElements,
appState: { appState: {
...nextAppState, ...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }), activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,

View File

@@ -261,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") { if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || { ...(appState.activeTool.lastActiveTool || {
type: "selection", type: app.defaultSelectionTool,
}), }),
lastActiveToolBeforeEraser: null, lastActiveToolBeforeEraser: null,
}); });
} else { } else {
activeTool = updateActiveTool(appState, { activeTool = updateActiveTool(appState, {
type: "selection", type: app.defaultSelectionTool,
}); });
} }

View File

@@ -46,7 +46,7 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { SHAPES } from "./shapes"; import { getToolbarTools } from "./shapes";
import "./Actions.scss"; import "./Actions.scss";
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame"; const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso"; const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable"; const embeddableToolSelected = activeTool.type === "embeddable";
@@ -303,10 +304,14 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
if ( if (
UIOptions.tools?.[ UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]> value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false ] === false
) { ) {
return null; return null;
@@ -359,7 +364,8 @@ export const ShapesSwitcher = ({
}} }}
/> />
); );
})} },
)}
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}> <DropdownMenu open={isExtraToolsMenuOpen}>
@@ -418,6 +424,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })} onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon} icon={LassoIcon}
@@ -426,6 +433,7 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.lasso")} {t("toolBar.lasso")}
</DropdownMenu.Item> </DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate Generate
</div> </div>

View File

@@ -100,6 +100,7 @@ import {
randomInteger, randomInteger,
CLASSES, CLASSES,
Emitter, Emitter,
isMobile,
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -653,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
>(); >();
onRemoveEventListenersEmitter = new Emitter<[]>(); onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const { const {
excalidrawAPI, excalidrawAPI,
viewModeEnabled = false, viewModeEnabled = false,
@@ -1606,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
renderWelcomeScreen={ renderWelcomeScreen={
!this.state.isLoading && !this.state.isLoading &&
this.state.showWelcomeScreen && this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" && this.state.activeTool.type ===
this.defaultSelectionTool &&
!this.state.zenModeEnabled && !this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
@@ -2350,6 +2357,7 @@ class App extends React.Component<AppProps, AppState> {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
}); });
const activeTool = scene.appState.activeTool;
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
theme: this.props.theme || scene.appState.theme, 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) // with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar, openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool: activeTool:
scene.appState.activeTool.type === "image" activeTool.type === "image" ||
? { ...scene.appState.activeTool, type: "selection" } activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
}
: scene.appState.activeTool, : scene.appState.activeTool,
isLoading: false, isLoading: false,
toast: this.state.toast, 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) => { private isMobileBreakpoint = (width: number, height: number) => {
return ( return (
width < MQ_MAX_WIDTH_PORTRAIT || width < MQ_MAX_WIDTH_PORTRAIT ||
@@ -3117,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files: data.files || null, files: data.files || null,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste, retainSeed: isPlainPaste,
}); });
} else if (data.text) { } else if (data.text) {
@@ -3135,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
files, files,
position: "cursor", position: this.isMobileOrTablet() ? "center" : "cursor",
}); });
return; return;
@@ -3195,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
} }
this.addTextFromPaste(data.text, isPlainPaste); this.addTextFromPaste(data.text, isPlainPaste);
} }
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault(); 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) { if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, { this.scrollToContent(duplicatedElements, {
@@ -3587,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool( ...updateActiveTool(
this.state, this.state,
prevState.activeTool.locked prevState.activeTool.locked
? { type: "selection" } ? { type: this.defaultSelectionTool }
: prevState.activeTool, : prevState.activeTool,
), ),
locked: !prevState.activeTool.locked, locked: !prevState.activeTool.locked,
@@ -4500,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.selectionElement && !this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged !this.state.selectedElementsAreBeingDragged
) { ) {
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key, this);
if (shape) { if (shape) {
if (this.state.activeTool.type !== shape) { if (this.state.activeTool.type !== shape) {
trackEvent( 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 (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") { if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: this.defaultSelectionTool });
} else { } else {
this.setActiveTool({ type: "laser" }); this.setActiveTool({ type: "laser" });
} }
@@ -5438,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// we should only be able to double click when mode is selection // 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; return;
} }
@@ -6050,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
hasDeselectedButton || hasDeselectedButton ||
(this.state.activeTool.type !== "selection" && (this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
) { ) {
@@ -6211,8 +6235,13 @@ class App extends React.Component<AppProps, AppState> {
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(hitElement) || !isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding) !(hitElement.startBinding || hitElement.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
selectedElements.length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
} }
@@ -6328,19 +6357,29 @@ class App extends React.Component<AppProps, AppState> {
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} }
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if ( if (
// Ebow arrows can only be moved when unconnected // Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} }
}
if ( if (
this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
@@ -6600,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
} }
if (this.state.activeTool.type === "lasso") { if (this.state.activeTool.type === "lasso") {
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( this.lassoTrail.startPath(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
event.shiftKey, 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") { } else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState); this.handleTextOnPointerDown(event, pointerDownState);
} else if ( } else if (
@@ -6984,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false, hasOccurred: false,
offset: null, offset: null,
origin: { ...origin }, origin: { ...origin },
blockDragging: false,
}, },
eventListeners: { eventListeners: {
onMove: null, onMove: null,
@@ -7059,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
): boolean => { ): boolean => {
if (this.state.activeTool.type === "selection") { if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state); 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. // 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 (event.altKey) { 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; return false;
} }
if (!this.state.selectedElementIds[hitElement.id]) { if (!this.state.selectedElementIds[hitElement.id]) {
@@ -7487,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) { if (!this.state.activeTool.locked) {
this.setState({ 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 && event.shiftKey &&
this.state.selectedLinearElement.elementId === this.state.selectedLinearElement.elementId ===
pointerDownState.hit.element?.id; pointerDownState.hit.element?.id;
if ( if (
(hasHitASelectedElement || (hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor && !isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso" !pointerDownState.drag.blockDragging
) { ) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (
if (selectedElements.every((element) => element.locked)) { selectedElements.length > 0 &&
selectedElements.every((element) => element.locked)
) {
return; return;
} }
@@ -8300,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup // if elements should be deselected on pointerup
pointerDownState.drag.hasOccurred = true; 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 // prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen) // it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503 // 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 { ): (event: PointerEvent) => void {
return withBatchedUpdates((childEvent: PointerEvent) => { return withBatchedUpdates((childEvent: PointerEvent) => {
this.removePointer(childEvent); this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) { if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush(); pointerDownState.eventListeners.onMove.flush();
} }
@@ -9182,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({ this.setState((prevState) => ({
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: "selection", type: this.defaultSelectionTool,
}), }),
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
{ {
@@ -9798,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
newElement: null, newElement: null,
suggestedBindings: [], suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}); });
} else { } else {
this.setState({ this.setState({
@@ -10092,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState( this.setState(
{ {
newElement: null, newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
}, },
() => { () => {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -10465,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" && event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button // always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) && event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== "selection" this.state.activeTool.type !== this.defaultSelectionTool
) { ) {
return; return;
} }

View File

@@ -13,6 +13,8 @@ import {
EraserIcon, EraserIcon,
} from "./icons"; } from "./icons";
import type { AppClassProperties } from "../types";
export const SHAPES = [ export const SHAPES = [
{ {
icon: SelectionIcon, icon: SelectionIcon,
@@ -86,8 +88,23 @@ export const SHAPES = [
}, },
] as const; ] as const;
export const findShapeByKey = (key: string) => { export const getToolbarTools = (app: AppClassProperties) => {
const shape = SHAPES.find((shape, index) => { return app.defaultSelectionTool === "lasso"
? ([
{
value: "lasso",
icon: SelectionIcon,
key: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
},
...SHAPES.slice(1),
] as const)
: SHAPES;
};
export const findShapeByKey = (key: string, app: AppClassProperties) => {
const shape = getToolbarTools(app).find((shape, index) => {
return ( return (
(shape.numericKey != null && key === shape.numericKey.toString()) || (shape.numericKey != null && key === shape.numericKey.toString()) ||
(shape.key && (shape.key &&

View File

@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
selectedElements: NonDeletedExcalidrawElement[]; selectedElements: NonDeletedExcalidrawElement[];
}) => { }) => {
if (event) { if (event) {
// Allow snapping for lasso tool when dragging selected elements
// but not during lasso selection phase
const isLassoDragging =
app.state.activeTool.type === "lasso" &&
app.state.selectedElementsAreBeingDragged;
return ( return (
app.state.activeTool.type !== "lasso" && (app.state.activeTool.type !== "lasso" || isLassoDragging) &&
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) || ((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled && (!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&

View File

@@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1116226695, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 23633383, "versionNonce": 81784553,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 1150084233,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,

View File

@@ -731,6 +731,8 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"]; onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"]; updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso";
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -780,6 +782,10 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// Whether to block drag after lasso selection
// this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
eventListeners: { eventListeners: {