479 lines
13 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
};
|