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