Browse Source

feat: add hand/panning tool (#6141)

* feat: add hand/panning tool

* move hand tool right of tool lock separator

* tweak i18n

* rename `panning` -> `hand`

* toggle between last tool and hand on `H` shortcut

* hide properties sidebar when `hand` active

* revert to rendering HandButton manually due to mobile toolbar
David Luzar 2 years ago
parent
commit
d4afd66268

+ 45 - 21
src/actions/actionCanvas.tsx

@@ -1,7 +1,7 @@
 import { ColorPicker } from "../components/ColorPicker";
-import { eraser, ZoomInIcon, ZoomOutIcon } from "../components/icons";
+import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
-import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
+import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
 import { getCommonBounds, getNonDeletedElements } from "../element";
 import { ExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
@@ -10,12 +10,15 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
 import { AppState, NormalizedZoomValue } from "../types";
-import { getShortcutKey, updateActiveTool } from "../utils";
+import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
 import { register } from "./register";
 import { Tooltip } from "../components/Tooltip";
 import { newElementWith } from "../element/mutateElement";
-import { getDefaultAppState, isEraserActive } from "../appState";
-import clsx from "clsx";
+import {
+  getDefaultAppState,
+  isEraserActive,
+  isHandToolActive,
+} from "../appState";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
@@ -306,15 +309,15 @@ export const actionToggleTheme = register({
   },
 });
 
-export const actionErase = register({
-  name: "eraser",
+export const actionToggleEraserTool = register({
+  name: "toggleEraserTool",
   trackEvent: { category: "toolbar" },
   perform: (elements, appState) => {
     let activeTool: AppState["activeTool"];
 
     if (isEraserActive(appState)) {
       activeTool = updateActiveTool(appState, {
-        ...(appState.activeTool.lastActiveToolBeforeEraser || {
+        ...(appState.activeTool.lastActiveTool || {
           type: "selection",
         }),
         lastActiveToolBeforeEraser: null,
@@ -337,17 +340,38 @@ export const actionErase = register({
     };
   },
   keyTest: (event) => event.key === KEYS.E,
-  PanelComponent: ({ elements, appState, updateData, data }) => (
-    <ToolButton
-      type="button"
-      icon={eraser}
-      className={clsx("eraser", { active: isEraserActive(appState) })}
-      title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
-      aria-label={t("toolBar.eraser")}
-      onClick={() => {
-        updateData(null);
-      }}
-      size={data?.size || "medium"}
-    ></ToolButton>
-  ),
+});
+
+export const actionToggleHandTool = register({
+  name: "toggleHandTool",
+  trackEvent: { category: "toolbar" },
+  perform: (elements, appState, _, app) => {
+    let activeTool: AppState["activeTool"];
+
+    if (isHandToolActive(appState)) {
+      activeTool = updateActiveTool(appState, {
+        ...(appState.activeTool.lastActiveTool || {
+          type: "selection",
+        }),
+        lastActiveToolBeforeEraser: null,
+      });
+    } else {
+      activeTool = updateActiveTool(appState, {
+        type: "hand",
+        lastActiveToolBeforeEraser: appState.activeTool,
+      });
+      setCursor(app.canvas, CURSOR_TYPE.GRAB);
+    }
+
+    return {
+      appState: {
+        ...appState,
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        activeTool,
+      },
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.key === KEYS.H,
 });

+ 1 - 1
src/actions/actionFinalize.tsx

@@ -145,7 +145,7 @@ export const actionFinalize = register({
     let activeTool: AppState["activeTool"];
     if (appState.activeTool.type === "eraser") {
       activeTool = updateActiveTool(appState, {
-        ...(appState.activeTool.lastActiveToolBeforeEraser || {
+        ...(appState.activeTool.lastActiveTool || {
           type: "selection",
         }),
         lastActiveToolBeforeEraser: null,

+ 3 - 2
src/actions/types.ts

@@ -109,10 +109,11 @@ export type ActionName =
   | "decreaseFontSize"
   | "unbindText"
   | "hyperlink"
-  | "eraser"
   | "bindText"
   | "toggleLock"
-  | "toggleLinearEditor";
+  | "toggleLinearEditor"
+  | "toggleEraserTool"
+  | "toggleHandTool";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 9 - 1
src/appState.ts

@@ -45,7 +45,7 @@ export const getDefaultAppState = (): Omit<
       type: "selection",
       customType: null,
       locked: false,
-      lastActiveToolBeforeEraser: null,
+      lastActiveTool: null,
     },
     penMode: false,
     penDetected: false,
@@ -228,3 +228,11 @@ export const isEraserActive = ({
 }: {
   activeTool: AppState["activeTool"];
 }) => activeTool.type === "eraser";
+
+export const isHandToolActive = ({
+  activeTool,
+}: {
+  activeTool: AppState["activeTool"];
+}) => {
+  return activeTool.type === "hand";
+};

+ 4 - 3
src/components/Actions.tsx

@@ -219,9 +219,10 @@ export const ShapesSwitcher = ({
   <>
     {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
       const label = t(`toolBar.${value}`);
-      const letter = key && (typeof key === "string" ? key : key[0]);
+      const letter =
+        key && capitalizeString(typeof key === "string" ? key : key[0]);
       const shortcut = letter
-        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
+        ? `${letter} ${t("helpDialog.or")} ${numericKey}`
         : `${numericKey}`;
       return (
         <ToolButton
@@ -232,7 +233,7 @@ export const ShapesSwitcher = ({
           checked={activeTool.type === value}
           name="editor-current-shape"
           title={`${capitalizeString(label)} — ${shortcut}`}
-          keyBindingLabel={numericKey}
+          keyBindingLabel={numericKey || letter}
           aria-label={capitalizeString(label)}
           aria-keyshortcuts={shortcut}
           data-testid={`toolbar-${value}`}

+ 26 - 5
src/components/App.tsx

@@ -41,7 +41,11 @@ import { ActionManager } from "../actions/manager";
 import { actions } from "../actions/register";
 import { ActionResult } from "../actions/types";
 import { trackEvent } from "../analytics";
-import { getDefaultAppState, isEraserActive } from "../appState";
+import {
+  getDefaultAppState,
+  isEraserActive,
+  isHandToolActive,
+} from "../appState";
 import { parseClipboard } from "../clipboard";
 import {
   APP_NAME,
@@ -274,6 +278,7 @@ import {
 import { shouldShowBoundingBox } from "../element/transformHandles";
 import { Fonts } from "../scene/Fonts";
 import { actionPaste } from "../actions/actionClipboard";
+import { actionToggleHandTool } from "../actions/actionCanvas";
 
 const deviceContextInitialValue = {
   isSmScreen: false,
@@ -575,6 +580,7 @@ class App extends React.Component<AppProps, AppState> {
                       elements={this.scene.getNonDeletedElements()}
                       onLockToggle={this.toggleLock}
                       onPenModeToggle={this.togglePenMode}
+                      onHandToolToggle={this.onHandToolToggle}
                       onInsertElements={(elements) =>
                         this.addElementsFromPasteOrLibrary({
                           elements,
@@ -1812,6 +1818,10 @@ class App extends React.Component<AppProps, AppState> {
     });
   };
 
+  onHandToolToggle = () => {
+    this.actionManager.executeAction(actionToggleHandTool);
+  };
+
   scrollToContent = (
     target:
       | ExcalidrawElement
@@ -2229,11 +2239,13 @@ class App extends React.Component<AppProps, AppState> {
 
   private setActiveTool = (
     tool:
-      | { type: typeof SHAPES[number]["value"] | "eraser" }
+      | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
       | { type: "custom"; customType: string },
   ) => {
     const nextActiveTool = updateActiveTool(this.state, tool);
-    if (!isHoldingSpace) {
+    if (nextActiveTool.type === "hand") {
+      setCursor(this.canvas, CURSOR_TYPE.GRAB);
+    } else if (!isHoldingSpace) {
       setCursorForShape(this.canvas, this.state);
     }
     if (isToolIcon(document.activeElement)) {
@@ -2904,7 +2916,12 @@ class App extends React.Component<AppProps, AppState> {
           null;
     }
 
-    if (isHoldingSpace || isPanning || isDraggingScrollBar) {
+    if (
+      isHoldingSpace ||
+      isPanning ||
+      isDraggingScrollBar ||
+      isHandToolActive(this.state)
+    ) {
       return;
     }
 
@@ -3496,7 +3513,10 @@ class App extends React.Component<AppProps, AppState> {
       );
     } else if (this.state.activeTool.type === "custom") {
       setCursor(this.canvas, CURSOR_TYPE.AUTO);
-    } else if (this.state.activeTool.type !== "eraser") {
+    } else if (
+      this.state.activeTool.type !== "eraser" &&
+      this.state.activeTool.type !== "hand"
+    ) {
       this.createGenericElementOnPointerDown(
         this.state.activeTool.type,
         pointerDownState,
@@ -3607,6 +3627,7 @@ class App extends React.Component<AppProps, AppState> {
         gesture.pointers.size <= 1 &&
         (event.button === POINTER_BUTTON.WHEEL ||
           (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
+          isHandToolActive(this.state) ||
           this.state.viewModeEnabled)
       ) ||
       isTextElement(this.state.editingElement)

+ 32 - 0
src/components/HandButton.tsx

@@ -0,0 +1,32 @@
+import "./ToolIcon.scss";
+
+import clsx from "clsx";
+import { ToolButton } from "./ToolButton";
+import { handIcon } from "./icons";
+import { KEYS } from "../keys";
+
+type LockIconProps = {
+  title?: string;
+  name?: string;
+  checked: boolean;
+  onChange?(): void;
+  isMobile?: boolean;
+};
+
+export const HandButton = (props: LockIconProps) => {
+  return (
+    <ToolButton
+      className={clsx("Shape", { fillable: false })}
+      type="radio"
+      icon={handIcon}
+      name="editor-current-shape"
+      checked={props.checked}
+      title={`${props.title} — H`}
+      keyBindingLabel={!props.isMobile ? KEYS.H.toLocaleUpperCase() : undefined}
+      aria-label={`${props.title} — H`}
+      aria-keyshortcuts={KEYS.H}
+      data-testid={`toolbar-hand`}
+      onChange={() => props.onChange?.()}
+    />
+  );
+};

+ 8 - 1
src/components/HelpDialog.tsx

@@ -69,6 +69,10 @@ function* intersperse(as: JSX.Element[][], delim: string | null) {
   }
 }
 
+const upperCaseSingleChars = (str: string) => {
+  return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
+};
+
 const Shortcut = ({
   label,
   shortcuts,
@@ -83,7 +87,9 @@ const Shortcut = ({
       ? [...shortcut.slice(0, -2).split("+"), "+"]
       : shortcut.split("+");
 
-    return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
+    return keys.map((key) => (
+      <ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
+    ));
   });
 
   return (
@@ -120,6 +126,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
             className="HelpDialog__island--tools"
             caption={t("helpDialog.tools")}
           >
+            <Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
             <Shortcut
               label={t("toolBar.selection")}
               shortcuts={[KEYS.V, KEYS["1"]]}

+ 15 - 6
src/components/LayerUI.tsx

@@ -50,6 +50,8 @@ import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
+import { HandButton } from "./HandButton";
+import { isHandToolActive } from "../appState";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -59,6 +61,7 @@ interface LayerUIProps {
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   onLockToggle: () => void;
+  onHandToolToggle: () => void;
   onPenModeToggle: () => void;
   onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
   showExitZenModeBtn: boolean;
@@ -85,6 +88,7 @@ const LayerUI = ({
   elements,
   canvas,
   onLockToggle,
+  onHandToolToggle,
   onPenModeToggle,
   onInsertElements,
   showExitZenModeBtn,
@@ -304,13 +308,20 @@ const LayerUI = ({
                             penDetected={appState.penDetected}
                           />
                           <LockButton
-                            zenModeEnabled={appState.zenModeEnabled}
                             checked={appState.activeTool.locked}
-                            onChange={() => onLockToggle()}
+                            onChange={onLockToggle}
                             title={t("toolBar.lock")}
                           />
+
                           <div className="App-toolbar__divider"></div>
 
+                          <HandButton
+                            checked={isHandToolActive(appState)}
+                            onChange={() => onHandToolToggle()}
+                            title={t("toolBar.hand")}
+                            isMobile
+                          />
+
                           <ShapesSwitcher
                             appState={appState}
                             canvas={canvas}
@@ -322,9 +333,6 @@ const LayerUI = ({
                               });
                             }}
                           />
-                          {/* {actionManager.renderAction("eraser", {
-                          // size: "small",
-                        })} */}
                         </Stack.Row>
                       </Island>
                     </Stack.Row>
@@ -408,7 +416,8 @@ const LayerUI = ({
           renderJSONExportDialog={renderJSONExportDialog}
           renderImageExportDialog={renderImageExportDialog}
           setAppState={setAppState}
-          onLockToggle={() => onLockToggle()}
+          onLockToggle={onLockToggle}
+          onHandToolToggle={onHandToolToggle}
           onPenModeToggle={onPenModeToggle}
           canvas={canvas}
           onImageAction={onImageAction}

+ 0 - 1
src/components/LockButton.tsx

@@ -9,7 +9,6 @@ type LockIconProps = {
   name?: string;
   checked: boolean;
   onChange?(): void;
-  zenModeEnabled?: boolean;
   isMobile?: boolean;
 };
 

+ 17 - 7
src/components/MobileMenu.tsx

@@ -22,6 +22,8 @@ import { LibraryButton } from "./LibraryButton";
 import { PenModeButton } from "./PenModeButton";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
+import { HandButton } from "./HandButton";
+import { isHandToolActive } from "../appState";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -31,6 +33,7 @@ type MobileMenuProps = {
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   onLockToggle: () => void;
+  onHandToolToggle: () => void;
   onPenModeToggle: () => void;
   canvas: HTMLCanvasElement | null;
 
@@ -52,6 +55,7 @@ export const MobileMenu = ({
   actionManager,
   setAppState,
   onLockToggle,
+  onHandToolToggle,
   onPenModeToggle,
   canvas,
   onImageAction,
@@ -88,6 +92,13 @@ export const MobileMenu = ({
                 </Island>
                 {renderTopRightUI && renderTopRightUI(true, appState)}
                 <div className="mobile-misc-tools-container">
+                  {!appState.viewModeEnabled && (
+                    <LibraryButton
+                      appState={appState}
+                      setAppState={setAppState}
+                      isMobile
+                    />
+                  )}
                   <PenModeButton
                     checked={appState.penMode}
                     onChange={onPenModeToggle}
@@ -101,13 +112,12 @@ export const MobileMenu = ({
                     title={t("toolBar.lock")}
                     isMobile
                   />
-                  {!appState.viewModeEnabled && (
-                    <LibraryButton
-                      appState={appState}
-                      setAppState={setAppState}
-                      isMobile
-                    />
-                  )}
+                  <HandButton
+                    checked={isHandToolActive(appState)}
+                    onChange={() => onHandToolToggle()}
+                    title={t("toolBar.hand")}
+                    isMobile
+                  />
                 </div>
               </Stack.Row>
             </Stack.Col>

+ 1 - 1
src/components/ToolButton.tsx

@@ -19,7 +19,7 @@ type ToolButtonBaseProps = {
   name?: string;
   id?: string;
   size?: ToolButtonSize;
-  keyBindingLabel?: string;
+  keyBindingLabel?: string | null;
   showAriaLabel?: boolean;
   hidden?: boolean;
   visible?: boolean;

+ 11 - 0
src/components/icons.tsx

@@ -1532,3 +1532,14 @@ export const publishIcon = createIcon(
 export const eraser = createIcon(
   <path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
 );
+
+export const handIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5"></path>
+    <path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5"></path>
+    <path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5"></path>
+    <path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
+  </g>,
+  tablerIconProps,
+);

+ 1 - 0
src/css/styles.scss

@@ -549,6 +549,7 @@
     border-top-left-radius: var(--border-radius-lg);
     border-bottom-left-radius: var(--border-radius-lg);
     border-right: 0;
+    overflow: hidden;
 
     background-color: var(--island-bg-color);
 

+ 2 - 1
src/data/restore.ts

@@ -55,6 +55,7 @@ export const AllowedExcalidrawActiveTools: Record<
   freedraw: true,
   eraser: false,
   custom: true,
+  hand: true,
 };
 
 export type RestoredDataState = {
@@ -465,7 +466,7 @@ export const restoreAppState = (
           ? nextAppState.activeTool
           : { type: "selection" },
       ),
-      lastActiveToolBeforeEraser: null,
+      lastActiveTool: null,
       locked: nextAppState.activeTool.locked ?? false,
     },
     // Migrates from previous version where appState.zoom was a number

+ 2 - 1
src/element/showSelectedShapeActions.ts

@@ -11,6 +11,7 @@ export const showSelectedShapeActions = (
       appState.activeTool.type !== "custom" &&
       (appState.editingElement ||
         (appState.activeTool.type !== "selection" &&
-          appState.activeTool.type !== "eraser"))) ||
+          appState.activeTool.type !== "eraser" &&
+          appState.activeTool.type !== "hand"))) ||
       getSelectedElements(elements, appState).length,
   );

+ 3 - 2
src/locales/en.json

@@ -220,7 +220,8 @@
     "lock": "Keep selected tool active after drawing",
     "penMode": "Pen mode - prevent touch",
     "link": "Add/ Update link for a selected shape",
-    "eraser": "Eraser"
+    "eraser": "Eraser",
+    "hand": "Hand (panning tool)"
   },
   "headings": {
     "canvasActions": "Canvas actions",
@@ -228,7 +229,7 @@
     "shapes": "Shapes"
   },
   "hints": {
-    "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging",
+    "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
     "linearElement": "Click to start multiple points, drag for single line",
     "freeDraw": "Click and drag, release when you're finished",
     "text": "Tip: you can also add text by double-clicking anywhere with the selection tool",

+ 17 - 17
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -4,7 +4,7 @@ exports[`contextMenu element right-clicking on a group should select whole group
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -434,7 +434,7 @@ exports[`contextMenu element selecting 'Add to library' in context menu adds ele
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -620,7 +620,7 @@ exports[`contextMenu element selecting 'Bring forward' in context menu brings el
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -975,7 +975,7 @@ exports[`contextMenu element selecting 'Bring to front' in context menu brings e
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -1330,7 +1330,7 @@ exports[`contextMenu element selecting 'Copy styles' in context menu copies styl
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -1516,7 +1516,7 @@ exports[`contextMenu element selecting 'Delete' in context menu deletes element:
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -1738,7 +1738,7 @@ exports[`contextMenu element selecting 'Duplicate' in context menu duplicates el
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -2023,7 +2023,7 @@ exports[`contextMenu element selecting 'Group selection' in context menu groups
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -2396,7 +2396,7 @@ exports[`contextMenu element selecting 'Paste styles' in context menu pastes sty
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3243,7 +3243,7 @@ exports[`contextMenu element selecting 'Send backward' in context menu sends ele
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3598,7 +3598,7 @@ exports[`contextMenu element selecting 'Send to back' in context menu sends elem
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3953,7 +3953,7 @@ exports[`contextMenu element selecting 'Ungroup selection' in context menu ungro
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4392,7 +4392,7 @@ exports[`contextMenu element shows 'Group selection' in context menu for multipl
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4933,7 +4933,7 @@ exports[`contextMenu element shows 'Ungroup selection' in context menu for group
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -5559,7 +5559,7 @@ exports[`contextMenu element shows context menu for canvas: [end of test] appSta
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -5773,7 +5773,7 @@ exports[`contextMenu element shows context menu for element: [end of test] appSt
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -6110,7 +6110,7 @@ exports[`contextMenu element shows context menu for element: [end of test] appSt
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },

+ 53 - 53
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -4,7 +4,7 @@ exports[`given element A and group of elements B and given both are selected whe
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -540,7 +540,7 @@ exports[`given element A and group of elements B and given both are selected whe
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -1082,7 +1082,7 @@ exports[`regression tests Cmd/Ctrl-click exclusively select element under pointe
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -1989,7 +1989,7 @@ exports[`regression tests Drags selected element when hitting only bounding box
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -2219,7 +2219,7 @@ exports[`regression tests adjusts z order when grouping: [end of test] appState
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -2752,7 +2752,7 @@ exports[`regression tests alt-drag duplicates an element: [end of test] appState
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3041,7 +3041,7 @@ exports[`regression tests arrow keys: [end of test] appState 1`] = `
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3225,7 +3225,7 @@ exports[`regression tests can drag element that covers another element, while an
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -3741,7 +3741,7 @@ exports[`regression tests change the properties of a shape: [end of test] appSta
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4009,7 +4009,7 @@ exports[`regression tests click on an element and drag it: [dragged] appState 1`
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4239,7 +4239,7 @@ exports[`regression tests click on an element and drag it: [end of test] appStat
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4515,7 +4515,7 @@ exports[`regression tests click to select a shape: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -4803,7 +4803,7 @@ exports[`regression tests click-drag to select a group: [end of test] appState 1
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -5221,7 +5221,7 @@ exports[`regression tests deselects group of selected elements on pointer down w
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -5562,7 +5562,7 @@ exports[`regression tests deselects group of selected elements on pointer up whe
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -5876,7 +5876,7 @@ exports[`regression tests deselects selected element on pointer down when pointe
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -6114,7 +6114,7 @@ exports[`regression tests deselects selected element, on pointer up, when click
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -6300,7 +6300,7 @@ exports[`regression tests double click to edit a group: [end of test] appState 1
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -6828,7 +6828,7 @@ exports[`regression tests drags selected elements from point inside common bound
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -7193,7 +7193,7 @@ exports[`regression tests draw every type of shape: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "freedraw",
   },
@@ -9545,7 +9545,7 @@ exports[`regression tests given a group of selected elements with an element tha
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -9964,7 +9964,7 @@ exports[`regression tests given a selected element A and a not selected element
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -10253,7 +10253,7 @@ exports[`regression tests given selected element A with lower z-index than unsel
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -10501,7 +10501,7 @@ exports[`regression tests given selected element A with lower z-index than unsel
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -10822,7 +10822,7 @@ exports[`regression tests key 2 selects rectangle tool: [end of test] appState 1
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -11006,7 +11006,7 @@ exports[`regression tests key 3 selects diamond tool: [end of test] appState 1`]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -11190,7 +11190,7 @@ exports[`regression tests key 4 selects ellipse tool: [end of test] appState 1`]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -11374,7 +11374,7 @@ exports[`regression tests key 5 selects arrow tool: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -11611,7 +11611,7 @@ exports[`regression tests key 6 selects line tool: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -11848,7 +11848,7 @@ exports[`regression tests key 7 selects freedraw tool: [end of test] appState 1`
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "freedraw",
   },
@@ -12076,7 +12076,7 @@ exports[`regression tests key a selects arrow tool: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -12313,7 +12313,7 @@ exports[`regression tests key d selects diamond tool: [end of test] appState 1`]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -12497,7 +12497,7 @@ exports[`regression tests key l selects line tool: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -12734,7 +12734,7 @@ exports[`regression tests key o selects ellipse tool: [end of test] appState 1`]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -12918,7 +12918,7 @@ exports[`regression tests key p selects freedraw tool: [end of test] appState 1`
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "freedraw",
   },
@@ -13146,7 +13146,7 @@ exports[`regression tests key r selects rectangle tool: [end of test] appState 1
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -13330,7 +13330,7 @@ exports[`regression tests make a group and duplicate it: [end of test] appState
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -14169,7 +14169,7 @@ exports[`regression tests noop interaction after undo shouldn't create history e
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -14458,7 +14458,7 @@ exports[`regression tests pinch-to-zoom works: [end of test] appState 1`] = `
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -14569,7 +14569,7 @@ exports[`regression tests rerenders UI on language change: [end of test] appStat
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "rectangle",
   },
@@ -14678,7 +14678,7 @@ exports[`regression tests shift click on selected element should deselect it on
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -14865,7 +14865,7 @@ exports[`regression tests shift-click to multiselect, then drag: [end of test] a
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -15233,7 +15233,7 @@ exports[`regression tests should group elements and ungroup them: [end of test]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -15864,7 +15864,7 @@ exports[`regression tests should show fill icons when element has non transparen
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -16090,7 +16090,7 @@ exports[`regression tests single-clicking on a subgroup of a selected group shou
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -17053,7 +17053,7 @@ exports[`regression tests spacebar + drag scrolls the canvas: [end of test] appS
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -17162,7 +17162,7 @@ exports[`regression tests supports nested groups: [end of test] appState 1`] = `
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -18021,7 +18021,7 @@ exports[`regression tests switches from group of selected elements to another el
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -18493,7 +18493,7 @@ exports[`regression tests switches selected element on pointer down: [end of tes
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -18834,7 +18834,7 @@ exports[`regression tests two-finger scroll works: [end of test] appState 1`] =
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -18945,7 +18945,7 @@ exports[`regression tests undo/redo drawing an element: [end of test] appState 1
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },
@@ -19516,7 +19516,7 @@ exports[`regression tests updates fontSize & fontFamily appState: [end of test]
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "text",
   },
@@ -19625,7 +19625,7 @@ exports[`regression tests zoom hotkeys: [end of test] appState 1`] = `
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },

+ 1 - 1
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -4,7 +4,7 @@ exports[`exportToSvg with default arguments 1`] = `
 Object {
   "activeTool": Object {
     "customType": null,
-    "lastActiveToolBeforeEraser": null,
+    "lastActiveTool": null,
     "locked": false,
     "type": "selection",
   },

+ 13 - 9
src/types.ts

@@ -81,9 +81,9 @@ export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
 
 export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
 
-export type LastActiveToolBeforeEraser =
+export type LastActiveTool =
   | {
-      type: typeof SHAPES[number]["value"] | "eraser";
+      type: typeof SHAPES[number]["value"] | "eraser" | "hand";
       customType: null;
     }
   | {
@@ -112,19 +112,23 @@ export type AppState = {
   // (e.g. text element when typing into the input)
   editingElement: NonDeletedExcalidrawElement | null;
   editingLinearElement: LinearElementEditor | null;
-  activeTool:
+  activeTool: {
+    /**
+     * indicates a previous tool we should revert back to if we deselect the
+     * currently active tool. At the moment applies to `eraser` and `hand` tool.
+     */
+    lastActiveTool: LastActiveTool;
+    locked: boolean;
+  } & (
     | {
-        type: typeof SHAPES[number]["value"] | "eraser";
-        lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
-        locked: boolean;
+        type: typeof SHAPES[number]["value"] | "eraser" | "hand";
         customType: null;
       }
     | {
         type: "custom";
         customType: string;
-        lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
-        locked: boolean;
-      };
+      }
+  );
   penMode: boolean;
   penDetected: boolean;
   exportBackground: boolean;

+ 9 - 6
src/utils.ts

@@ -12,10 +12,11 @@ import {
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 import { FontFamilyValues, FontString } from "./element/types";
-import { AppState, DataURL, LastActiveToolBeforeEraser, Zoom } from "./types";
+import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { SHAPES } from "./shapes";
 import React from "react";
+import { isEraserActive, isHandToolActive } from "./appState";
 
 let mockDateTime: string | null = null;
 
@@ -219,9 +220,9 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
 export const updateActiveTool = (
   appState: Pick<AppState, "activeTool">,
   data: (
-    | { type: typeof SHAPES[number]["value"] | "eraser" }
+    | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
     | { type: "custom"; customType: string }
-  ) & { lastActiveToolBeforeEraser?: LastActiveToolBeforeEraser },
+  ) & { lastActiveToolBeforeEraser?: LastActiveTool },
 ): AppState["activeTool"] => {
   if (data.type === "custom") {
     return {
@@ -233,9 +234,9 @@ export const updateActiveTool = (
 
   return {
     ...appState.activeTool,
-    lastActiveToolBeforeEraser:
+    lastActiveTool:
       data.lastActiveToolBeforeEraser === undefined
-        ? appState.activeTool.lastActiveToolBeforeEraser
+        ? appState.activeTool.lastActiveTool
         : data.lastActiveToolBeforeEraser,
     type: data.type,
     customType: null,
@@ -305,7 +306,9 @@ export const setCursorForShape = (
   }
   if (appState.activeTool.type === "selection") {
     resetCursor(canvas);
-  } else if (appState.activeTool.type === "eraser") {
+  } else if (isHandToolActive(appState)) {
+    canvas.style.cursor = CURSOR_TYPE.GRAB;
+  } else if (isEraserActive(appState)) {
     setEraserCursor(canvas, appState.theme);
     // do nothing if image tool is selected which suggests there's
     // a image-preview set as the cursor