Compare commits

...

19 Commits

Author SHA1 Message Date
7f0b97a163 fix tests 2025-07-23 19:09:12 +10:00
358f687b4f lint 2025-07-23 18:42:28 +10:00
4053ced148 switch to default selection tool after pasting 2025-07-23 18:39:46 +10:00
691ece340f paste to center on touch screen 2025-07-23 18:15:35 +10:00
1489b6a740 set to default selection tool after unlocking tool 2025-07-23 18:10:50 +10:00
2132c9ac44 double click to add text when using default selection tool 2025-07-23 17:58:01 +10:00
285134405b return to default selection tool after creation 2025-07-23 17:55:08 +10:00
5c449839ba toggle between laser and default selection 2025-07-23 17:46:14 +10:00
edc894fd04 finalize to default selection tool 2025-07-23 17:43:39 +10:00
4e20c8d722 if default lasso, close lasso toggle 2025-07-23 17:31:07 +10:00
0118f9b1b0 return to default tool after eraser toggle 2025-07-23 17:30:08 +10:00
385cb347bb reset to default tool after clearing out the canvas 2025-07-23 17:27:36 +10:00
c182115c92 return to default selection tool after deletion 2025-07-23 17:09:33 +10:00
d29c8e7d32 render according to default selection tool 2025-07-23 17:00:38 +10:00
19d434c366 add default selection tool 2025-07-23 17:00:08 +10:00
9eaf4385c5 add mobile lasso icon 2025-07-21 18:05:31 +10:00
69676fb325 improve mobile dection 2025-07-18 12:23:18 +02:00
9b644169ae alternative: drag after selection on PCs 2025-07-18 10:22:41 +10:00
85dc55c718 alternatvie: keep lasso drag to only mobile 2025-07-17 17:29:32 +10:00
8 changed files with 134 additions and 41 deletions

View File

@ -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;

View File

@ -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"];

View File

@ -291,7 +291,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,

View File

@ -240,13 +240,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,
});
}

View File

@ -63,6 +63,7 @@ import {
laserPointerToolIcon,
MagicIcon,
LassoIcon,
LassoIconMobile,
} from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
@ -295,15 +296,31 @@ 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";
const { TTDDialogTriggerTunnel } = useTunnels();
// we'll need to update SHAPES to swap lasso and selection
const _SHAPES =
app.defaultSelectionTool === "lasso"
? ([
{
value: "lasso",
icon: LassoIconMobile,
key: KEYS.L,
numericKey: KEYS["1"],
fillable: true,
},
...SHAPES.slice(1),
] as const)
: SHAPES;
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
{_SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
@ -418,14 +435,16 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>

View File

@ -100,6 +100,7 @@ import {
randomInteger,
CLASSES,
Emitter,
isMobile,
} from "@excalidraw/common";
import {
@ -649,9 +650,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,
@ -2337,6 +2343,7 @@ class App extends React.Component<AppProps, AppState> {
};
}
const scene = restore(initialData, null, null, { repairBindings: true });
const activeTool = scene.appState.activeTool;
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@ -2346,8 +2353,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,
@ -2386,6 +2398,15 @@ 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 ||
@ -3104,7 +3125,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) {
@ -3122,7 +3143,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
});
return;
@ -3182,7 +3203,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();
},
);
@ -3326,7 +3347,7 @@ class App extends React.Component<AppProps, AppState> {
}
},
);
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool }, true);
if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, {
@ -3572,7 +3593,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,
@ -4561,7 +4582,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" });
}
@ -5403,7 +5424,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;
}
@ -6584,8 +6605,8 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
// Start a new lasso ONLY if we're not interacting with an existing
// selection (move/resize/rotate).
const isMobileOrTablet = this.isMobileOrTablet();
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
@ -6596,10 +6617,18 @@ class App extends React.Component<AppProps, AppState> {
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;
}
// For lasso tool, if we hit an element, select it immediately like normal selection
if (pointerDownState.hit.element && !hitSelectedElement) {
// 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,
@ -7064,6 +7093,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false,
offset: null,
origin: { ...origin },
blockDragging: false,
},
eventListeners: {
onMove: null,
@ -7586,7 +7616,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,
}),
});
}
};
@ -8385,14 +8417,9 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.element?.id;
if (
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements ||
(this.state.activeTool.type === "lasso" &&
pointerDownState.hit.element)) &&
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor &&
(this.state.activeTool.type !== "lasso" ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements ||
hasHitASelectedElement ||
pointerDownState.hit.element)
!pointerDownState.drag.blockDragging
) {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
@ -9026,6 +9053,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();
}
@ -9285,7 +9313,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: "selection",
type: this.defaultSelectionTool,
}),
selectedElementIds: makeNextSelectedElementIds(
{
@ -9897,7 +9925,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({
@ -10191,7 +10221,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);

View File

@ -314,6 +314,28 @@ export const LassoIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
export const LassoIconMobile = createIcon(
<g>
<path
d="M2.5 12
C2.5 6.5, 8 4.5, 15 7.5
C21 9, 22 12, 20 14.5
C18 17, 11.5 17.5, 7 16
C4 15.2, 2.5 13.5, 2.5 12Z"
fill="none"
stroke="black"
strokeDasharray="2 2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
{
width: 24,
height: 24,
strokeWidth: 1.5,
},
);
// tabler-icons: square
export const RectangleIcon = createIcon(
<g strokeWidth="1.5">

View File

@ -733,6 +733,8 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso";
};
export type PointerDownState = Readonly<{
@ -782,6 +784,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: {