snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
478
packages/excalidraw/components/Tools.tsx
Normal file
478
packages/excalidraw/components/Tools.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user