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(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(/(? {shortcuts.map((item) => { return (
{item}
); })}
{children}
); }; 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 ; }, { 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[] | null >(null); const inputRef = useRef(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(null); const [commandsByCategory, setCommandsByCategory] = useState< Record >({}); 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 = {}; 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 ( closeCommandPalette()} closeOnClickOutside title={false} size={720} autofocus className="command-palette-dialog" > { setCommandSearch(value); }} selectOnRender ref={inputRef} /> {!app.device.viewport.isMobile && (
{t("commandPalette.shortcuts.select")} {t("commandPalette.shortcuts.confirm")} {t("commandPalette.shortcuts.close")}
)}
{lastUsed && !commandSearch && (
{t("commandPalette.recents")}
{clockIcon}
executeCommand(lastUsed, event)} disabled={!isCommandAvailable(lastUsed)} onMouseMove={() => setCurrentCommand(lastUsed)} showShortcut={!app.device.viewport.isMobile} appState={uiAppState} />
)} {Object.keys(commandsByCategory).length > 0 ? ( Object.keys(commandsByCategory).map((category, idx) => { return (
{category}
{commandsByCategory[category].map((command) => ( executeCommand(command, event)} onMouseMove={() => setCurrentCommand(command)} showShortcut={!app.device.viewport.isMobile} appState={uiAppState} /> ))}
); }) ) : allCommands ? (
{searchIcon}
{" "} {t("commandPalette.search.noMatch")}
) : null}
); } 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 (
{ if (isSelected && !disabled) { ref?.scrollIntoView?.({ block: "nearest", }); } }} onClick={disabled ? noop : onClick} onMouseMove={disabled ? noop : onMouseMove} title={disabled ? t("commandPalette.itemNotAvailable") : ""} >
{command.icon && ( )} {command.label}
{showShortcut && command.shortcut && ( )}
); };