Files
upstream-excalidraw/packages/excalidraw/components/Tools.tsx
2026-09-16 05:31:04 +05:00

479 lines
13 KiB
TypeScript

import clsx from "clsx";
import { useEffect, useState } from "react";
import { KEYS, capitalizeString } from "@excalidraw/common";
import type { PointerType } from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { IconButton } from "./IconButton";
import { ToolPopover } from "./ToolPopover";
import {
SelectionIcon,
RectangleIcon,
DiamondIcon,
EllipseIcon,
ArrowIcon,
LineIcon,
FreedrawIcon,
drawShapeToolIcon,
TextIcon,
ImageIcon,
EraserIcon,
laserPointerToolIcon,
bucketFillIcon,
LassoIcon,
handIcon,
frameToolIcon,
EmbedIcon,
stickyNoteToolIcon,
} from "./icons";
import type {
AppClassProperties,
AppState,
ToolType,
UIAppState,
} from "../types";
export type ToolConfig = {
icon: React.ReactNode;
/** letter shortcut(s) — the first one is shown in tooltips and badges */
letterKey?: string | readonly string[];
/** whether `letterKey` requires Shift to be held (e.g. Shift+X) */
shiftKey?: boolean;
numericKey?: string;
/** whether the tool's shapes can be filled — fills the icon when active */
fillable?: boolean;
/**
* re-activating the tool switches back to the previously active tool
* (via the keyboard shortcut or ESC — see `setActiveTool`'s `toggle`
* option)
*/
toggle?: boolean;
};
/** preserves the exact keys while typing every entry as ToolConfig */
const defineTools = <T extends Record<string, ToolConfig>>(tools: T) =>
tools as { [K in keyof T]: ToolConfig };
/**
* Tool data — the single source of truth for tool buttons, keyboard
* shortcuts (`findShapeByKey`), and the command palette. Toolbar placement
* is not defined here: toolbars compose the `*ToolButton` components below
* manually, so entries without a toolbar slot (e.g. laser) are data-only.
*/
export const TOOLS = defineTools({
hand: {
icon: handIcon,
letterKey: KEYS.H,
toggle: true,
},
selection: {
icon: SelectionIcon,
letterKey: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
},
rectangle: {
icon: RectangleIcon,
letterKey: KEYS.R,
numericKey: KEYS["2"],
fillable: true,
},
diamond: {
icon: DiamondIcon,
letterKey: KEYS.D,
numericKey: KEYS["3"],
fillable: true,
},
ellipse: {
icon: EllipseIcon,
letterKey: KEYS.O,
numericKey: KEYS["4"],
fillable: true,
},
arrow: {
icon: ArrowIcon,
letterKey: KEYS.A,
numericKey: KEYS["5"],
fillable: true,
},
line: {
icon: LineIcon,
letterKey: KEYS.L,
numericKey: KEYS["6"],
fillable: true,
},
freedraw: {
icon: FreedrawIcon,
letterKey: [KEYS.P, KEYS.X],
numericKey: KEYS["7"],
},
text: {
icon: TextIcon,
letterKey: KEYS.T,
numericKey: KEYS["8"],
},
stickynote: {
icon: stickyNoteToolIcon,
letterKey: KEYS.N,
},
image: {
icon: ImageIcon,
numericKey: KEYS["9"],
},
eraser: {
icon: EraserIcon,
letterKey: KEYS.E,
numericKey: KEYS["0"],
toggle: true,
},
frame: {
icon: frameToolIcon,
letterKey: KEYS.F,
},
autoshape: {
icon: drawShapeToolIcon,
letterKey: KEYS.X,
shiftKey: true,
fillable: false,
},
embeddable: {
icon: EmbedIcon,
},
laser: {
icon: laserPointerToolIcon,
letterKey: KEYS.K,
},
bucketfill: {
icon: bucketFillIcon,
letterKey: KEYS.B,
},
lasso: {
icon: LassoIcon,
fillable: false,
},
});
export type ToolbarToolType = keyof typeof TOOLS;
/**
* tools that, when activated while already active, switch back to the
* previously active tool (see `setActiveTool`'s `toggle` option)
*/
export const TOGGLE_TOOLS: readonly (ToolType | "custom")[] = (
Object.keys(TOOLS) as ToolbarToolType[]
).filter((type) => TOOLS[type].toggle);
export const getToolLetter = (type: ToolbarToolType) => {
const { letterKey, shiftKey } = TOOLS[type];
if (!letterKey) {
return letterKey;
}
const letter = capitalizeString(
typeof letterKey === "string" ? letterKey : letterKey[0],
);
return shiftKey ? getShortcutKey(`Shift+${letter}`) : letter;
};
/** human-readable shortcut hint, e.g. "R or 2", used in tooltips & aria */
export const getToolShortcut = (type: ToolbarToolType) => {
const letter = getToolLetter(type);
const { numericKey } = TOOLS[type];
return letter && numericKey != null
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${letter || numericKey}`;
};
export const findShapeByKey = (
key: string,
app: AppClassProperties,
shiftKey: boolean = false,
) => {
// CapsLock-insensitive: the caller excludes every modifier but shift, and
// shift is matched explicitly below, so a capital letter on its own means
// CapsLock
const lowerKey = key.toLowerCase();
for (const type of Object.keys(TOOLS) as ToolbarToolType[]) {
const { letterKey, numericKey, shiftKey: requiresShift } = TOOLS[type];
// shift-bound tools require shift; plain-bound ones require its absence
if (shiftKey !== Boolean(requiresShift)) {
continue;
}
if (
(numericKey != null && key === numericKey) ||
(letterKey &&
(typeof letterKey === "string"
? letterKey === lowerKey
: letterKey.includes(lowerKey)))
) {
// the selection shortcut activates whichever selection tool the user
// prefers (selection or lasso)
return type === "selection"
? app.state.preferredSelectionTool.type
: type;
}
}
return null;
};
/**
* Whether a toolbar entry activating the given tool renders disabled — true
* when the active tool is host-controlled (`props.activeTool`) and the entry
* doesn't activate the forced tool (`setActiveTool` refuses it).
*/
export const isToolButtonDisabled = (app: AppClassProperties, type: string) =>
app.props.activeTool != null && app.props.activeTool.type !== type;
export type ToolButtonComponentProps = {
app: AppClassProperties;
activeTool: UIAppState["activeTool"];
/** hide the keybinding badge rendered in the button's corner */
hideKeyBinding?: boolean;
/**
* hide all shortcut affordances (tooltip hint, aria-keyshortcuts, and the
* keybinding badge) — used on mobile where there's no keyboard
*/
hideShortcut?: boolean;
};
type ToolButtonBehavior = {
/**
* display the shortcut of another tool (tooltip, aria, keybinding badge) —
* e.g. the lasso button shows the selection shortcut, which activates it
* when lasso is the preferred selection tool
*/
shortcutType?: ToolbarToolType;
/**
* custom activation — replaces the default track + `setActiveTool` (pen
* detection still runs before it)
*/
onSelect?: (
app: AppClassProperties,
data: { pointerType: PointerType | null },
) => void;
};
/**
* Creates a toolbar button component for the given tool. Activation is
* uniform: track + `setActiveTool` (recording the previous tool for toggle
* tools), with pen detection on the first pen interaction.
*/
const createToolButton = (
type: ToolbarToolType,
behavior?: ToolButtonBehavior,
) => {
const config = TOOLS[type];
const shortcutType = behavior?.shortcutType ?? type;
const ToolButtonComponent = ({
app,
activeTool,
hideKeyBinding,
hideShortcut,
}: ToolButtonComponentProps) => {
const label = capitalizeString(t(`toolBar.${type}`));
const shortcut = hideShortcut ? null : getToolShortcut(shortcutType);
return (
<IconButton
className={clsx({ fillable: config.fillable })}
type="toggle"
icon={config.icon}
checked={activeTool.type === type}
disabled={isToolButtonDisabled(app, type)}
title={shortcut ? `${label} — ${shortcut}` : label}
keyBindingLabel={
hideKeyBinding || hideShortcut
? undefined
: getToolLetter(shortcutType) || TOOLS[shortcutType].numericKey
}
aria-label={label}
aria-keyshortcuts={shortcut ?? undefined}
data-testid={`toolbar-${type}`}
onPointerDown={(event) => {
// a draggable tool can be dragged out onto the canvas; a press that
// stays put is still a click (`onSelect`)
app.toolDrag.handleButtonPointerDown(type, event.nativeEvent);
}}
onSelect={({ pointerType }) => {
if (!app.state.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
if (behavior?.onSelect) {
behavior.onSelect(app, { pointerType });
return;
}
if (app.state.activeTool.type !== type) {
trackEvent("toolbar", type, "ui");
app.setActiveTool({ type });
}
}}
/>
);
};
ToolButtonComponent.displayName = `${capitalizeString(type)}IconButton`;
return ToolButtonComponent;
};
export const HandToolButton = createToolButton("hand");
export const RectangleToolButton = createToolButton("rectangle");
export const DiamondToolButton = createToolButton("diamond");
export const EllipseToolButton = createToolButton("ellipse");
export const ArrowToolButton = createToolButton("arrow");
export const LineToolButton = createToolButton("line");
export const FreedrawToolButton = createToolButton("freedraw");
export const TextToolButton = createToolButton("text");
export const StickyNoteToolButton = createToolButton("stickynote");
export const ImageToolButton = createToolButton("image");
export const EraserToolButton = createToolButton("eraser");
export const FrameToolButton = createToolButton("frame");
/**
* The selection tool button — pointer-clicking it while the selection tool
* is active switches to lasso.
*/
export const SelectionToolButton = createToolButton("selection", {
onSelect: (app, { pointerType }) => {
if (app.state.activeTool.type === "selection" && pointerType !== null) {
// pointer-clicking the active selection tool switches to lasso;
// keyboard/AT activation stays on selection
app.setActiveTool({ type: "lasso" });
return;
}
if (app.state.activeTool.type !== "selection") {
trackEvent("toolbar", "selection", "ui");
app.setActiveTool({ type: "selection" });
}
},
});
/**
* Rendered in place of the selection button when lasso is the preferred
* selection tool; the selection shortcut activates it then.
*/
export const LassoToolButton = createToolButton("lasso", {
shortcutType: "selection",
});
/**
* The selection ⇄ lasso popover used in compact (tablet) and mobile
* toolbars; picking an option also makes it the preferred selection tool.
*/
export const SelectionToolPopover = ({
app,
activeTool,
setAppState,
}: {
app: AppClassProperties;
activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"];
}) => {
const SELECTION_TOOLS = [
{
type: "selection",
icon: TOOLS.selection.icon,
fillable: TOOLS.selection.fillable,
title: capitalizeString(t("toolBar.selection")),
},
{
type: "lasso",
icon: TOOLS.lasso.icon,
fillable: TOOLS.lasso.fillable,
title: capitalizeString(t("toolBar.lasso")),
},
] as const;
const displayedOption =
SELECTION_TOOLS.find(
(tool) => tool.type === app.state.preferredSelectionTool.type,
) || SELECTION_TOOLS[0];
return (
<ToolPopover
app={app}
options={SELECTION_TOOLS}
activeTool={activeTool}
defaultOption={app.state.preferredSelectionTool.type}
data-testid="toolbar-selection"
onToolChange={(type: string) => {
if (type === "selection" || type === "lasso") {
app.setActiveTool({ type });
setAppState({
preferredSelectionTool: { type, initialized: true },
});
}
}}
displayedOption={displayedOption}
/>
);
};
/**
* The freedraw ⇄ draw-shape popover used in compact (tablet) and mobile
* toolbars. The trigger remembers and displays the most recently used option.
*/
export const FreedrawToolPopover = ({
app,
activeTool,
}: {
app: AppClassProperties;
activeTool: UIAppState["activeTool"];
}) => {
const DRAWING_TOOLS = [
{
type: "freedraw",
icon: TOOLS.freedraw.icon,
fillable: TOOLS.freedraw.fillable,
title: capitalizeString(t("toolBar.freedraw")),
},
{
type: "autoshape",
icon: TOOLS.autoshape.icon,
fillable: TOOLS.autoshape.fillable,
title: capitalizeString(t("toolBar.autoshape")),
},
] as const;
const [lastDrawingTool, setLastDrawingTool] = useState<
typeof DRAWING_TOOLS[number]["type"]
>(activeTool.type === "autoshape" ? "autoshape" : "freedraw");
useEffect(() => {
if (activeTool.type === "freedraw" || activeTool.type === "autoshape") {
setLastDrawingTool(activeTool.type);
}
}, [activeTool.type]);
const displayedOption =
DRAWING_TOOLS.find((tool) => tool.type === lastDrawingTool) ||
DRAWING_TOOLS[0];
return (
<ToolPopover
app={app}
options={DRAWING_TOOLS}
activeTool={activeTool}
defaultOption={lastDrawingTool}
data-testid="toolbar-freedraw"
onToolChange={(type: string) => {
if (type === "freedraw" || type === "autoshape") {
setLastDrawingTool(type);
app.setActiveTool({ type });
}
}}
displayedOption={displayedOption}
/>
);
};