mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-15 02:04:21 +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 =
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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] &&
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user