You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
937 lines
27 KiB
TypeScript
937 lines
27 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 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 } from "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 } from "../../actions";
|
|
import { jotaiStore } from "../../jotai";
|
|
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";
|
|
|
|
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.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,
|
|
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.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: () => {
|
|
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.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>
|
|
);
|
|
};
|