mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-14 17:54:47 +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:
@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const 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 =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@@ -298,7 +298,9 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
|
||||
@@ -261,13 +261,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { SHAPES } from "./shapes";
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const lassoToolSelected = activeTool.type === "lasso";
|
||||
const lassoToolSelected =
|
||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
@@ -303,10 +304,14 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
@@ -359,7 +364,8 @@ export const ShapesSwitcher = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
},
|
||||
)}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@@ -418,6 +424,7 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
{app.defaultSelectionTool !== "lasso" && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
@@ -426,6 +433,7 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
|
||||
@@ -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")
|
||||
) {
|
||||
@@ -6211,8 +6235,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
!isElbowArrow(hitElement) ||
|
||||
!(hitElement.startBinding || hitElement.endBinding)
|
||||
) {
|
||||
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 });
|
||||
}
|
||||
@@ -6328,19 +6357,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
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)) {
|
||||
if (
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
if (
|
||||
this.state.activeTool.type !== "lasso" ||
|
||||
Object.keys(this.state.selectedElementIds).length > 0
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
|
||||
@@ -6600,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
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(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@@ -86,8 +88,23 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
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 (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
|
||||
selectedElements: NonDeletedExcalidrawElement[];
|
||||
}) => {
|
||||
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 (
|
||||
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] &&
|
||||
|
||||
@@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1116226695,
|
||||
"seed": 400692809,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 23633383,
|
||||
"versionNonce": 81784553,
|
||||
"width": 20,
|
||||
"x": 20,
|
||||
"y": 30,
|
||||
@@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"seed": 449462985,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
|
||||
@@ -731,6 +731,8 @@ export type AppClassProperties = {
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
updateEditorAtom: App["updateEditorAtom"];
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso";
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -780,6 +782,10 @@ export type PointerDownState = Readonly<{
|
||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||
// to current pointer position at time of duplication.
|
||||
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
|
||||
eventListeners: {
|
||||
|
||||
Reference in New Issue
Block a user