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
This commit is contained in:
David Luzar 2023-01-23 16:12:28 +01:00 committed by GitHub
parent 849e6a0c86
commit d4afd66268
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 273 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?.()}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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