excalidraw/packages/excalidraw/components/CommandPalette/CommandPalette.tsx

916 lines
26 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import {
useApp,
useAppProps,
useExcalidrawActionManager,
useExcalidrawSetAppState,
} from "../App";
import { KEYS } from "../../keys";
import { Dialog } from "../Dialog";
import { TextField } from "../TextField";
import clsx from "clsx";
import { getSelectedElements } from "../../scene";
import { Action } from "../../actions/types";
import { TranslationKeys, t } from "../../i18n";
import {
ShortcutName,
getShortcutFromShortcutName,
} from "../../actions/shortcuts";
import { DEFAULT_SIDEBAR, EVENT } from "../../constants";
import {
LockedIcon,
UnlockedIcon,
clockIcon,
searchIcon,
boltIcon,
bucketFillIcon,
ExportImageIcon,
mermaidLogoIcon,
brainIconThin,
LibraryIcon,
} from "../icons";
import fuzzy from "fuzzy";
import { useUIAppState } from "../../context/ui-appState";
import { AppProps, AppState, UIAppState } from "../../types";
import {
capitalizeString,
getShortcutKey,
isWritableElement,
} from "../../utils";
import { atom, useAtom } from "jotai";
import { deburr } from "../../deburr";
import { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
export const DEFAULT_CATEGORIES = {
app: "App",
export: "Export",
tools: "Tools",
editor: "Editor",
elements: "Elements",
links: "Links",
};
const getCategoryOrder = (category: string) => {
switch (category) {
case DEFAULT_CATEGORIES.app:
return 1;
case DEFAULT_CATEGORIES.export:
return 2;
case DEFAULT_CATEGORIES.editor:
return 3;
case DEFAULT_CATEGORIES.tools:
return 4;
case DEFAULT_CATEGORIES.elements:
return 5;
case DEFAULT_CATEGORIES.links:
return 6;
default:
return 10;
}
};
const CommandShortcutHint = ({
shortcut,
className,
children,
}: {
shortcut: string;
className?: string;
children?: React.ReactNode;
}) => {
const shortcuts = shortcut.split(/(?<!\+)(?:\+)/g);
return (
<div className={clsx("shortcut", className)}>
{shortcuts.map((item) => {
return (
<div className="shortcut-wrapper" key={item}>
<div className="shortcut-key">{item}</div>
</div>
);
})}
<div className="shortcut-desc">{children}</div>
</div>
);
};
const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => {
return (
!event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
((event.shiftKey && event.key.toLowerCase() === KEYS.P) ||
event.key === KEYS.SLASH)
);
};
type CommandPaletteProps = {
customCommandPaletteItems?: CommandPaletteItem[];
};
export const CommandPalette = Object.assign(
(props: CommandPaletteProps) => {
const uiAppState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
useEffect(() => {
const commandPaletteShortcut = (event: KeyboardEvent) => {
if (isCommandPaletteToggleShortcut(event)) {
event.preventDefault();
event.stopPropagation();
setAppState((appState) => ({
openDialog:
appState.openDialog?.name === "commandPalette"
? null
: { name: "commandPalette" },
}));
}
};
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
capture: true,
});
return () =>
window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
capture: true,
});
}, [setAppState]);
if (uiAppState.openDialog?.name !== "commandPalette") {
return null;
}
return <CommandPaletteInner {...props} />;
},
{
defaultItems,
},
);
function CommandPaletteInner({
customCommandPaletteItems,
}: CommandPaletteProps) {
const app = useApp();
const uiAppState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const appProps = useAppProps();
const actionManager = useExcalidrawActionManager();
const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem);
const [allCommands, setAllCommands] = useState<
MarkRequired<CommandPaletteItem, "haystack" | "order">[] | null
>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!uiAppState || !app.scene || !actionManager) {
return;
}
const getActionLabel = (action: Action) => {
let label = "";
if (action.label) {
if (typeof action.label === "function") {
label = t(
action.label(
app.scene.getNonDeletedElements(),
uiAppState as AppState,
app,
) as unknown as TranslationKeys,
);
} else {
label = t(action.label as unknown as TranslationKeys);
}
}
return label;
};
const getActionIcon = (action: Action) => {
if (typeof action.icon === "function") {
return action.icon(uiAppState, app.scene.getNonDeletedElements());
}
return action.icon;
};
let commandsFromActions: CommandPaletteItem[] = [];
const actionToCommand = (
action: Action,
category: string,
transformer?: (
command: CommandPaletteItem,
action: Action,
) => CommandPaletteItem,
): CommandPaletteItem => {
const command: CommandPaletteItem = {
label: getActionLabel(action),
icon: getActionIcon(action),
category,
shortcut: getShortcutFromShortcutName(action.name as ShortcutName),
keywords: action.keywords,
predicate: action.predicate,
viewMode: action.viewMode,
perform: () => {
actionManager.executeAction(action, "commandPalette");
},
};
return transformer ? transformer(command, action) : command;
};
if (uiAppState && app.scene && actionManager) {
const elementsCommands: CommandPaletteItem[] = [
actionManager.actions.group,
actionManager.actions.ungroup,
actionManager.actions.cut,
actionManager.actions.copy,
actionManager.actions.deleteSelectedElements,
actionManager.actions.copyStyles,
actionManager.actions.pasteStyles,
actionManager.actions.sendBackward,
actionManager.actions.sendToBack,
actionManager.actions.bringForward,
actionManager.actions.bringToFront,
actionManager.actions.alignTop,
actionManager.actions.alignBottom,
actionManager.actions.alignLeft,
actionManager.actions.alignRight,
actionManager.actions.alignVerticallyCentered,
actionManager.actions.alignHorizontallyCentered,
actionManager.actions.duplicateSelection,
actionManager.actions.flipHorizontal,
actionManager.actions.flipVertical,
actionManager.actions.zoomToFitSelection,
actionManager.actions.zoomToFitSelectionInViewport,
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionLink,
].map((action: Action) =>
actionToCommand(
action,
DEFAULT_CATEGORIES.elements,
(command, action) => ({
...command,
predicate: action.predicate
? action.predicate
: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(
elements,
appState,
);
return selectedElements.length > 0;
},
}),
),
);
const toolCommands: CommandPaletteItem[] = [
actionManager.actions.toggleHandTool,
actionManager.actions.setFrameAsActiveTool,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
const editorCommands: CommandPaletteItem[] = [
actionManager.actions.undo,
actionManager.actions.redo,
actionManager.actions.zoomIn,
actionManager.actions.zoomOut,
actionManager.actions.resetZoom,
actionManager.actions.zoomToFit,
actionManager.actions.zenMode,
actionManager.actions.viewMode,
actionManager.actions.objectsSnapMode,
actionManager.actions.toggleShortcuts,
actionManager.actions.selectAll,
actionManager.actions.toggleElementLock,
actionManager.actions.unlockAllElements,
actionManager.actions.stats,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor));
const exportCommands: CommandPaletteItem[] = [
actionManager.actions.saveToActiveFile,
actionManager.actions.saveFileToDisk,
actionManager.actions.copyAsPng,
actionManager.actions.copyAsSvg,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export));
commandsFromActions = [
...elementsCommands,
...editorCommands,
{
label: getActionLabel(actionClearCanvas),
icon: getActionIcon(actionClearCanvas),
shortcut: getShortcutFromShortcutName(
actionClearCanvas.name as ShortcutName,
),
category: DEFAULT_CATEGORIES.editor,
keywords: ["delete", "destroy"],
viewMode: false,
perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
},
},
{
label: t("buttons.exportImage"),
category: DEFAULT_CATEGORIES.export,
icon: ExportImageIcon,
shortcut: getShortcutFromShortcutName("imageExport"),
keywords: [
"export",
"image",
"png",
"jpeg",
"svg",
"clipboard",
"picture",
],
perform: () => {
setAppState({ openDialog: { name: "imageExport" } });
},
},
...exportCommands,
];
const additionalCommands: CommandPaletteItem[] = [
{
label: t("toolBar.library"),
category: DEFAULT_CATEGORIES.app,
icon: LibraryIcon,
viewMode: false,
perform: () => {
if (uiAppState.openSidebar) {
setAppState({
openSidebar: null,
});
} else {
setAppState({
openSidebar: {
name: DEFAULT_SIDEBAR.name,
tab: DEFAULT_SIDEBAR.defaultTab,
},
});
}
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],
category: DEFAULT_CATEGORIES.elements,
icon: bucketFillIcon,
viewMode: false,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
canChangeStrokeColor(appState, selectedElements)
);
},
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke",
}));
},
},
{
label: t("labels.changeBackground"),
keywords: ["color", "fill"],
icon: bucketFillIcon,
category: DEFAULT_CATEGORIES.elements,
viewMode: false,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
canChangeBackgroundColor(appState, selectedElements)
);
},
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground",
}));
},
},
{
label: t("labels.canvasBackground"),
keywords: ["color"],
icon: bucketFillIcon,
category: DEFAULT_CATEGORIES.editor,
viewMode: false,
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "canvas" ? null : "canvas",
openPopup: "canvasBackground",
}));
},
},
...SHAPES.reduce((acc: CommandPaletteItem[], shape) => {
const { value, icon, key, numericKey } = shape;
if (
appProps.UIOptions.tools?.[
value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false
) {
return acc;
}
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter || numericKey;
const command: CommandPaletteItem = {
label: t(`toolBar.${value}`),
category: DEFAULT_CATEGORIES.tools,
shortcut,
icon,
keywords: ["toolbar"],
viewMode: false,
perform: ({ event }) => {
if (value === "image") {
app.setActiveTool({
type: value,
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
});
} else {
app.setActiveTool({ type: value });
}
},
};
acc.push(command);
return acc;
}, []),
...toolCommands,
{
label: t("toolBar.lock"),
category: DEFAULT_CATEGORIES.tools,
icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon,
shortcut: KEYS.Q.toLocaleUpperCase(),
viewMode: false,
perform: () => {
app.toggleLock();
},
},
{
label: `${t("labels.textToDiagram")}...`,
category: DEFAULT_CATEGORIES.tools,
icon: brainIconThin,
viewMode: false,
predicate: appProps.aiEnabled,
perform: () => {
setAppState((state) => ({
...state,
openDialog: {
name: "ttd",
tab: "text-to-diagram",
},
}));
},
},
{
label: `${t("toolBar.mermaidToExcalidraw")}...`,
category: DEFAULT_CATEGORIES.tools,
icon: mermaidLogoIcon,
viewMode: false,
predicate: appProps.aiEnabled,
perform: () => {
setAppState((state) => ({
...state,
openDialog: {
name: "ttd",
tab: "mermaid",
},
}));
},
},
// {
// label: `${t("toolBar.magicframe")}...`,
// category: DEFAULT_CATEGORIES.tools,
// icon: MagicIconThin,
// viewMode: false,
// predicate: appProps.aiEnabled,
// perform: () => {
// app.onMagicframeToolSelect();
// },
// },
];
const allCommands = [
...commandsFromActions,
...additionalCommands,
...(customCommandPaletteItems || []),
].map((command) => {
return {
...command,
icon: command.icon || boltIcon,
order: command.order ?? getCategoryOrder(command.category),
haystack: `${deburr(command.label)} ${
command.keywords?.join(" ") || ""
}`,
};
});
setAllCommands(allCommands);
setLastUsed(
allCommands.find((command) => command.label === lastUsed?.label) ??
null,
);
}
}, [
app,
appProps,
uiAppState,
actionManager,
setAllCommands,
lastUsed?.label,
setLastUsed,
setAppState,
customCommandPaletteItems,
]);
const [commandSearch, setCommandSearch] = useState("");
const [currentCommand, setCurrentCommand] =
useState<CommandPaletteItem | null>(null);
const [commandsByCategory, setCommandsByCategory] = useState<
Record<string, CommandPaletteItem[]>
>({});
const closeCommandPalette = (cb?: () => void) => {
setAppState(
{
openDialog: null,
},
cb,
);
setCommandSearch("");
};
const executeCommand = (
command: CommandPaletteItem,
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent,
) => {
if (uiAppState.openDialog?.name === "commandPalette") {
event.stopPropagation();
event.preventDefault();
document.body.classList.add("excalidraw-animations-disabled");
closeCommandPalette(() => {
command.perform({ actionManager, event });
setLastUsed(command);
requestAnimationFrame(() => {
document.body.classList.remove("excalidraw-animations-disabled");
});
});
}
};
const isCommandAvailable = useStableCallback(
(command: CommandPaletteItem) => {
if (command.viewMode === false && uiAppState.viewModeEnabled) {
return false;
}
return typeof command.predicate === "function"
? command.predicate(
app.scene.getNonDeletedElements(),
uiAppState as AppState,
appProps,
app,
)
: command.predicate === undefined || command.predicate;
},
);
const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
const ignoreAlphanumerics =
isWritableElement(event.target) ||
isCommandPaletteToggleShortcut(event) ||
event.key === KEYS.ESCAPE;
if (
ignoreAlphanumerics &&
event.key !== KEYS.ARROW_UP &&
event.key !== KEYS.ARROW_DOWN &&
event.key !== KEYS.ENTER
) {
return;
}
const matchingCommands = Object.values(commandsByCategory).flat();
const shouldConsiderLastUsed =
lastUsed && !commandSearch && isCommandAvailable(lastUsed);
if (event.key === KEYS.ARROW_UP) {
event.preventDefault();
const index = matchingCommands.findIndex(
(item) => item.label === currentCommand?.label,
);
if (shouldConsiderLastUsed) {
if (index === 0) {
setCurrentCommand(lastUsed);
return;
}
if (currentCommand === lastUsed) {
const nextItem = matchingCommands[matchingCommands.length - 1];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
}
let nextIndex;
if (index === -1) {
nextIndex = matchingCommands.length - 1;
} else {
nextIndex =
index === 0
? matchingCommands.length - 1
: (index - 1) % matchingCommands.length;
}
const nextItem = matchingCommands[nextIndex];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
if (event.key === KEYS.ARROW_DOWN) {
event.preventDefault();
const index = matchingCommands.findIndex(
(item) => item.label === currentCommand?.label,
);
if (shouldConsiderLastUsed) {
if (!currentCommand || index === matchingCommands.length - 1) {
setCurrentCommand(lastUsed);
return;
}
if (currentCommand === lastUsed) {
const nextItem = matchingCommands[0];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
}
const nextIndex = (index + 1) % matchingCommands.length;
const nextItem = matchingCommands[nextIndex];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
if (event.key === KEYS.ENTER) {
if (currentCommand) {
setTimeout(() => {
executeCommand(currentCommand, event);
});
}
}
if (ignoreAlphanumerics) {
return;
}
// prevent regular editor shortcuts
event.stopPropagation();
// if alphanumeric keypress and we're not inside the input, focus it
if (/^[a-zA-Z0-9]$/.test(event.key)) {
inputRef?.current?.focus();
return;
}
event.preventDefault();
});
useEffect(() => {
window.addEventListener(EVENT.KEYDOWN, handleKeyDown, {
capture: true,
});
return () =>
window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, {
capture: true,
});
}, [handleKeyDown]);
useEffect(() => {
if (!allCommands) {
return;
}
const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => {
const nextCommandsByCategory: Record<string, CommandPaletteItem[]> = {};
for (const command of commands) {
if (nextCommandsByCategory[command.category]) {
nextCommandsByCategory[command.category].push(command);
} else {
nextCommandsByCategory[command.category] = [command];
}
}
return nextCommandsByCategory;
};
let matchingCommands = allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order);
const showLastUsed =
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
if (!commandSearch) {
setCommandsByCategory(
getNextCommandsByCategory(
showLastUsed
? matchingCommands.filter(
(command) => command.label !== lastUsed?.label,
)
: matchingCommands,
),
);
setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null);
return;
}
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
matchingCommands = fuzzy
.filter(_query, matchingCommands, {
extract: (command) => command.haystack,
})
.sort((a, b) => b.score - a.score)
.map((item) => item.original);
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
setCurrentCommand(matchingCommands[0] ?? null);
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
return (
<Dialog
onCloseRequest={() => closeCommandPalette()}
closeOnClickOutside
title={false}
size={720}
autofocus
className="command-palette-dialog"
>
<TextField
value={commandSearch}
placeholder={t("commandPalette.search.placeholder")}
onChange={(value) => {
setCommandSearch(value);
}}
selectOnRender
ref={inputRef}
/>
{!app.device.viewport.isMobile && (
<div className="shortcuts-wrapper">
<CommandShortcutHint shortcut="↑↓">
{t("commandPalette.shortcuts.select")}
</CommandShortcutHint>
<CommandShortcutHint shortcut="↵">
{t("commandPalette.shortcuts.confirm")}
</CommandShortcutHint>
<CommandShortcutHint shortcut={getShortcutKey("Esc")}>
{t("commandPalette.shortcuts.close")}
</CommandShortcutHint>
</div>
)}
<div className="commands">
{lastUsed && !commandSearch && (
<div className="command-category">
<div className="command-category-title">
{t("commandPalette.recents")}
<div
className="icon"
style={{
marginLeft: "6px",
}}
>
{clockIcon}
</div>
</div>
<CommandItem
command={lastUsed}
isSelected={lastUsed.label === currentCommand?.label}
onClick={(event) => executeCommand(lastUsed, event)}
disabled={!isCommandAvailable(lastUsed)}
onMouseMove={() => setCurrentCommand(lastUsed)}
showShortcut={!app.device.viewport.isMobile}
appState={uiAppState}
/>
</div>
)}
{Object.keys(commandsByCategory).length > 0 ? (
Object.keys(commandsByCategory).map((category, idx) => {
return (
<div className="command-category" key={category}>
<div className="command-category-title">{category}</div>
{commandsByCategory[category].map((command) => (
<CommandItem
key={command.label}
command={command}
isSelected={command.label === currentCommand?.label}
onClick={(event) => executeCommand(command, event)}
onMouseMove={() => setCurrentCommand(command)}
showShortcut={!app.device.viewport.isMobile}
appState={uiAppState}
/>
))}
</div>
);
})
) : allCommands ? (
<div className="no-match">
<div className="icon">{searchIcon}</div>{" "}
{t("commandPalette.search.noMatch")}
</div>
) : null}
</div>
</Dialog>
);
}
const CommandItem = ({
command,
isSelected,
disabled,
onMouseMove,
onClick,
showShortcut,
appState,
}: {
command: CommandPaletteItem;
isSelected: boolean;
disabled?: boolean;
onMouseMove: () => void;
onClick: (event: React.MouseEvent) => void;
showShortcut: boolean;
appState: UIAppState;
}) => {
const noop = () => {};
return (
<div
className={clsx("command-item", {
"item-selected": isSelected,
"item-disabled": disabled,
})}
ref={(ref) => {
if (isSelected && !disabled) {
ref?.scrollIntoView?.({
block: "nearest",
});
}
}}
onClick={disabled ? noop : onClick}
onMouseMove={disabled ? noop : onMouseMove}
title={disabled ? t("commandPalette.itemNotAvailable") : ""}
>
<div className="name">
{command.icon && (
<InlineIcon
icon={
typeof command.icon === "function"
? command.icon(appState)
: command.icon
}
/>
)}
{command.label}
</div>
{showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} />
)}
</div>
);
};