From d4afd6626850befdb000d86c203e7a604f8a871c Mon Sep 17 00:00:00 2001 From: David Luzar Date: Mon, 23 Jan 2023 16:12:28 +0100 Subject: [PATCH] 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 --- src/actions/actionCanvas.tsx | 66 +++++++---- src/actions/actionFinalize.tsx | 2 +- src/actions/types.ts | 5 +- src/appState.ts | 10 +- src/components/Actions.tsx | 7 +- src/components/App.tsx | 31 ++++- src/components/HandButton.tsx | 32 ++++++ src/components/HelpDialog.tsx | 9 +- src/components/LayerUI.tsx | 21 +++- src/components/LockButton.tsx | 1 - src/components/MobileMenu.tsx | 24 ++-- src/components/ToolButton.tsx | 2 +- src/components/icons.tsx | 11 ++ src/css/styles.scss | 1 + src/data/restore.ts | 3 +- src/element/showSelectedShapeActions.ts | 3 +- src/locales/en.json | 5 +- .../__snapshots__/contextmenu.test.tsx.snap | 34 +++--- .../regressionTests.test.tsx.snap | 106 +++++++++--------- .../packages/__snapshots__/utils.test.ts.snap | 2 +- src/types.ts | 22 ++-- src/utils.ts | 15 ++- 22 files changed, 273 insertions(+), 139 deletions(-) create mode 100644 src/components/HandButton.tsx diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 259b43e0..1154d1ef 100644 --- a/src/actions/actionCanvas.tsx +++ b/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 }) => ( - { - updateData(null); - }} - size={data?.size || "medium"} - > - ), +}); + +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, }); diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 2e78a1a3..3508de0a 100644 --- a/src/actions/actionFinalize.tsx +++ b/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, diff --git a/src/actions/types.ts b/src/actions/types.ts index 4a7a4fe5..54bd5a26 100644 --- a/src/actions/types.ts +++ b/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[]; diff --git a/src/appState.ts b/src/appState.ts index d1cbe92f..f02d5943 100644 --- a/src/appState.ts +++ b/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"; +}; diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index fe017f77..2ee9babf 100644 --- a/src/components/Actions.tsx +++ b/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 ( { 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 { }); }; + onHandToolToggle = () => { + this.actionManager.executeAction(actionToggleHandTool); + }; + scrollToContent = ( target: | ExcalidrawElement @@ -2229,11 +2239,13 @@ class App extends React.Component { 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 { null; } - if (isHoldingSpace || isPanning || isDraggingScrollBar) { + if ( + isHoldingSpace || + isPanning || + isDraggingScrollBar || + isHandToolActive(this.state) + ) { return; } @@ -3496,7 +3513,10 @@ class App extends React.Component { ); } 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 { 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) diff --git a/src/components/HandButton.tsx b/src/components/HandButton.tsx new file mode 100644 index 00000000..ce63791e --- /dev/null +++ b/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 ( + props.onChange?.()} + /> + ); +}; diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 4eea5d69..69f3e6a5 100644 --- a/src/components/HelpDialog.tsx +++ b/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) => {key}); + return keys.map((key) => ( + {upperCaseSingleChars(key)} + )); }); return ( @@ -120,6 +126,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { className="HelpDialog__island--tools" caption={t("helpDialog.tools")} > + ["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} /> onLockToggle()} + onChange={onLockToggle} title={t("toolBar.lock")} /> +
+ onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + /> + - {/* {actionManager.renderAction("eraser", { - // size: "small", - })} */} @@ -408,7 +416,8 @@ const LayerUI = ({ renderJSONExportDialog={renderJSONExportDialog} renderImageExportDialog={renderImageExportDialog} setAppState={setAppState} - onLockToggle={() => onLockToggle()} + onLockToggle={onLockToggle} + onHandToolToggle={onHandToolToggle} onPenModeToggle={onPenModeToggle} canvas={canvas} onImageAction={onImageAction} diff --git a/src/components/LockButton.tsx b/src/components/LockButton.tsx index cbcf2b33..a039a577 100644 --- a/src/components/LockButton.tsx +++ b/src/components/LockButton.tsx @@ -9,7 +9,6 @@ type LockIconProps = { name?: string; checked: boolean; onChange?(): void; - zenModeEnabled?: boolean; isMobile?: boolean; }; diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 54929243..7c814f53 100644 --- a/src/components/MobileMenu.tsx +++ b/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["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 = ({ {renderTopRightUI && renderTopRightUI(true, appState)}
+ {!appState.viewModeEnabled && ( + + )} - {!appState.viewModeEnabled && ( - - )} + onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + />
diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index 7077898f..446d154d 100644 --- a/src/components/ToolButton.tsx +++ b/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; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index cc43aaa4..046ee490 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1532,3 +1532,14 @@ export const publishIcon = createIcon( export const eraser = createIcon( , ); + +export const handIcon = createIcon( + + + + + + + , + tablerIconProps, +); diff --git a/src/css/styles.scss b/src/css/styles.scss index cff9aaf9..6fe14bb5 100644 --- a/src/css/styles.scss +++ b/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); diff --git a/src/data/restore.ts b/src/data/restore.ts index 976a0551..bb779cf0 100644 --- a/src/data/restore.ts +++ b/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 diff --git a/src/element/showSelectedShapeActions.ts b/src/element/showSelectedShapeActions.ts index 14a7694e..29f8d9fc 100644 --- a/src/element/showSelectedShapeActions.ts +++ b/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, ); diff --git a/src/locales/en.json b/src/locales/en.json index b0e5a2c5..1a46b20e 100644 --- a/src/locales/en.json +++ b/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", diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 8633e88b..326cde0e 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/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", }, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index ba2a82ac..35f9eb7c 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/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", }, diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index a74da5dc..b1002a40 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/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", }, diff --git a/src/types.ts b/src/types.ts index 60e25e0e..49e09a76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,9 +81,9 @@ export type BinaryFileMetadata = Omit; export type BinaryFiles = Record; -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; diff --git a/src/utils.ts b/src/utils.ts index a332e921..80aa03f1 100644 --- a/src/utils.ts +++ b/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, 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