feat: drag, resize, and rotate after selecting in lasso (#9732)

* feat: drag, resize, and rotate after selecting in lasso

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2025-08-20 08:03:02 +10:00
committed by GitHub
parent c6f8ef9ad2
commit b4903a7eab
10 changed files with 353 additions and 107 deletions

View File

@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] &&

View File

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

View File

@@ -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: {