aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/CommandPalette
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/CommandPalette
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/CommandPalette')
-rw-r--r--packages/excalidraw/components/CommandPalette/CommandPalette.scss137
-rw-r--r--packages/excalidraw/components/CommandPalette/CommandPalette.tsx956
-rw-r--r--packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts11
-rw-r--r--packages/excalidraw/components/CommandPalette/types.ts26
4 files changed, 1130 insertions, 0 deletions
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss
new file mode 100644
index 0000000..ebb7e4f
--- /dev/null
+++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss
@@ -0,0 +1,137 @@
+@import "../../css/variables.module.scss";
+
+$verticalBreakpoint: 861px;
+
+.excalidraw {
+ .command-palette-dialog {
+ user-select: none;
+
+ .Modal__content {
+ height: auto;
+ max-height: 100%;
+
+ @media screen and (min-width: $verticalBreakpoint) {
+ max-height: 750px;
+ height: 100%;
+ }
+
+ .Island {
+ height: 100%;
+ padding: 1.5rem;
+ }
+
+ .Dialog__content {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ }
+
+ .shortcuts-wrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 12px;
+ gap: 1.5rem;
+ }
+
+ .shortcut {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 16px;
+ font-size: 10px;
+ gap: 0.25rem;
+
+ .shortcut-wrapper {
+ display: flex;
+ }
+
+ .shortcut-plus {
+ margin: 0px 4px;
+ }
+
+ .shortcut-key {
+ padding: 0px 4px;
+ height: 16px;
+ border-radius: 4px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--color-primary-light);
+ }
+
+ .shortcut-desc {
+ margin-left: 4px;
+ color: var(--color-gray-50);
+ }
+ }
+
+ .commands {
+ overflow-y: auto;
+ box-sizing: border-box;
+ margin-top: 12px;
+ color: var(--popup-text-color);
+ user-select: none;
+
+ .command-category {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 0px;
+ margin-right: 0.25rem;
+ }
+
+ .command-category-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ }
+
+ .command-item {
+ color: var(--popup-text-color);
+ height: 2.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-sizing: border-box;
+ padding: 0 0.5rem;
+ border-radius: var(--border-radius-lg);
+ cursor: pointer;
+
+ &:active {
+ background-color: var(--color-surface-low);
+ }
+
+ .name {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+ }
+
+ .item-selected {
+ background-color: var(--color-surface-mid);
+ }
+
+ .item-disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+
+ .no-match {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 36px;
+ }
+ }
+
+ .icon {
+ width: 16px;
+ height: 16px;
+ margin-right: 6px;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
new file mode 100644
index 0000000..cc3c782
--- /dev/null
+++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
@@ -0,0 +1,956 @@
+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 type { Action } from "../../actions/types";
+import type { TranslationKeys } from "../../i18n";
+import { t } from "../../i18n";
+import type { ShortcutName } from "../../actions/shortcuts";
+import { 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 type { AppProps, AppState, UIAppState } from "../../types";
+import {
+ capitalizeString,
+ getShortcutKey,
+ isWritableElement,
+} from "../../utils";
+import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
+import { deburr } from "../../deburr";
+import type { 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,
+ actionToggleSearchMenu,
+} from "../../actions";
+import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
+import type { CommandPaletteItem } from "./types";
+import * as defaultItems from "./defaultCommandPaletteItems";
+import { trackEvent } from "../../analytics";
+import { useStable } from "../../hooks/useStable";
+
+import "./CommandPalette.scss";
+import {
+ actionCopyElementLink,
+ actionLinkToElement,
+} from "../../actions/actionElementLink";
+
+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.replace("++", "+$").split("+");
+
+ return (
+ <div className={clsx("shortcut", className)}>
+ {shortcuts.map((item, idx) => {
+ return (
+ <div className="shortcut-wrapper" key={item}>
+ <div className="shortcut-key">{item === "$" ? "+" : 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) => {
+ const nextState =
+ appState.openDialog?.name === "commandPalette"
+ ? null
+ : ({ name: "commandPalette" } as const);
+
+ if (nextState) {
+ trackEvent("command_palette", "open", "shortcut");
+ }
+
+ return {
+ openDialog: nextState,
+ };
+ });
+ }
+ };
+ 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);
+
+ const stableDeps = useStable({
+ uiAppState,
+ customCommandPaletteItems,
+ appProps,
+ });
+
+ useEffect(() => {
+ // these props change often and we don't want them to re-run the effect
+ // which would renew `allCommands`, cascading down and resetting state.
+ //
+ // This means that the commands won't update on appState/appProps changes
+ // while the command palette is open
+ const { uiAppState, customCommandPaletteItems, appProps } = stableDeps;
+
+ 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.wrapSelectionInFrame,
+ actionManager.actions.copyStyles,
+ actionManager.actions.pasteStyles,
+ actionManager.actions.bringToFront,
+ actionManager.actions.bringForward,
+ actionManager.actions.sendBackward,
+ actionManager.actions.sendToBack,
+ 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,
+ actionManager.actions.cropEditor,
+ actionLink,
+ actionCopyElementLink,
+ actionLinkToElement,
+ ].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.gridMode,
+ 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: () => {
+ editorJotaiStore.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("search.title"),
+ category: DEFAULT_CATEGORIES.app,
+ icon: searchIcon,
+ viewMode: true,
+ perform: () => {
+ actionManager.executeAction(actionToggleSearchMenu);
+ },
+ },
+ {
+ 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.toLocaleLowerCase())} ${
+ command.keywords?.join(" ") || ""
+ }`,
+ };
+ });
+
+ setAllCommands(allCommands);
+ setLastUsed(
+ allCommands.find((command) => command.label === lastUsed?.label) ??
+ null,
+ );
+ }
+ }, [
+ stableDeps,
+ app,
+ actionManager,
+ setAllCommands,
+ lastUsed?.label,
+ setLastUsed,
+ setAppState,
+ ]);
+
+ 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.toLocaleLowerCase().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>
+ );
+};
diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts
new file mode 100644
index 0000000..dea14ff
--- /dev/null
+++ b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts
@@ -0,0 +1,11 @@
+import { actionToggleTheme } from "../../actions";
+import type { CommandPaletteItem } from "./types";
+
+export const toggleTheme: CommandPaletteItem = {
+ ...actionToggleTheme,
+ category: "App",
+ label: "Toggle theme",
+ perform: ({ actionManager }) => {
+ actionManager.executeAction(actionToggleTheme, "commandPalette");
+ },
+};
diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts
new file mode 100644
index 0000000..957d699
--- /dev/null
+++ b/packages/excalidraw/components/CommandPalette/types.ts
@@ -0,0 +1,26 @@
+import type { ActionManager } from "../../actions/manager";
+import type { Action } from "../../actions/types";
+import type { UIAppState } from "../../types";
+
+export type CommandPaletteItem = {
+ label: string;
+ /** additional keywords to match against
+ * (appended to haystack, not displayed) */
+ keywords?: string[];
+ /**
+ * string we should match against when searching
+ * (deburred name + keywords)
+ */
+ haystack?: string;
+ icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
+ category: string;
+ order?: number;
+ predicate?: boolean | Action["predicate"];
+ shortcut?: string;
+ /** if false, command will not show while in view mode */
+ viewMode?: boolean;
+ perform: (data: {
+ actionManager: ActionManager;
+ event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent;
+ }) => void;
+};