mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: command palette (#7804)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
6b523563d8
commit
550a388b2b
63 changed files with 5226 additions and 317 deletions
915
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
915
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
|
@ -0,0 +1,915 @@
|
|||
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>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue