Переглянути джерело

feat: command palette (#7804)

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 рік тому
батько
коміт
550a388b2b
63 змінених файлів з 2826 додано та 205 видалено
  1. 207 0
      excalidraw-app/App.tsx
  2. 1 1
      excalidraw-app/components/AppMainMenu.tsx
  3. 2 0
      excalidraw-app/components/TopErrorBoundary.tsx
  4. 11 2
      excalidraw-app/share/ShareDialog.tsx
  5. 1 1
      packages/excalidraw/actions/actionAddToLibrary.ts
  6. 14 2
      packages/excalidraw/actions/actionAlign.tsx
  7. 3 3
      packages/excalidraw/actions/actionBoundText.tsx
  8. 37 1
      packages/excalidraw/actions/actionCanvas.tsx
  9. 14 6
      packages/excalidraw/actions/actionClipboard.tsx
  10. 2 1
      packages/excalidraw/actions/actionDeleteSelected.tsx
  11. 2 0
      packages/excalidraw/actions/actionDistribute.tsx
  12. 2 1
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  13. 33 20
      packages/excalidraw/actions/actionElementLock.ts
  14. 11 1
      packages/excalidraw/actions/actionExport.tsx
  15. 1 0
      packages/excalidraw/actions/actionFinalize.tsx
  16. 5 2
      packages/excalidraw/actions/actionFlip.ts
  17. 12 5
      packages/excalidraw/actions/actionFrame.ts
  18. 4 2
      packages/excalidraw/actions/actionGroup.tsx
  19. 6 0
      packages/excalidraw/actions/actionHistory.tsx
  20. 11 9
      packages/excalidraw/actions/actionLinearEditor.ts
  21. 2 2
      packages/excalidraw/actions/actionLink.tsx
  22. 5 1
      packages/excalidraw/actions/actionMenu.tsx
  23. 1 0
      packages/excalidraw/actions/actionNavigate.tsx
  24. 18 0
      packages/excalidraw/actions/actionProperties.tsx
  25. 4 1
      packages/excalidraw/actions/actionSelectAll.ts
  26. 5 2
      packages/excalidraw/actions/actionStyles.ts
  27. 1 1
      packages/excalidraw/actions/actionToggleGridMode.tsx
  28. 4 2
      packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
  29. 4 1
      packages/excalidraw/actions/actionToggleStats.tsx
  30. 4 1
      packages/excalidraw/actions/actionToggleViewMode.tsx
  31. 4 1
      packages/excalidraw/actions/actionToggleZenMode.tsx
  32. 8 4
      packages/excalidraw/actions/actionZindex.tsx
  33. 34 3
      packages/excalidraw/actions/shortcuts.ts
  34. 23 9
      packages/excalidraw/actions/types.ts
  35. 37 20
      packages/excalidraw/components/Actions.tsx
  36. 22 6
      packages/excalidraw/components/App.tsx
  37. 137 0
      packages/excalidraw/components/CommandPalette/CommandPalette.scss
  38. 915 0
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  39. 11 0
      packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts
  40. 26 0
      packages/excalidraw/components/CommandPalette/types.ts
  41. 4 4
      packages/excalidraw/components/ContextMenu.tsx
  42. 6 0
      packages/excalidraw/components/Dialog.scss
  43. 18 14
      packages/excalidraw/components/Dialog.tsx
  44. 4 2
      packages/excalidraw/components/FilledButton.scss
  45. 8 0
      packages/excalidraw/components/HelpDialog.tsx
  46. 1 1
      packages/excalidraw/components/InlineIcon.tsx
  47. 18 3
      packages/excalidraw/components/Modal.scss
  48. 8 1
      packages/excalidraw/components/Modal.tsx
  49. 5 3
      packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx
  50. 3 3
      packages/excalidraw/components/hyperlink/Hyperlink.tsx
  51. 238 6
      packages/excalidraw/components/icons.tsx
  52. 21 2
      packages/excalidraw/components/main-menu/DefaultItems.tsx
  53. 93 0
      packages/excalidraw/deburr.ts
  54. 2 0
      packages/excalidraw/element/embeddable.ts
  55. 18 0
      packages/excalidraw/hooks/useStableCallback.ts
  56. 1 0
      packages/excalidraw/keys.ts
  57. 24 2
      packages/excalidraw/locales/en.json
  58. 3 2
      packages/excalidraw/package.json
  59. 1 14
      packages/excalidraw/tests/MermaidToExcalidraw.test.tsx
  60. 1 1
      packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
  61. 697 35
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  62. 3 1
      packages/excalidraw/types.ts
  63. 5 0
      yarn.lock

+ 207 - 0
excalidraw-app/App.tsx

@@ -47,6 +47,7 @@ import {
 } from "../packages/excalidraw/utils";
 import {
   FIREBASE_STORAGE_PREFIXES,
+  isExcalidrawPlusSignedUser,
   STORAGE_KEYS,
   SYNC_BROWSER_TABS_TIMEOUT,
 } from "./app_constants";
@@ -107,6 +108,19 @@ import { OverwriteConfirmDialog } from "../packages/excalidraw/components/Overwr
 import Trans from "../packages/excalidraw/components/Trans";
 import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
 import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
+import {
+  CommandPalette,
+  DEFAULT_CATEGORIES,
+} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
+import {
+  GithubIcon,
+  XBrandIcon,
+  DiscordIcon,
+  ExcalLogo,
+  usersIcon,
+  exportToPlus,
+  share,
+} from "../packages/excalidraw/components/icons";
 
 polyfill();
 
@@ -692,6 +706,45 @@ const ExcalidrawWrapper = () => {
     );
   }
 
+  const ExcalidrawPlusCommand = {
+    label: "Excalidraw+",
+    category: DEFAULT_CATEGORIES.links,
+    predicate: true,
+    icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+    keywords: ["plus", "cloud", "server"],
+    perform: () => {
+      window.open(
+        `${
+          import.meta.env.VITE_APP_PLUS_LP
+        }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
+        "_blank",
+      );
+    },
+  };
+  const ExcalidrawPlusAppCommand = {
+    label: "Sign up",
+    category: DEFAULT_CATEGORIES.links,
+    predicate: true,
+    icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+    keywords: [
+      "excalidraw",
+      "plus",
+      "cloud",
+      "server",
+      "signin",
+      "login",
+      "signup",
+    ],
+    perform: () => {
+      window.open(
+        `${
+          import.meta.env.VITE_APP_PLUS_APP
+        }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
+        "_blank",
+      );
+    },
+  };
+
   return (
     <div
       style={{ height: "100%" }}
@@ -886,6 +939,160 @@ const ExcalidrawWrapper = () => {
             {errorMessage}
           </ErrorDialog>
         )}
+
+        <CommandPalette
+          customCommandPaletteItems={[
+            {
+              label: t("labels.liveCollaboration"),
+              category: DEFAULT_CATEGORIES.app,
+              keywords: [
+                "team",
+                "multiplayer",
+                "share",
+                "public",
+                "session",
+                "invite",
+              ],
+              icon: usersIcon,
+              perform: () => {
+                setShareDialogState({
+                  isOpen: true,
+                  type: "collaborationOnly",
+                });
+              },
+            },
+            {
+              label: t("roomDialog.button_stopSession"),
+              category: DEFAULT_CATEGORIES.app,
+              predicate: () => !!collabAPI?.isCollaborating(),
+              keywords: [
+                "stop",
+                "session",
+                "end",
+                "leave",
+                "close",
+                "exit",
+                "collaboration",
+              ],
+              perform: () => {
+                if (collabAPI) {
+                  collabAPI.stopCollaboration();
+                  if (!collabAPI.isCollaborating()) {
+                    setShareDialogState({ isOpen: false });
+                  }
+                }
+              },
+            },
+            {
+              label: t("labels.share"),
+              category: DEFAULT_CATEGORIES.app,
+              predicate: true,
+              icon: share,
+              keywords: [
+                "link",
+                "shareable",
+                "readonly",
+                "export",
+                "publish",
+                "snapshot",
+                "url",
+                "collaborate",
+                "invite",
+              ],
+              perform: async () => {
+                setShareDialogState({ isOpen: true, type: "share" });
+              },
+            },
+            {
+              label: "GitHub",
+              icon: GithubIcon,
+              category: DEFAULT_CATEGORIES.links,
+              predicate: true,
+              keywords: [
+                "issues",
+                "bugs",
+                "requests",
+                "report",
+                "features",
+                "social",
+                "community",
+              ],
+              perform: () => {
+                window.open(
+                  "https://github.com/excalidraw/excalidraw",
+                  "_blank",
+                  "noopener noreferrer",
+                );
+              },
+            },
+            {
+              label: t("labels.followUs"),
+              icon: XBrandIcon,
+              category: DEFAULT_CATEGORIES.links,
+              predicate: true,
+              keywords: ["twitter", "contact", "social", "community"],
+              perform: () => {
+                window.open(
+                  "https://x.com/excalidraw",
+                  "_blank",
+                  "noopener noreferrer",
+                );
+              },
+            },
+            {
+              label: t("labels.discordChat"),
+              category: DEFAULT_CATEGORIES.links,
+              predicate: true,
+              icon: DiscordIcon,
+              keywords: [
+                "chat",
+                "talk",
+                "contact",
+                "bugs",
+                "requests",
+                "report",
+                "feedback",
+                "suggestions",
+                "social",
+                "community",
+              ],
+              perform: () => {
+                window.open(
+                  "https://discord.gg/UexuTaE",
+                  "_blank",
+                  "noopener noreferrer",
+                );
+              },
+            },
+            ...(isExcalidrawPlusSignedUser
+              ? [
+                  {
+                    ...ExcalidrawPlusAppCommand,
+                    label: "Sign in / Go to Excalidraw+",
+                  },
+                ]
+              : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
+
+            {
+              label: t("overwriteConfirm.action.excalidrawPlus.button"),
+              category: DEFAULT_CATEGORIES.export,
+              icon: exportToPlus,
+              predicate: true,
+              keywords: ["plus", "export", "save", "backup"],
+              perform: () => {
+                if (excalidrawAPI) {
+                  exportToExcalidrawPlus(
+                    excalidrawAPI.getSceneElements(),
+                    excalidrawAPI.getAppState(),
+                    excalidrawAPI.getFiles(),
+                    excalidrawAPI.getName(),
+                  );
+                }
+              },
+            },
+            CommandPalette.defaultItems.toggleTheme,
+          ]}
+        />
       </Excalidraw>
     </div>
   );

+ 1 - 1
excalidraw-app/components/AppMainMenu.tsx

@@ -20,7 +20,7 @@ export const AppMainMenu: React.FC<{
           onSelect={() => props.onCollabDialogOpen()}
         />
       )}
-
+      <MainMenu.DefaultItems.CommandPalette />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />

+ 2 - 0
excalidraw-app/components/TopErrorBoundary.tsx

@@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
 
     window.open(
       `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
+      "_blank",
+      "noopener noreferrer",
     );
   }
 

+ 11 - 2
excalidraw-app/share/ShareDialog.tsx

@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import * as Popover from "@radix-ui/react-popover";
 import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
 import { trackEvent } from "../../packages/excalidraw/analytics";
@@ -22,6 +22,7 @@ import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
 import { atom, useAtom, useAtomValue } from "jotai";
 
 import "./ShareDialog.scss";
+import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
 
 type OnExportToBackend = () => void;
 type ShareDialogType = "share" | "collaborationOnly";
@@ -275,6 +276,14 @@ export const ShareDialog = (props: {
 }) => {
   const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
 
+  const { openDialog } = useUIAppState();
+
+  useEffect(() => {
+    if (openDialog) {
+      setShareDialogState({ isOpen: false });
+    }
+  }, [openDialog, setShareDialogState]);
+
   if (!shareDialogState.isOpen) {
     return null;
   }
@@ -285,6 +294,6 @@ export const ShareDialog = (props: {
       collabAPI={props.collabAPI}
       onExportToBackend={props.onExportToBackend}
       type={shareDialogState.type}
-    ></ShareDialogInner>
+    />
   );
 };

+ 1 - 1
packages/excalidraw/actions/actionAddToLibrary.ts

@@ -58,5 +58,5 @@ export const actionAddToLibrary = register({
         };
       });
   },
-  contextItemLabel: "labels.addToLibrary",
+  label: "labels.addToLibrary",
 });

+ 14 - 2
packages/excalidraw/actions/actionAlign.tsx

@@ -15,13 +15,13 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { t } from "../i18n";
 import { KEYS } from "../keys";
 import { isSomeElementSelected } from "../scene";
-import { AppClassProperties, AppState } from "../types";
+import { AppClassProperties, AppState, UIAppState } from "../types";
 import { arrayToMap, getShortcutKey } from "../utils";
 import { register } from "./register";
 
 const alignActionsPredicate = (
   elements: readonly ExcalidrawElement[],
-  appState: AppState,
+  appState: UIAppState,
   _: unknown,
   app: AppClassProperties,
 ) => {
@@ -59,6 +59,8 @@ const alignSelectedElements = (
 
 export const actionAlignTop = register({
   name: "alignTop",
+  label: "labels.alignTop",
+  icon: AlignTopIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {
@@ -90,6 +92,8 @@ export const actionAlignTop = register({
 
 export const actionAlignBottom = register({
   name: "alignBottom",
+  label: "labels.alignBottom",
+  icon: AlignBottomIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {
@@ -121,6 +125,8 @@ export const actionAlignBottom = register({
 
 export const actionAlignLeft = register({
   name: "alignLeft",
+  label: "labels.alignLeft",
+  icon: AlignLeftIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {
@@ -152,6 +158,8 @@ export const actionAlignLeft = register({
 
 export const actionAlignRight = register({
   name: "alignRight",
+  label: "labels.alignRight",
+  icon: AlignRightIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {
@@ -183,6 +191,8 @@ export const actionAlignRight = register({
 
 export const actionAlignVerticallyCentered = register({
   name: "alignVerticallyCentered",
+  label: "labels.centerVertically",
+  icon: CenterVerticallyIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {
@@ -210,6 +220,8 @@ export const actionAlignVerticallyCentered = register({
 
 export const actionAlignHorizontallyCentered = register({
   name: "alignHorizontallyCentered",
+  label: "labels.centerHorizontally",
+  icon: CenterHorizontallyIcon,
   trackEvent: { category: "element" },
   predicate: alignActionsPredicate,
   perform: (elements, appState, _, app) => {

+ 3 - 3
packages/excalidraw/actions/actionBoundText.tsx

@@ -36,7 +36,7 @@ import { register } from "./register";
 
 export const actionUnbindText = register({
   name: "unbindText",
-  contextItemLabel: "labels.unbindText",
+  label: "labels.unbindText",
   trackEvent: { category: "element" },
   predicate: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
@@ -91,7 +91,7 @@ export const actionUnbindText = register({
 
 export const actionBindText = register({
   name: "bindText",
-  contextItemLabel: "labels.bindText",
+  label: "labels.bindText",
   trackEvent: { category: "element" },
   predicate: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
@@ -203,7 +203,7 @@ const pushContainerBelowText = (
 
 export const actionWrapTextInContainer = register({
   name: "wrapTextInContainer",
-  contextItemLabel: "labels.createContainerFromText",
+  label: "labels.createContainerFromText",
   trackEvent: { category: "element" },
   predicate: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);

+ 37 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -1,5 +1,14 @@
 import { ColorPicker } from "../components/ColorPicker/ColorPicker";
-import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
+import {
+  handIcon,
+  MoonIcon,
+  SunIcon,
+  TrashIcon,
+  zoomAreaIcon,
+  ZoomInIcon,
+  ZoomOutIcon,
+  ZoomResetIcon,
+} from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
 import { getCommonBounds, getNonDeletedElements } from "../element";
@@ -25,6 +34,8 @@ import { setCursor } from "../cursor";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
+  label: "labels.canvasBackground",
+  paletteName: "Change canvas background color",
   trackEvent: false,
   predicate: (elements, appState, props, app) => {
     return (
@@ -59,6 +70,9 @@ export const actionChangeViewBackgroundColor = register({
 
 export const actionClearCanvas = register({
   name: "clearCanvas",
+  label: "labels.clearCanvas",
+  paletteName: "Clear canvas",
+  icon: TrashIcon,
   trackEvent: { category: "canvas" },
   predicate: (elements, appState, props, app) => {
     return (
@@ -95,7 +109,9 @@ export const actionClearCanvas = register({
 
 export const actionZoomIn = register({
   name: "zoomIn",
+  label: "buttons.zoomIn",
   viewMode: true,
+  icon: ZoomInIcon,
   trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
     return {
@@ -133,6 +149,8 @@ export const actionZoomIn = register({
 
 export const actionZoomOut = register({
   name: "zoomOut",
+  label: "buttons.zoomOut",
+  icon: ZoomOutIcon,
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
@@ -171,6 +189,8 @@ export const actionZoomOut = register({
 
 export const actionResetZoom = register({
   name: "resetZoom",
+  label: "buttons.resetZoom",
+  icon: ZoomResetIcon,
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (_elements, appState, _, app) => {
@@ -340,6 +360,8 @@ export const zoomToFit = ({
 // size, it won't be zoomed in.
 export const actionZoomToFitSelectionInViewport = register({
   name: "zoomToFitSelectionInViewport",
+  label: "labels.zoomToFitViewport",
+  icon: zoomAreaIcon,
   trackEvent: { category: "canvas" },
   perform: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
@@ -363,6 +385,8 @@ export const actionZoomToFitSelectionInViewport = register({
 
 export const actionZoomToFitSelection = register({
   name: "zoomToFitSelection",
+  label: "helpDialog.zoomToSelection",
+  icon: zoomAreaIcon,
   trackEvent: { category: "canvas" },
   perform: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
@@ -385,6 +409,8 @@ export const actionZoomToFitSelection = register({
 
 export const actionZoomToFit = register({
   name: "zoomToFit",
+  label: "helpDialog.zoomToFit",
+  icon: zoomAreaIcon,
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (elements, appState) =>
@@ -405,6 +431,11 @@ export const actionZoomToFit = register({
 
 export const actionToggleTheme = register({
   name: "toggleTheme",
+  label: (_, appState) => {
+    return appState.theme === "dark" ? "buttons.lightMode" : "buttons.darkMode";
+  },
+  keywords: ["toggle", "dark", "light", "mode", "theme"],
+  icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (_, appState, value) => {
@@ -425,6 +456,7 @@ export const actionToggleTheme = register({
 
 export const actionToggleEraserTool = register({
   name: "toggleEraserTool",
+  label: "toolBar.eraser",
   trackEvent: { category: "toolbar" },
   perform: (elements, appState) => {
     let activeTool: AppState["activeTool"];
@@ -459,7 +491,11 @@ export const actionToggleEraserTool = register({
 
 export const actionToggleHandTool = register({
   name: "toggleHandTool",
+  label: "toolBar.hand",
+  paletteName: "Toggle hand tool",
   trackEvent: { category: "toolbar" },
+  icon: handIcon,
+  viewMode: false,
   perform: (elements, appState, _, app) => {
     let activeTool: AppState["activeTool"];
 

+ 14 - 6
packages/excalidraw/actions/actionClipboard.tsx

@@ -13,9 +13,12 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
 import { isTextElement } from "../element";
 import { t } from "../i18n";
 import { isFirefox } from "../constants";
+import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
 
 export const actionCopy = register({
   name: "copy",
+  label: "labels.copy",
+  icon: DuplicateIcon,
   trackEvent: { category: "element" },
   perform: async (elements, appState, event: ClipboardEvent | null, app) => {
     const elementsToCopy = app.scene.getSelectedElements({
@@ -40,13 +43,13 @@ export const actionCopy = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.copy",
   // don't supply a shortcut since we handle this conditionally via onCopy event
   keyTest: undefined,
 });
 
 export const actionPaste = register({
   name: "paste",
+  label: "labels.paste",
   trackEvent: { category: "element" },
   perform: async (elements, appState, data, app) => {
     let types;
@@ -97,24 +100,26 @@ export const actionPaste = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.paste",
   // don't supply a shortcut since we handle this conditionally via onCopy event
   keyTest: undefined,
 });
 
 export const actionCut = register({
   name: "cut",
+  label: "labels.cut",
+  icon: cutIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, event: ClipboardEvent | null, app) => {
     actionCopy.perform(elements, appState, event, app);
     return actionDeleteSelected.perform(elements, appState, null, app);
   },
-  contextItemLabel: "labels.cut",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
 });
 
 export const actionCopyAsSvg = register({
   name: "copyAsSvg",
+  label: "labels.copyAsSvg",
+  icon: svgIcon,
   trackEvent: { category: "element" },
   perform: async (elements, appState, _data, app) => {
     if (!app.canvas) {
@@ -158,11 +163,13 @@ export const actionCopyAsSvg = register({
   predicate: (elements) => {
     return probablySupportsClipboardWriteText && elements.length > 0;
   },
-  contextItemLabel: "labels.copyAsSvg",
+  keywords: ["svg", "clipboard", "copy"],
 });
 
 export const actionCopyAsPng = register({
   name: "copyAsPng",
+  label: "labels.copyAsPng",
+  icon: pngIcon,
   trackEvent: { category: "element" },
   perform: async (elements, appState, _data, app) => {
     if (!app.canvas) {
@@ -217,12 +224,13 @@ export const actionCopyAsPng = register({
   predicate: (elements) => {
     return probablySupportsClipboardBlob && elements.length > 0;
   },
-  contextItemLabel: "labels.copyAsPng",
   keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
+  keywords: ["png", "clipboard", "copy"],
 });
 
 export const copyText = register({
   name: "copyText",
+  label: "labels.copyText",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements({
@@ -258,5 +266,5 @@ export const copyText = register({
         .some(isTextElement)
     );
   },
-  contextItemLabel: "labels.copyText",
+  keywords: ["text", "clipboard", "copy"],
 });

+ 2 - 1
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -72,6 +72,8 @@ const handleGroupEditingState = (
 
 export const actionDeleteSelected = register({
   name: "deleteSelectedElements",
+  label: "labels.delete",
+  icon: TrashIcon,
   trackEvent: { category: "element", action: "delete" },
   perform: (elements, appState, formData, app) => {
     if (appState.editingLinearElement) {
@@ -168,7 +170,6 @@ export const actionDeleteSelected = register({
       ),
     };
   },
-  contextItemLabel: "labels.delete",
   keyTest: (event, appState, elements) =>
     (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
     !event[KEYS.CTRL_OR_CMD],

+ 2 - 0
packages/excalidraw/actions/actionDistribute.tsx

@@ -49,6 +49,7 @@ const distributeSelectedElements = (
 
 export const distributeHorizontally = register({
   name: "distributeHorizontally",
+  label: "labels.distributeHorizontally",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     return {
@@ -79,6 +80,7 @@ export const distributeHorizontally = register({
 
 export const distributeVertically = register({
   name: "distributeVertically",
+  label: "labels.distributeVertically",
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     return {

+ 2 - 1
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -34,6 +34,8 @@ import {
 
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
+  label: "labels.duplicateSelection",
+  icon: DuplicateIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, formData, app) => {
     const elementsMap = app.scene.getNonDeletedElementsMap();
@@ -60,7 +62,6 @@ export const actionDuplicateSelection = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.duplicateSelection",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
   PanelComponent: ({ elements, appState, updateData }) => (
     <ToolButton

+ 33 - 20
packages/excalidraw/actions/actionElementLock.ts

@@ -1,7 +1,9 @@
+import { LockedIcon, UnlockedIcon } from "../components/icons";
 import { newElementWith } from "../element/mutateElement";
 import { isFrameLikeElement } from "../element/typeChecks";
 import { ExcalidrawElement } from "../element/types";
 import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
 import { arrayToMap } from "../utils";
 import { register } from "./register";
 
@@ -10,11 +12,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
 
 export const actionToggleElementLock = register({
   name: "toggleElementLock",
+  label: (elements, appState, app) => {
+    const selected = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: false,
+    });
+    if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
+      return selected[0].locked
+        ? "labels.elementLock.unlock"
+        : "labels.elementLock.lock";
+    }
+
+    return shouldLock(selected)
+      ? "labels.elementLock.lockAll"
+      : "labels.elementLock.unlockAll";
+  },
+  icon: (appState, elements) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
+  },
   trackEvent: { category: "element" },
   predicate: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
-    return !selectedElements.some(
-      (element) => element.locked && element.frameId,
+    return (
+      selectedElements.length > 0 &&
+      !selectedElements.some((element) => element.locked && element.frameId)
     );
   },
   perform: (elements, appState, _, app) => {
@@ -47,21 +69,6 @@ export const actionToggleElementLock = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: (elements, appState, app) => {
-    const selected = app.scene.getSelectedElements({
-      selectedElementIds: appState.selectedElementIds,
-      includeBoundTextElement: false,
-    });
-    if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
-      return selected[0].locked
-        ? "labels.elementLock.unlock"
-        : "labels.elementLock.lock";
-    }
-
-    return shouldLock(selected)
-      ? "labels.elementLock.lockAll"
-      : "labels.elementLock.unlockAll";
-  },
   keyTest: (event, appState, elements, app) => {
     return (
       event.key.toLocaleLowerCase() === KEYS.L &&
@@ -77,10 +84,16 @@ export const actionToggleElementLock = register({
 
 export const actionUnlockAllElements = register({
   name: "unlockAllElements",
+  paletteName: "Unlock all elements",
   trackEvent: { category: "canvas" },
   viewMode: false,
-  predicate: (elements) => {
-    return elements.some((element) => element.locked);
+  icon: UnlockedIcon,
+  predicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return (
+      selectedElements.length === 0 &&
+      elements.some((element) => element.locked)
+    );
   },
   perform: (elements, appState) => {
     const lockedElements = elements.filter((el) => el.locked);
@@ -101,5 +114,5 @@ export const actionUnlockAllElements = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.elementLock.unlockAll",
+  label: "labels.elementLock.unlockAll",
 });

+ 11 - 1
packages/excalidraw/actions/actionExport.tsx

@@ -1,4 +1,4 @@
-import { questionCircle, saveAs } from "../components/icons";
+import { ExportIcon, questionCircle, saveAs } from "../components/icons";
 import { ProjectName } from "../components/ProjectName";
 import { ToolButton } from "../components/ToolButton";
 import { Tooltip } from "../components/Tooltip";
@@ -22,6 +22,7 @@ import "../components/ToolIcon.scss";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",
+  label: "labels.fileTitle",
   trackEvent: false,
   perform: (_elements, appState, value) => {
     return { appState: { ...appState, name: value }, commitToHistory: false };
@@ -38,6 +39,7 @@ export const actionChangeProjectName = register({
 
 export const actionChangeExportScale = register({
   name: "changeExportScale",
+  label: "imageExportDialog.scale",
   trackEvent: { category: "export", action: "scale" },
   perform: (_elements, appState, value) => {
     return {
@@ -87,6 +89,7 @@ export const actionChangeExportScale = register({
 
 export const actionChangeExportBackground = register({
   name: "changeExportBackground",
+  label: "imageExportDialog.label.withBackground",
   trackEvent: { category: "export", action: "toggleBackground" },
   perform: (_elements, appState, value) => {
     return {
@@ -106,6 +109,7 @@ export const actionChangeExportBackground = register({
 
 export const actionChangeExportEmbedScene = register({
   name: "changeExportEmbedScene",
+  label: "imageExportDialog.tooltip.embedScene",
   trackEvent: { category: "export", action: "embedScene" },
   perform: (_elements, appState, value) => {
     return {
@@ -128,6 +132,8 @@ export const actionChangeExportEmbedScene = register({
 
 export const actionSaveToActiveFile = register({
   name: "saveToActiveFile",
+  label: "buttons.save",
+  icon: ExportIcon,
   trackEvent: { category: "export" },
   predicate: (elements, appState, props, app) => {
     return (
@@ -181,6 +187,8 @@ export const actionSaveToActiveFile = register({
 
 export const actionSaveFileToDisk = register({
   name: "saveFileToDisk",
+  label: "exportDialog.disk_title",
+  icon: ExportIcon,
   viewMode: true,
   trackEvent: { category: "export" },
   perform: async (elements, appState, value, app) => {
@@ -230,6 +238,7 @@ export const actionSaveFileToDisk = register({
 
 export const actionLoadScene = register({
   name: "loadScene",
+  label: "buttons.load",
   trackEvent: { category: "export" },
   predicate: (elements, appState, props, app) => {
     return (
@@ -267,6 +276,7 @@ export const actionLoadScene = register({
 
 export const actionExportWithDarkMode = register({
   name: "exportWithDarkMode",
+  label: "imageExportDialog.label.darkMode",
   trackEvent: { category: "export", action: "toggleTheme" },
   perform: (_elements, appState, value) => {
     return {

+ 1 - 0
packages/excalidraw/actions/actionFinalize.tsx

@@ -19,6 +19,7 @@ import { resetCursor } from "../cursor";
 
 export const actionFinalize = register({
   name: "finalize",
+  label: "",
   trackEvent: false,
   perform: (
     elements,

+ 5 - 2
packages/excalidraw/actions/actionFlip.ts

@@ -17,9 +17,12 @@ import {
   unbindLinearElements,
 } from "../element/binding";
 import { updateFrameMembershipOfSelectedElements } from "../frame";
+import { flipHorizontal, flipVertical } from "../components/icons";
 
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
+  label: "labels.flipHorizontal",
+  icon: flipHorizontal,
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     return {
@@ -38,11 +41,12 @@ export const actionFlipHorizontal = register({
     };
   },
   keyTest: (event) => event.shiftKey && event.code === CODES.H,
-  contextItemLabel: "labels.flipHorizontal",
 });
 
 export const actionFlipVertical = register({
   name: "flipVertical",
+  label: "labels.flipVertical",
+  icon: flipVertical,
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     return {
@@ -62,7 +66,6 @@ export const actionFlipVertical = register({
   },
   keyTest: (event) =>
     event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
-  contextItemLabel: "labels.flipVertical",
 });
 
 const flipSelectedElements = (

+ 12 - 5
packages/excalidraw/actions/actionFrame.ts

@@ -3,13 +3,17 @@ import { ExcalidrawElement } from "../element/types";
 import { removeAllElementsFromFrame } from "../frame";
 import { getFrameChildren } from "../frame";
 import { KEYS } from "../keys";
-import { AppClassProperties, AppState } from "../types";
+import { AppClassProperties, AppState, UIAppState } from "../types";
 import { updateActiveTool } from "../utils";
 import { setCursorForShape } from "../cursor";
 import { register } from "./register";
 import { isFrameLikeElement } from "../element/typeChecks";
+import { frameToolIcon } from "../components/icons";
 
-const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+const isSingleFrameSelected = (
+  appState: UIAppState,
+  app: AppClassProperties,
+) => {
   const selectedElements = app.scene.getSelectedElements(appState);
 
   return (
@@ -19,6 +23,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
 
 export const actionSelectAllElementsInFrame = register({
   name: "selectAllElementsInFrame",
+  label: "labels.selectAllElementsInFrame",
   trackEvent: { category: "canvas" },
   perform: (elements, appState, _, app) => {
     const selectedElement =
@@ -49,13 +54,13 @@ export const actionSelectAllElementsInFrame = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.selectAllElementsInFrame",
   predicate: (elements, appState, _, app) =>
     isSingleFrameSelected(appState, app),
 });
 
 export const actionRemoveAllElementsFromFrame = register({
   name: "removeAllElementsFromFrame",
+  label: "labels.removeAllElementsFromFrame",
   trackEvent: { category: "history" },
   perform: (elements, appState, _, app) => {
     const selectedElement =
@@ -80,13 +85,13 @@ export const actionRemoveAllElementsFromFrame = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.removeAllElementsFromFrame",
   predicate: (elements, appState, _, app) =>
     isSingleFrameSelected(appState, app),
 });
 
 export const actionupdateFrameRendering = register({
   name: "updateFrameRendering",
+  label: "labels.updateFrameRendering",
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (elements, appState) => {
@@ -102,13 +107,15 @@ export const actionupdateFrameRendering = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.updateFrameRendering",
   checked: (appState: AppState) => appState.frameRendering.enabled,
 });
 
 export const actionSetFrameAsActiveTool = register({
   name: "setFrameAsActiveTool",
+  label: "toolBar.frame",
   trackEvent: { category: "toolbar" },
+  icon: frameToolIcon,
+  viewMode: false,
   perform: (elements, appState, _, app) => {
     const nextActiveTool = updateActiveTool(appState, {
       type: "frame",

+ 4 - 2
packages/excalidraw/actions/actionGroup.tsx

@@ -61,6 +61,8 @@ const enableActionGroup = (
 
 export const actionGroup = register({
   name: "group",
+  label: "labels.group",
+  icon: (appState) => <GroupIcon theme={appState.theme} />,
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements({
@@ -157,7 +159,6 @@ export const actionGroup = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.group",
   predicate: (elements, appState, _, app) =>
     enableActionGroup(elements, appState, app),
   keyTest: (event) =>
@@ -177,6 +178,8 @@ export const actionGroup = register({
 
 export const actionUngroup = register({
   name: "ungroup",
+  label: "labels.ungroup",
+  icon: (appState) => <UngroupIcon theme={appState.theme} />,
   trackEvent: { category: "element" },
   perform: (elements, appState, _, app) => {
     const groupIds = getSelectedGroupIds(appState);
@@ -263,7 +266,6 @@ export const actionUngroup = register({
     event.shiftKey &&
     event[KEYS.CTRL_OR_CMD] &&
     event.key === KEYS.G.toUpperCase(),
-  contextItemLabel: "labels.ungroup",
   predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
 
   PanelComponent: ({ elements, appState, updateData }) => (

+ 6 - 0
packages/excalidraw/actions/actionHistory.tsx

@@ -63,7 +63,10 @@ type ActionCreator = (history: History) => Action;
 
 export const createUndoAction: ActionCreator = (history) => ({
   name: "undo",
+  label: "buttons.undo",
+  icon: UndoIcon,
   trackEvent: { category: "history" },
+  viewMode: false,
   perform: (elements, appState) =>
     writeData(elements, appState, () => history.undoOnce()),
   keyTest: (event) =>
@@ -84,7 +87,10 @@ export const createUndoAction: ActionCreator = (history) => ({
 
 export const createRedoAction: ActionCreator = (history) => ({
   name: "redo",
+  label: "buttons.redo",
+  icon: RedoIcon,
   trackEvent: { category: "history" },
+  viewMode: false,
   perform: (elements, appState) =>
     writeData(elements, appState, () => history.redoOnce()),
   keyTest: (event) =>

+ 11 - 9
packages/excalidraw/actions/actionLinearEditor.ts

@@ -1,3 +1,4 @@
+import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { isLinearElement } from "../element/typeChecks";
 import { ExcalidrawLinearElement } from "../element/types";
@@ -5,6 +6,16 @@ import { register } from "./register";
 
 export const actionToggleLinearEditor = register({
   name: "toggleLinearEditor",
+  category: DEFAULT_CATEGORIES.elements,
+  label: (elements, appState, app) => {
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawLinearElement;
+    return appState.editingLinearElement?.elementId === selectedElement?.id
+      ? "labels.lineEditor.exit"
+      : "labels.lineEditor.edit";
+  },
   trackEvent: {
     category: "element",
   },
@@ -33,13 +44,4 @@ export const actionToggleLinearEditor = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: (elements, appState, app) => {
-    const selectedElement = app.scene.getSelectedElements({
-      selectedElementIds: appState.selectedElementIds,
-      includeBoundTextElement: true,
-    })[0] as ExcalidrawLinearElement;
-    return appState.editingLinearElement?.elementId === selectedElement.id
-      ? "labels.lineEditor.exit"
-      : "labels.lineEditor.edit";
-  },
 });

+ 2 - 2
packages/excalidraw/actions/actionLink.tsx

@@ -10,6 +10,8 @@ import { register } from "./register";
 
 export const actionLink = register({
   name: "hyperlink",
+  label: (elements, appState) => getContextMenuLabel(elements, appState),
+  icon: LinkIcon,
   perform: (elements, appState) => {
     if (appState.showHyperlinkPopup === "editor") {
       return false;
@@ -27,8 +29,6 @@ export const actionLink = register({
   },
   trackEvent: { category: "hyperlink", action: "click" },
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
-  contextItemLabel: (elements, appState) =>
-    getContextMenuLabel(elements, appState),
   predicate: (elements, appState) => {
     const selectedElements = getSelectedElements(elements, appState);
     return selectedElements.length === 1;

+ 5 - 1
packages/excalidraw/actions/actionMenu.tsx

@@ -1,4 +1,4 @@
-import { HamburgerMenuIcon, palette } from "../components/icons";
+import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import { showSelectedShapeActions, getNonDeletedElements } from "../element";
@@ -7,6 +7,7 @@ import { KEYS } from "../keys";
 
 export const actionToggleCanvasMenu = register({
   name: "toggleCanvasMenu",
+  label: "buttons.menu",
   trackEvent: { category: "menu" },
   perform: (_, appState) => ({
     appState: {
@@ -28,6 +29,7 @@ export const actionToggleCanvasMenu = register({
 
 export const actionToggleEditMenu = register({
   name: "toggleEditMenu",
+  label: "buttons.edit",
   trackEvent: { category: "menu" },
   perform: (_elements, appState) => ({
     appState: {
@@ -53,6 +55,8 @@ export const actionToggleEditMenu = register({
 
 export const actionShortcuts = register({
   name: "toggleShortcuts",
+  label: "welcomeScreen.defaults.helpHint",
+  icon: HelpIconThin,
   viewMode: true,
   trackEvent: { category: "menu", action: "toggleHelpDialog" },
   perform: (_elements, appState, _, { focusContainer }) => {

+ 1 - 0
packages/excalidraw/actions/actionNavigate.tsx

@@ -13,6 +13,7 @@ import clsx from "clsx";
 
 export const actionGoToCollaborator = register({
   name: "goToCollaborator",
+  label: "Go to a collaborator",
   viewMode: true,
   trackEvent: { category: "collab" },
   perform: (_elements, appState, collaborator: Collaborator) => {

+ 18 - 0
packages/excalidraw/actions/actionProperties.tsx

@@ -49,6 +49,7 @@ import {
   ArrowheadCircleOutlineIcon,
   ArrowheadDiamondIcon,
   ArrowheadDiamondOutlineIcon,
+  fontSizeIcon,
 } from "../components/icons";
 import {
   DEFAULT_FONT_FAMILY,
@@ -238,6 +239,7 @@ const changeFontSize = (
 
 export const actionChangeStrokeColor = register({
   name: "changeStrokeColor",
+  label: "labels.stroke",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -288,6 +290,7 @@ export const actionChangeStrokeColor = register({
 
 export const actionChangeBackgroundColor = register({
   name: "changeBackgroundColor",
+  label: "labels.changeBackground",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -331,6 +334,7 @@ export const actionChangeBackgroundColor = register({
 
 export const actionChangeFillStyle = register({
   name: "changeFillStyle",
+  label: "labels.fill",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     trackEvent(
@@ -408,6 +412,7 @@ export const actionChangeFillStyle = register({
 
 export const actionChangeStrokeWidth = register({
   name: "changeStrokeWidth",
+  label: "labels.strokeWidth",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -461,6 +466,7 @@ export const actionChangeStrokeWidth = register({
 
 export const actionChangeSloppiness = register({
   name: "changeSloppiness",
+  label: "labels.sloppiness",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -512,6 +518,7 @@ export const actionChangeSloppiness = register({
 
 export const actionChangeStrokeStyle = register({
   name: "changeStrokeStyle",
+  label: "labels.strokeStyle",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -562,6 +569,7 @@ export const actionChangeStrokeStyle = register({
 
 export const actionChangeOpacity = register({
   name: "changeOpacity",
+  label: "labels.opacity",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -603,6 +611,7 @@ export const actionChangeOpacity = register({
 
 export const actionChangeFontSize = register({
   name: "changeFontSize",
+  label: "labels.fontSize",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     return changeFontSize(elements, appState, app, () => value, value);
@@ -673,6 +682,8 @@ export const actionChangeFontSize = register({
 
 export const actionDecreaseFontSize = register({
   name: "decreaseFontSize",
+  label: "labels.decreaseFontSize",
+  icon: fontSizeIcon,
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     return changeFontSize(elements, appState, app, (element) =>
@@ -695,6 +706,8 @@ export const actionDecreaseFontSize = register({
 
 export const actionIncreaseFontSize = register({
   name: "increaseFontSize",
+  label: "labels.increaseFontSize",
+  icon: fontSizeIcon,
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     return changeFontSize(elements, appState, app, (element) =>
@@ -713,6 +726,7 @@ export const actionIncreaseFontSize = register({
 
 export const actionChangeFontFamily = register({
   name: "changeFontFamily",
+  label: "labels.fontFamily",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     return {
@@ -816,6 +830,7 @@ export const actionChangeFontFamily = register({
 
 export const actionChangeTextAlign = register({
   name: "changeTextAlign",
+  label: "Change text alignment",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
     return {
@@ -905,6 +920,7 @@ export const actionChangeTextAlign = register({
 
 export const actionChangeVerticalAlign = register({
   name: "changeVerticalAlign",
+  label: "Change vertical alignment",
   trackEvent: { category: "element" },
   perform: (elements, appState, value, app) => {
     return {
@@ -994,6 +1010,7 @@ export const actionChangeVerticalAlign = register({
 
 export const actionChangeRoundness = register({
   name: "changeRoundness",
+  label: "Change edge roundness",
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
@@ -1132,6 +1149,7 @@ const getArrowheadOptions = (flip: boolean) => {
 
 export const actionChangeArrowhead = register({
   name: "changeArrowhead",
+  label: "Change arrowheads",
   trackEvent: false,
   perform: (
     elements,

+ 4 - 1
packages/excalidraw/actions/actionSelectAll.ts

@@ -6,10 +6,14 @@ import { ExcalidrawElement } from "../element/types";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { excludeElementsInFramesFromSelection } from "../scene/selection";
+import { selectAllIcon } from "../components/icons";
 
 export const actionSelectAll = register({
   name: "selectAll",
+  label: "labels.selectAll",
+  icon: selectAllIcon,
   trackEvent: { category: "canvas" },
+  viewMode: false,
   perform: (elements, appState, value, app) => {
     if (appState.editingLinearElement) {
       return false;
@@ -49,6 +53,5 @@ export const actionSelectAll = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.selectAll",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
 });

+ 5 - 2
packages/excalidraw/actions/actionStyles.ts

@@ -25,12 +25,15 @@ import {
 } from "../element/typeChecks";
 import { getSelectedElements } from "../scene";
 import { ExcalidrawTextElement } from "../element/types";
+import { paintIcon } from "../components/icons";
 
 // `copiedStyles` is exported only for tests.
 export let copiedStyles: string = "{}";
 
 export const actionCopyStyles = register({
   name: "copyStyles",
+  label: "labels.copyStyles",
+  icon: paintIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, formData, app) => {
     const elementsCopied = [];
@@ -54,13 +57,14 @@ export const actionCopyStyles = register({
       commitToHistory: false,
     };
   },
-  contextItemLabel: "labels.copyStyles",
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
 });
 
 export const actionPasteStyles = register({
   name: "pasteStyles",
+  label: "labels.pasteStyles",
+  icon: paintIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, formData, app) => {
     const elementsCopied = JSON.parse(copiedStyles);
@@ -159,7 +163,6 @@ export const actionPasteStyles = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.pasteStyles",
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
 });

+ 1 - 1
packages/excalidraw/actions/actionToggleGridMode.tsx

@@ -5,6 +5,7 @@ import { AppState } from "../types";
 
 export const actionToggleGridMode = register({
   name: "gridMode",
+  label: "labels.showGrid",
   viewMode: true,
   trackEvent: {
     category: "canvas",
@@ -24,6 +25,5 @@ export const actionToggleGridMode = register({
   predicate: (element, appState, props) => {
     return typeof props.gridModeEnabled === "undefined";
   },
-  contextItemLabel: "labels.showGrid",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
 });

+ 4 - 2
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx

@@ -1,9 +1,12 @@
+import { magnetIcon } from "../components/icons";
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
 
 export const actionToggleObjectsSnapMode = register({
   name: "objectsSnapMode",
-  viewMode: true,
+  label: "buttons.objectsSnapMode",
+  icon: magnetIcon,
+  viewMode: false,
   trackEvent: {
     category: "canvas",
     predicate: (appState) => !appState.objectsSnapModeEnabled,
@@ -22,7 +25,6 @@ export const actionToggleObjectsSnapMode = register({
   predicate: (elements, appState, appProps) => {
     return typeof appProps.objectsSnapModeEnabled === "undefined";
   },
-  contextItemLabel: "buttons.objectsSnapMode",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
 });

+ 4 - 1
packages/excalidraw/actions/actionToggleStats.tsx

@@ -1,8 +1,12 @@
 import { register } from "./register";
 import { CODES, KEYS } from "../keys";
+import { abacusIcon } from "../components/icons";
 
 export const actionToggleStats = register({
   name: "stats",
+  label: "stats.title",
+  icon: abacusIcon,
+  paletteName: "Toggle stats",
   viewMode: true,
   trackEvent: { category: "menu" },
   perform(elements, appState) {
@@ -15,7 +19,6 @@ export const actionToggleStats = register({
     };
   },
   checked: (appState) => appState.showStats,
-  contextItemLabel: "stats.title",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
 });

+ 4 - 1
packages/excalidraw/actions/actionToggleViewMode.tsx

@@ -1,8 +1,12 @@
+import { eyeIcon } from "../components/icons";
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
 
 export const actionToggleViewMode = register({
   name: "viewMode",
+  label: "labels.viewMode",
+  paletteName: "Toggle view mode",
+  icon: eyeIcon,
   viewMode: true,
   trackEvent: {
     category: "canvas",
@@ -21,7 +25,6 @@ export const actionToggleViewMode = register({
   predicate: (elements, appState, appProps) => {
     return typeof appProps.viewModeEnabled === "undefined";
   },
-  contextItemLabel: "labels.viewMode",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
 });

+ 4 - 1
packages/excalidraw/actions/actionToggleZenMode.tsx

@@ -1,8 +1,12 @@
+import { coffeeIcon } from "../components/icons";
 import { CODES, KEYS } from "../keys";
 import { register } from "./register";
 
 export const actionToggleZenMode = register({
   name: "zenMode",
+  label: "buttons.zenMode",
+  icon: coffeeIcon,
+  paletteName: "Toggle zen mode",
   viewMode: true,
   trackEvent: {
     category: "canvas",
@@ -21,7 +25,6 @@ export const actionToggleZenMode = register({
   predicate: (elements, appState, appProps) => {
     return typeof appProps.zenModeEnabled === "undefined";
   },
-  contextItemLabel: "buttons.zenMode",
   keyTest: (event) =>
     !event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
 });

+ 8 - 4
packages/excalidraw/actions/actionZindex.tsx

@@ -19,6 +19,8 @@ import { isDarwin } from "../constants";
 
 export const actionSendBackward = register({
   name: "sendBackward",
+  label: "labels.sendBackward",
+  icon: SendBackwardIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
@@ -27,7 +29,6 @@ export const actionSendBackward = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.sendBackward",
   keyPriority: 40,
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] &&
@@ -47,6 +48,8 @@ export const actionSendBackward = register({
 
 export const actionBringForward = register({
   name: "bringForward",
+  label: "labels.bringForward",
+  icon: BringForwardIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
@@ -55,7 +58,6 @@ export const actionBringForward = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.bringForward",
   keyPriority: 40,
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] &&
@@ -75,6 +77,8 @@ export const actionBringForward = register({
 
 export const actionSendToBack = register({
   name: "sendToBack",
+  label: "labels.sendToBack",
+  icon: SendToBackIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState) => {
     return {
@@ -83,7 +87,6 @@ export const actionSendToBack = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.sendToBack",
   keyTest: (event) =>
     isDarwin
       ? event[KEYS.CTRL_OR_CMD] &&
@@ -110,6 +113,8 @@ export const actionSendToBack = register({
 
 export const actionBringToFront = register({
   name: "bringToFront",
+  label: "labels.bringToFront",
+  icon: BringToFrontIcon,
   trackEvent: { category: "element" },
 
   perform: (elements, appState) => {
@@ -119,7 +124,6 @@ export const actionBringToFront = register({
       commitToHistory: true,
     };
   },
-  contextItemLabel: "labels.bringToFront",
   keyTest: (event) =>
     isDarwin
       ? event[KEYS.CTRL_OR_CMD] &&

+ 34 - 3
packages/excalidraw/actions/shortcuts.ts

@@ -36,9 +36,22 @@ export type ShortcutName =
       | "flipVertical"
       | "hyperlink"
       | "toggleElementLock"
+      | "resetZoom"
+      | "zoomOut"
+      | "zoomIn"
+      | "zoomToFit"
+      | "zoomToFitSelectionInViewport"
+      | "zoomToFitSelection"
+      | "toggleEraserTool"
+      | "toggleHandTool"
+      | "setFrameAsActiveTool"
+      | "saveFileToDisk"
+      | "saveToActiveFile"
+      | "toggleShortcuts"
     >
   | "saveScene"
-  | "imageExport";
+  | "imageExport"
+  | "commandPalette";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -46,6 +59,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   loadScene: [getShortcutKey("CtrlOrCmd+O")],
   clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
   imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
+  commandPalette: [
+    getShortcutKey("CtrlOrCmd+Shift+P"),
+    getShortcutKey("CtrlOrCmd+/"),
+  ],
   cut: [getShortcutKey("CtrlOrCmd+X")],
   copy: [getShortcutKey("CtrlOrCmd+C")],
   paste: [getShortcutKey("CtrlOrCmd+V")],
@@ -83,10 +100,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   viewMode: [getShortcutKey("Alt+R")],
   hyperlink: [getShortcutKey("CtrlOrCmd+K")],
   toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
+  resetZoom: [getShortcutKey("CtrlOrCmd+0")],
+  zoomOut: [getShortcutKey("CtrlOrCmd+-")],
+  zoomIn: [getShortcutKey("CtrlOrCmd++")],
+  zoomToFitSelection: [getShortcutKey("Shift+3")],
+  zoomToFit: [getShortcutKey("Shift+1")],
+  zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
+  toggleEraserTool: [getShortcutKey("E")],
+  toggleHandTool: [getShortcutKey("H")],
+  setFrameAsActiveTool: [getShortcutKey("F")],
+  saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
+  saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
+  toggleShortcuts: [getShortcutKey("?")],
 };
 
-export const getShortcutFromShortcutName = (name: ShortcutName) => {
+export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
   const shortcuts = shortcutMap[name];
   // if multiple shortcuts available, take the first one
-  return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
+  return shortcuts && shortcuts.length > 0
+    ? shortcuts[idx] || shortcuts[0]
+    : "";
 };

+ 23 - 9
packages/excalidraw/actions/types.ts

@@ -5,10 +5,16 @@ import {
   AppState,
   ExcalidrawProps,
   BinaryFiles,
+  UIAppState,
 } from "../types";
 import { MarkOptional } from "../utility-types";
 
-export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
+export type ActionSource =
+  | "ui"
+  | "keyboard"
+  | "contextMenu"
+  | "api"
+  | "commandPalette";
 
 /** if false, the action should be prevented */
 export type ActionResult =
@@ -124,7 +130,8 @@ export type ActionName =
   | "setFrameAsActiveTool"
   | "setEmbeddableAsActiveTool"
   | "createContainerFromText"
-  | "wrapTextInContainer";
+  | "wrapTextInContainer"
+  | "commandPalette";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
@@ -137,6 +144,20 @@ export type PanelComponentProps = {
 
 export interface Action {
   name: ActionName;
+  label:
+    | string
+    | ((
+        elements: readonly ExcalidrawElement[],
+        appState: Readonly<AppState>,
+        app: AppClassProperties,
+      ) => string);
+  keywords?: string[];
+  icon?:
+    | React.ReactNode
+    | ((
+        appState: UIAppState,
+        elements: readonly ExcalidrawElement[],
+      ) => React.ReactNode);
   PanelComponent?: React.FC<PanelComponentProps>;
   perform: ActionFn;
   keyPriority?: number;
@@ -146,13 +167,6 @@ export interface Action {
     elements: readonly ExcalidrawElement[],
     app: AppClassProperties,
   ) => boolean;
-  contextItemLabel?:
-    | string
-    | ((
-        elements: readonly ExcalidrawElement[],
-        appState: Readonly<AppState>,
-        app: AppClassProperties,
-      ) => string);
   predicate?: (
     elements: readonly ExcalidrawElement[],
     appState: AppState,

+ 37 - 20
packages/excalidraw/components/Actions.tsx

@@ -1,6 +1,7 @@
 import { useState } from "react";
 import { ActionManager } from "../actions/manager";
 import {
+  ExcalidrawElement,
   ExcalidrawElementType,
   NonDeletedElementsMap,
   NonDeletedSceneElementsMap,
@@ -45,6 +46,40 @@ import {
 import { KEYS } from "../keys";
 import { useTunnels } from "../context/tunnels";
 
+export const canChangeStrokeColor = (
+  appState: UIAppState,
+  targetElements: ExcalidrawElement[],
+) => {
+  let commonSelectedType: ExcalidrawElementType | null =
+    targetElements[0]?.type || null;
+
+  for (const element of targetElements) {
+    if (element.type !== commonSelectedType) {
+      commonSelectedType = null;
+      break;
+    }
+  }
+
+  return (
+    (hasStrokeColor(appState.activeTool.type) &&
+      appState.activeTool.type !== "image" &&
+      commonSelectedType !== "image" &&
+      commonSelectedType !== "frame" &&
+      commonSelectedType !== "magicframe") ||
+    targetElements.some((element) => hasStrokeColor(element.type))
+  );
+};
+
+export const canChangeBackgroundColor = (
+  appState: UIAppState,
+  targetElements: ExcalidrawElement[],
+) => {
+  return (
+    hasBackground(appState.activeTool.type) ||
+    targetElements.some((element) => hasBackground(element.type))
+  );
+};
+
 export const SelectedShapeActions = ({
   appState,
   elementsMap,
@@ -75,35 +110,17 @@ export const SelectedShapeActions = ({
       (element) =>
         hasBackground(element.type) && !isTransparent(element.backgroundColor),
     );
-  const showChangeBackgroundIcons =
-    hasBackground(appState.activeTool.type) ||
-    targetElements.some((element) => hasBackground(element.type));
 
   const showLinkIcon =
     targetElements.length === 1 || isSingleElementBoundContainer;
 
-  let commonSelectedType: ExcalidrawElementType | null =
-    targetElements[0]?.type || null;
-
-  for (const element of targetElements) {
-    if (element.type !== commonSelectedType) {
-      commonSelectedType = null;
-      break;
-    }
-  }
-
   return (
     <div className="panelColumn">
       <div>
-        {((hasStrokeColor(appState.activeTool.type) &&
-          appState.activeTool.type !== "image" &&
-          commonSelectedType !== "image" &&
-          commonSelectedType !== "frame" &&
-          commonSelectedType !== "magicframe") ||
-          targetElements.some((element) => hasStrokeColor(element.type))) &&
+        {canChangeStrokeColor(appState, targetElements) &&
           renderAction("changeStrokeColor")}
       </div>
-      {showChangeBackgroundIcons && (
+      {canChangeBackgroundColor(appState, targetElements) && (
         <div>{renderAction("changeBackgroundColor")}</div>
       )}
       {showFillIcons && renderAction("changeFillStyle")}

+ 22 - 6
packages/excalidraw/components/App.tsx

@@ -413,6 +413,7 @@ import {
   isPointHittingLink,
   isPointHittingLinkIcon,
 } from "./hyperlink/helpers";
+import { getShortcutFromShortcutName } from "../actions/shortcuts";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -3746,6 +3747,22 @@ class App extends React.Component<AppProps, AppState> {
         });
       }
 
+      if (
+        event[KEYS.CTRL_OR_CMD] &&
+        event.key === KEYS.P &&
+        !event.shiftKey &&
+        !event.altKey
+      ) {
+        this.setToast({
+          message: t("commandPalette.shortcutHint", {
+            shortcutOne: getShortcutFromShortcutName("commandPalette"),
+            shortcutTwo: getShortcutFromShortcutName("commandPalette", 1),
+          }),
+        });
+        event.preventDefault();
+        return;
+      }
+
       if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
         IS_PLAIN_PASTE = event.shiftKey;
         clearTimeout(IS_PLAIN_PASTE_TIMER);
@@ -4604,11 +4621,6 @@ class App extends React.Component<AppProps, AppState> {
           editingLinearElement: new LinearElementEditor(selectedElements[0]),
         });
         return;
-      } else if (
-        this.state.editingLinearElement &&
-        this.state.editingLinearElement.elementId === selectedElements[0].id
-      ) {
-        return;
       }
     }
 
@@ -4781,7 +4793,11 @@ class App extends React.Component<AppProps, AppState> {
         }
         if (!customEvent?.defaultPrevented) {
           const target = isLocalLink(url) ? "_self" : "_blank";
-          const newWindow = window.open(undefined, target);
+          const newWindow = window.open(
+            undefined,
+            target,
+            "noopener noreferrer",
+          );
           // https://mathiasbynens.github.io/rel-noopener/
           if (newWindow) {
             newWindow.opener = null;

+ 137 - 0
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;
+    }
+  }
+}

+ 915 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -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>
+  );
+};

+ 11 - 0
packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts

@@ -0,0 +1,11 @@
+import { actionToggleTheme } from "../../actions";
+import { CommandPaletteItem } from "./types";
+
+export const toggleTheme: CommandPaletteItem = {
+  ...actionToggleTheme,
+  category: "App",
+  label: "Toggle theme",
+  perform: ({ actionManager }) => {
+    actionManager.executeAction(actionToggleTheme, "commandPalette");
+  },
+};

+ 26 - 0
packages/excalidraw/components/CommandPalette/types.ts

@@ -0,0 +1,26 @@
+import { ActionManager } from "../../actions/manager";
+import { Action } from "../../actions/types";
+import { 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;
+};

+ 4 - 4
packages/excalidraw/components/ContextMenu.tsx

@@ -78,17 +78,17 @@ export const ContextMenu = React.memo(
 
             const actionName = item.name;
             let label = "";
-            if (item.contextItemLabel) {
-              if (typeof item.contextItemLabel === "function") {
+            if (item.label) {
+              if (typeof item.label === "function") {
                 label = t(
-                  item.contextItemLabel(
+                  item.label(
                     elements,
                     appState,
                     actionManager.app,
                   ) as unknown as TranslationKeys,
                 );
               } else {
-                label = t(item.contextItemLabel as unknown as TranslationKeys);
+                label = t(item.label as unknown as TranslationKeys);
               }
             }
 

+ 6 - 0
packages/excalidraw/components/Dialog.scss

@@ -37,6 +37,12 @@
       width: 1.5rem;
       height: 1.5rem;
     }
+
+    & + .Dialog__content {
+      --offset: 28px;
+      height: calc(100% - var(--offset)) !important;
+      margin-top: var(--offset) !important;
+    }
   }
 
   .Dialog--fullscreen {

+ 18 - 14
packages/excalidraw/components/Dialog.tsx

@@ -1,7 +1,6 @@
 import clsx from "clsx";
 import React, { useEffect, useState } from "react";
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
-import { t } from "../i18n";
 import {
   useExcalidrawContainer,
   useDevice,
@@ -9,13 +8,14 @@ import {
 } from "./App";
 import { KEYS } from "../keys";
 import "./Dialog.scss";
-import { back, CloseIcon } from "./icons";
 import { Island } from "./Island";
 import { Modal } from "./Modal";
 import { queryFocusableElements } from "../utils";
 import { useSetAtom } from "jotai";
 import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 import { jotaiScope } from "../jotai";
+import { t } from "../i18n";
+import { CloseIcon } from "./icons";
 
 export type DialogSize = number | "small" | "regular" | "wide" | undefined;
 
@@ -58,10 +58,12 @@ export const Dialog = (props: DialogProps) => {
 
     const focusableElements = queryFocusableElements(islandNode);
 
-    if (focusableElements.length > 0 && props.autofocus !== false) {
-      // If there's an element other than close, focus it.
-      (focusableElements[1] || focusableElements[0]).focus();
-    }
+    setTimeout(() => {
+      if (focusableElements.length > 0 && props.autofocus !== false) {
+        // If there's an element other than close, focus it.
+        (focusableElements[1] || focusableElements[0]).focus();
+      }
+    });
 
     const handleKeyDown = (event: KeyboardEvent) => {
       if (event.key === KEYS.TAB) {
@@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => {
             <span className="Dialog__titleContent">{props.title}</span>
           </h2>
         )}
-        <button
-          className="Dialog__close"
-          onClick={onClose}
-          title={t("buttons.close")}
-          aria-label={t("buttons.close")}
-        >
-          {isFullscreen ? back : CloseIcon}
-        </button>
+        {isFullscreen && (
+          <button
+            className="Dialog__close"
+            onClick={onClose}
+            title={t("buttons.close")}
+            aria-label={t("buttons.close")}
+          >
+            {CloseIcon}
+          </button>
+        )}
         <div className="Dialog__content">{props.children}</div>
       </Island>
     </Modal>

+ 4 - 2
packages/excalidraw/components/FilledButton.scss

@@ -10,6 +10,10 @@
     background-color: var(--back-color);
     border-color: var(--border-color);
 
+    &:hover {
+      transition: all 150ms ease-out;
+    }
+
     .Spinner {
       --spinner-color: var(--color-surface-lowest);
       position: absolute;
@@ -203,8 +207,6 @@
 
     user-select: none;
 
-    transition: all 150ms ease-out;
-
     &--size-large {
       font-weight: 600;
       font-size: 0.875rem;

+ 8 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -7,6 +7,7 @@ import "./HelpDialog.scss";
 import { ExternalLinkIcon } from "./icons";
 import { probablySupportsClipboardBlob } from "../clipboard";
 import { isDarwin, isFirefox, isWindows } from "../constants";
+import { getShortcutFromShortcutName } from "../actions/shortcuts";
 
 const Header = () => (
   <div className="HelpDialog__header">
@@ -278,6 +279,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("stats.title")}
               shortcuts={[getShortcutKey("Alt+/")]}
             />
+            <Shortcut
+              label={t("commandPalette.title")}
+              shortcuts={[
+                getShortcutFromShortcutName("commandPalette"),
+                getShortcutFromShortcutName("commandPalette", 1),
+              ]}
+            />
           </ShortcutIsland>
           <ShortcutIsland
             className="HelpDialog__island--editor"

+ 1 - 1
packages/excalidraw/components/InlineIcon.tsx

@@ -1,4 +1,4 @@
-export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
+export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
   return (
     <span
       style={{

+ 18 - 3
packages/excalidraw/components/Modal.scss

@@ -23,6 +23,20 @@
 
     .Island {
       padding: 2.5rem;
+      border: 0;
+      box-shadow: none;
+      border-radius: 0;
+    }
+
+    &.animations-disabled {
+      .Modal__background {
+        animation: none;
+      }
+
+      .Modal__content {
+        animation: none;
+        opacity: 1;
+      }
     }
   }
 
@@ -35,7 +49,7 @@
     z-index: 1;
     background-color: rgba(#121212, 0.2);
 
-    animation: Modal__background__fade-in 0.125s linear forwards;
+    animation: Modal__background__fade-in 0.1s linear forwards;
   }
 
   .Modal__content {
@@ -47,7 +61,8 @@
 
     opacity: 0;
     transform: translateY(10px);
-    animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
+    animation: Modal__content_fade-in 0.025s ease-out 0s forwards;
+
     position: relative;
     overflow-y: auto;
 
@@ -56,7 +71,7 @@
 
     border: 1px solid var(--dialog-border-color);
     box-shadow: var(--modal-shadow);
-    border-radius: 6px;
+    border-radius: 0.75rem;
     box-sizing: border-box;
 
     &:focus {

+ 8 - 1
packages/excalidraw/components/Modal.tsx

@@ -5,6 +5,7 @@ import clsx from "clsx";
 import { KEYS } from "../keys";
 import { AppState } from "../types";
 import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
+import { useRef } from "react";
 
 export const Modal: React.FC<{
   className?: string;
@@ -20,6 +21,10 @@ export const Modal: React.FC<{
     className: "excalidraw-modal-container",
   });
 
+  const animationsDisabledRef = useRef(
+    document.body.classList.contains("excalidraw-animations-disabled"),
+  );
+
   if (!modalRoot) {
     return null;
   }
@@ -34,7 +39,9 @@ export const Modal: React.FC<{
 
   return createPortal(
     <div
-      className={clsx("Modal", props.className)}
+      className={clsx("Modal", props.className, {
+        "animations-disabled": animationsDisabledRef.current,
+      })}
       role="dialog"
       aria-modal="true"
       onKeyDown={handleKeydown}

+ 5 - 3
packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx

@@ -42,13 +42,15 @@ const MenuContent = ({
       }
     };
 
-    document.addEventListener(EVENT.KEYDOWN, onKeyDown, {
+    const option = {
       // so that we can stop propagation of the event before it reaches
       // event handlers that were bound before this one
       capture: true,
-    });
+    };
+
+    document.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
     return () => {
-      document.removeEventListener(EVENT.KEYDOWN, onKeyDown);
+      document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
     };
   }, [callbacksRef]);
 

+ 3 - 3
packages/excalidraw/components/hyperlink/Hyperlink.tsx

@@ -1,4 +1,4 @@
-import { AppState, ExcalidrawProps, Point } from "../../types";
+import { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
 import {
   sceneCoordsToViewportCoords,
   viewportCoordsToSceneCoords,
@@ -332,10 +332,10 @@ const getCoordsForPopover = (
 
 export const getContextMenuLabel = (
   elements: readonly NonDeletedExcalidrawElement[],
-  appState: AppState,
+  appState: UIAppState,
 ) => {
   const selectedElements = getSelectedElements(elements, appState);
-  const label = selectedElements[0]!.link
+  const label = selectedElements[0]?.link
     ? isEmbeddableElement(selectedElements[0])
       ? "labels.link.editEmbed"
       : "labels.link.edit"

+ 238 - 6
packages/excalidraw/components/icons.tsx

@@ -85,7 +85,7 @@ export const PlusPromoIcon = createIcon(
 
 // tabler-icons: book
 export const LibraryIcon = createIcon(
-  <g strokeWidth="1.5">
+  <g strokeWidth="1.25">
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />
     <path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
     <path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
@@ -386,6 +386,16 @@ export const ZoomOutIcon = createIcon(
   modifiedTablerIconProps,
 );
 
+export const ZoomResetIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M21 21l-6 -6" />
+    <path d="M3.268 12.043a7.017 7.017 0 0 0 6.634 4.957a7.012 7.012 0 0 0 7.043 -6.131a7 7 0 0 0 -5.314 -7.672a7.021 7.021 0 0 0 -8.241 4.403" />
+    <path d="M3 4v4h4" />
+  </g>,
+  tablerIconProps,
+);
+
 export const TrashIcon = createIcon(
   <path
     strokeWidth="1.25"
@@ -462,6 +472,16 @@ export const HelpIcon = createIcon(
   tablerIconProps,
 );
 
+export const HelpIconThin = createIcon(
+  <g strokeWidth="1.25">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <circle cx="12" cy="12" r="9"></circle>
+    <line x1="12" y1="17" x2="12" y2="17.01"></line>
+    <path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
+  </g>,
+  tablerIconProps,
+);
+
 export const ExternalLinkIcon = createIcon(
   <path
     strokeWidth="1.25"
@@ -539,6 +559,16 @@ export const palette = createIcon(
   "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
 );
 
+export const bucketFillIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M5 16l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737z" />
+    <path d="M13.737 9.737c2.299 -2.3 3.23 -5.095 2.081 -6.245c-1.15 -1.15 -3.945 -.217 -6.244 2.082c-2.3 2.299 -3.231 5.095 -2.082 6.244c1.15 1.15 3.946 .218 6.245 -2.081z" />
+    <path d="M7.492 11.818c.362 .362 .768 .676 1.208 .934l6.895 4.047c1.078 .557 2.255 -.075 3.692 -1.512c1.437 -1.437 2.07 -2.614 1.512 -3.692c-.372 -.718 -1.72 -3.017 -4.047 -6.895a6.015 6.015 0 0 0 -.934 -1.208" />
+  </g>,
+  tablerIconProps,
+);
+
 export const ExportImageIcon = createIcon(
   <g strokeWidth="1.25">
     <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -613,6 +643,16 @@ export const shareIOS = createIcon(
   { width: 24, height: 24 },
 );
 
+export const exportToPlus = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1" />
+    <path d="M12 14v-11" />
+    <path d="M9 6l3 -3l3 3" />
+  </g>,
+  tablerIconProps,
+);
+
 export const shareWindows = createIcon(
   <>
     <path
@@ -934,11 +974,6 @@ export const CloseIcon = createIcon(
   modifiedTablerIconProps,
 );
 
-export const back = createIcon(
-  "M34.52 239.03L228.87 44.69c9.37-9.37 24.57-9.37 33.94 0l22.67 22.67c9.36 9.36 9.37 24.52.04 33.9L131.49 256l154.02 154.75c9.34 9.38 9.32 24.54-.04 33.9l-22.67 22.67c-9.37 9.37-24.57 9.37-33.94 0L34.52 272.97c-9.37-9.37-9.37-24.57 0-33.94z",
-  { width: 320, height: 512, style: { marginLeft: "-0.2rem" }, mirror: true },
-);
-
 export const clone = createIcon(
   "M464 0c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48H176c-26.51 0-48-21.49-48-48V48c0-26.51 21.49-48 48-48h288M176 416c-44.112 0-80-35.888-80-80V128H48c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48v-48H176z",
   { mirror: true },
@@ -1472,6 +1507,19 @@ export const FontSizeExtraLargeIcon = createIcon(
   modifiedTablerIconProps,
 );
 
+export const fontSizeIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M3 7v-2h13v2" />
+    <path d="M10 5v14" />
+    <path d="M12 19h-4" />
+    <path d="M15 13v-1h6v1" />
+    <path d="M18 12v7" />
+    <path d="M17 19h2" />
+  </g>,
+  tablerIconProps,
+);
+
 export const FontFamilyNormalIcon = createIcon(
   <>
     <g
@@ -1649,6 +1697,17 @@ export const copyIcon = createIcon(
   tablerIconProps,
 );
 
+export const cutIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
+    <path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
+    <path d="M9.15 14.85l8.85 -10.85" />
+    <path d="M6 4l8.85 10.85" />
+  </g>,
+  tablerIconProps,
+);
+
 export const helpIcon = createIcon(
   <>
     <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -1773,6 +1832,17 @@ export const MagicIcon = createIcon(
   tablerIconProps,
 );
 
+export const MagicIconThin = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" />
+    <path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
+    <path d="M15 6l3 3" />
+    <path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
+    <path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
+  </g>,
+  tablerIconProps,
+);
+
 export const OpenAIIcon = createIcon(
   <g stroke="currentColor" fill="none">
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -1829,6 +1899,19 @@ export const brainIcon = createIcon(
   tablerIconProps,
 );
 
+export const brainIconThin = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
+    <path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
+    <path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
+    <path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
+    <path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
+    <path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
+  </g>,
+  tablerIconProps,
+);
+
 export const searchIcon = createIcon(
   <g strokeWidth={1.5}>
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -1838,6 +1921,16 @@ export const searchIcon = createIcon(
   tablerIconProps,
 );
 
+export const clockIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M20.984 12.53a9 9 0 1 0 -7.552 8.355" />
+    <path d="M12 7v5l3 3" />
+    <path d="M19 16l-2 3h4l-2 3" />
+  </g>,
+  tablerIconProps,
+);
+
 export const microphoneIcon = createIcon(
   <g strokeWidth={1.5}>
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -1860,3 +1953,142 @@ export const microphoneMutedIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const boltIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" />
+  </g>,
+  tablerIconProps,
+);
+export const selectAllIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M8 8m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" />
+    <path d="M12 20v.01" />
+    <path d="M16 20v.01" />
+    <path d="M8 20v.01" />
+    <path d="M4 20v.01" />
+    <path d="M4 16v.01" />
+    <path d="M4 12v.01" />
+    <path d="M4 8v.01" />
+    <path d="M4 4v.01" />
+    <path d="M8 4v.01" />
+    <path d="M12 4v.01" />
+    <path d="M16 4v.01" />
+    <path d="M20 4v.01" />
+    <path d="M20 8v.01" />
+    <path d="M20 12v.01" />
+    <path d="M20 16v.01" />
+    <path d="M20 20v.01" />
+  </g>,
+  tablerIconProps,
+);
+
+export const abacusIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M5 3v18" />
+    <path d="M19 21v-18" />
+    <path d="M5 7h14" />
+    <path d="M5 15h14" />
+    <path d="M8 13v4" />
+    <path d="M11 13v4" />
+    <path d="M16 13v4" />
+    <path d="M14 5v4" />
+    <path d="M11 5v4" />
+    <path d="M8 5v4" />
+    <path d="M3 21h18" />
+  </g>,
+  tablerIconProps,
+);
+
+export const flipVertical = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M3 12l18 0" />
+    <path d="M7 16l10 0l-10 5l0 -5" />
+    <path d="M7 8l10 0l-10 -5l0 5" />
+  </g>,
+  tablerIconProps,
+);
+
+export const flipHorizontal = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M12 3l0 18" />
+    <path d="M16 7l0 10l5 0l-5 -10" />
+    <path d="M8 7l0 10l-5 0l5 -10" />
+  </g>,
+  tablerIconProps,
+);
+
+export const paintIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" />
+    <path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" />
+    <path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" />
+  </g>,
+  tablerIconProps,
+);
+
+export const zoomAreaIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0" />
+    <path d="M22 22l-3 -3" />
+    <path d="M6 18h-1a2 2 0 0 1 -2 -2v-1" />
+    <path d="M3 11v-1" />
+    <path d="M3 6v-1a2 2 0 0 1 2 -2h1" />
+    <path d="M10 3h1" />
+    <path d="M15 3h1a2 2 0 0 1 2 2v1" />
+  </g>,
+  tablerIconProps,
+);
+
+export const svgIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M14 3v4a1 1 0 0 0 1 1h4" />
+    <path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
+    <path d="M4 20.25c0 .414 .336 .75 .75 .75h1.25a1 1 0 0 0 1 -1v-1a1 1 0 0 0 -1 -1h-1a1 1 0 0 1 -1 -1v-1a1 1 0 0 1 1 -1h1.25a.75 .75 0 0 1 .75 .75" />
+    <path d="M10 15l2 6l2 -6" />
+    <path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
+  </g>,
+  tablerIconProps,
+);
+
+export const pngIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M14 3v4a1 1 0 0 0 1 1h4" />
+    <path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
+    <path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
+    <path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6" />
+    <path d="M11 21v-6l3 6v-6" />
+  </g>,
+  tablerIconProps,
+);
+
+export const magnetIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0" />
+    <path d="M4 8l5 0" />
+    <path d="M15 8l4 0" />
+  </g>,
+  tablerIconProps,
+);
+
+export const coffeeIcon = createIcon(
+  <g strokeWidth={1.25}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M3 14c.83 .642 2.077 1.017 3.5 1c1.423 .017 2.67 -.358 3.5 -1c.83 -.642 2.077 -1.017 3.5 -1c1.423 -.017 2.67 .358 3.5 1" />
+    <path d="M8 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
+    <path d="M12 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
+    <path d="M3 10h14v5a6 6 0 0 1 -6 6h-2a6 6 0 0 1 -6 -6v-5z" />
+    <path d="M16.746 16.726a3 3 0 1 0 .252 -5.555" />
+  </g>,
+  tablerIconProps,
+);

+ 21 - 2
packages/excalidraw/components/main-menu/DefaultItems.tsx

@@ -7,6 +7,7 @@ import {
   useAppProps,
 } from "../App";
 import {
+  boltIcon,
   ExportIcon,
   ExportImageIcon,
   HelpIcon,
@@ -27,8 +28,6 @@ import {
   actionShortcuts,
   actionToggleTheme,
 } from "../../actions";
-
-import "./DefaultItems.scss";
 import clsx from "clsx";
 import { useSetAtom } from "jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
@@ -37,6 +36,8 @@ import { useUIAppState } from "../../context/ui-appState";
 import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
 import Trans from "../Trans";
 
+import "./DefaultItems.scss";
+
 export const LoadScene = () => {
   const { t } = useI18n();
   const actionManager = useExcalidrawActionManager();
@@ -117,6 +118,24 @@ export const SaveAsImage = () => {
 };
 SaveAsImage.displayName = "SaveAsImage";
 
+export const CommandPalette = () => {
+  const setAppState = useExcalidrawSetAppState();
+  const { t } = useI18n();
+
+  return (
+    <DropdownMenuItem
+      icon={boltIcon}
+      data-testid="command-palette-button"
+      onSelect={() => setAppState({ openDialog: { name: "commandPalette" } })}
+      shortcut={getShortcutFromShortcutName("commandPalette")}
+      aria-label={t("commandPalette.title")}
+    >
+      {t("commandPalette.title")}
+    </DropdownMenuItem>
+  );
+};
+CommandPalette.displayName = "CommandPalette";
+
 export const Help = () => {
   const { t } = useI18n();
 

+ 93 - 0
packages/excalidraw/deburr.ts

@@ -0,0 +1,93 @@
+// taken from lodash (MIT)
+// https://github.com/lodash/lodash/blob/67389a8c78975d97505fa15aa79bec6397749807/lodash.js#L14180
+
+const rsComboMarksRange = "\\u0300-\\u036f";
+const reComboHalfMarksRange = "\\ufe20-\\ufe2f";
+const rsComboSymbolsRange = "\\u20d0-\\u20ff";
+const rsComboRange =
+  rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange;
+const rsCombo = `[${rsComboRange}]`;
+
+const reComboMark = RegExp(rsCombo, "g");
+
+const reLatin = /[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g;
+
+// NOTE below letter replacements are modified from lodash to always convert
+// to single-letter form by phonetic similarity to keep indexing identical.
+// Doing this is only useful for search highlighting, and only insofar
+// we use a library that can highlight the original source string using
+// the matching indices. As such, we'll likely need to write our own highlighter
+// anyway. Ultimately, we'll want to write our own matcher altogether
+// so we don't have to do any deburring, which will be the most correct
+// solution.
+//
+// prettier-ignore
+const deburredLetters = {
+    '\xc0': 'A',  '\xc1': 'A', '\xc2': 'A', '\xc3': 'A', '\xc4': 'A', '\xc5': 'A',
+    '\xe0': 'a',  '\xe1': 'a', '\xe2': 'a', '\xe3': 'a', '\xe4': 'a', '\xe5': 'a',
+    '\xc7': 'C',  '\xe7': 'c',
+    '\xd0': 'D',  '\xf0': 'd',
+    '\xc8': 'E',  '\xc9': 'E', '\xca': 'E', '\xcb': 'E',
+    '\xe8': 'e',  '\xe9': 'e', '\xea': 'e', '\xeb': 'e',
+    '\xcc': 'I',  '\xcd': 'I', '\xce': 'I', '\xcf': 'I',
+    '\xec': 'i',  '\xed': 'i', '\xee': 'i', '\xef': 'i',
+    '\xd1': 'N',  '\xf1': 'n',
+    '\xd2': 'O',  '\xd3': 'O', '\xd4': 'O', '\xd5': 'O', '\xd6': 'O', '\xd8': 'O',
+    '\xf2': 'o',  '\xf3': 'o', '\xf4': 'o', '\xf5': 'o', '\xf6': 'o', '\xf8': 'o',
+    '\xd9': 'U',  '\xda': 'U', '\xdb': 'U', '\xdc': 'U',
+    '\xf9': 'u',  '\xfa': 'u', '\xfb': 'u', '\xfc': 'u',
+    '\xdd': 'Y',  '\xfd': 'y', '\xff': 'y',
+    // normaly Ae/ae
+    '\xc6': 'E', '\xe6': 'e',
+    // normally Th/th
+    '\xde': 'T', '\xfe': 't',
+    // normally ss
+    '\xdf': 's',
+    '\u0100': 'A',  '\u0102': 'A', '\u0104': 'A',
+    '\u0101': 'a',  '\u0103': 'a', '\u0105': 'a',
+    '\u0106': 'C',  '\u0108': 'C', '\u010a': 'C', '\u010c': 'C',
+    '\u0107': 'c',  '\u0109': 'c', '\u010b': 'c', '\u010d': 'c',
+    '\u010e': 'D',  '\u0110': 'D', '\u010f': 'd', '\u0111': 'd',
+    '\u0112': 'E',  '\u0114': 'E', '\u0116': 'E', '\u0118': 'E', '\u011a': 'E',
+    '\u0113': 'e',  '\u0115': 'e', '\u0117': 'e', '\u0119': 'e', '\u011b': 'e',
+    '\u011c': 'G',  '\u011e': 'G', '\u0120': 'G', '\u0122': 'G',
+    '\u011d': 'g',  '\u011f': 'g', '\u0121': 'g', '\u0123': 'g',
+    '\u0124': 'H',  '\u0126': 'H', '\u0125': 'h', '\u0127': 'h',
+    '\u0128': 'I',  '\u012a': 'I', '\u012c': 'I', '\u012e': 'I', '\u0130': 'I',
+    '\u0129': 'i',  '\u012b': 'i', '\u012d': 'i', '\u012f': 'i', '\u0131': 'i',
+    '\u0134': 'J',  '\u0135': 'j',
+    '\u0136': 'K',  '\u0137': 'k', '\u0138': 'k',
+    '\u0139': 'L',  '\u013b': 'L', '\u013d': 'L', '\u013f': 'L', '\u0141': 'L',
+    '\u013a': 'l',  '\u013c': 'l', '\u013e': 'l', '\u0140': 'l', '\u0142': 'l',
+    '\u0143': 'N',  '\u0145': 'N', '\u0147': 'N', '\u014a': 'N',
+    '\u0144': 'n',  '\u0146': 'n', '\u0148': 'n', '\u014b': 'n',
+    '\u014c': 'O',  '\u014e': 'O', '\u0150': 'O',
+    '\u014d': 'o',  '\u014f': 'o', '\u0151': 'o',
+    '\u0154': 'R',  '\u0156': 'R', '\u0158': 'R',
+    '\u0155': 'r',  '\u0157': 'r', '\u0159': 'r',
+    '\u015a': 'S',  '\u015c': 'S', '\u015e': 'S', '\u0160': 'S',
+    '\u015b': 's',  '\u015d': 's', '\u015f': 's', '\u0161': 's',
+    '\u0162': 'T',  '\u0164': 'T', '\u0166': 'T',
+    '\u0163': 't',  '\u0165': 't', '\u0167': 't',
+    '\u0168': 'U',  '\u016a': 'U', '\u016c': 'U', '\u016e': 'U', '\u0170': 'U', '\u0172': 'U',
+    '\u0169': 'u',  '\u016b': 'u', '\u016d': 'u', '\u016f': 'u', '\u0171': 'u', '\u0173': 'u',
+    '\u0174': 'W',  '\u0175': 'w',
+    '\u0176': 'Y',  '\u0177': 'y', '\u0178': 'Y',
+    '\u0179': 'Z',  '\u017b': 'Z', '\u017d': 'Z',
+    '\u017a': 'z',  '\u017c': 'z', '\u017e': 'z',
+    // normally IJ/ij
+    '\u0132': 'I', '\u0133': 'i',
+    // normally OE/oe
+    '\u0152': 'E', '\u0153': 'e',
+    // normally "'n"
+    '\u0149': "n",
+    '\u017f': 's'
+  };
+
+export const deburr = (str: string) => {
+  return str
+    .replace(reLatin, (key: string) => {
+      return deburredLetters[key as keyof typeof deburredLetters] || key;
+    })
+    .replace(reComboMark, "");
+};

+ 2 - 0
packages/excalidraw/element/embeddable.ts

@@ -251,6 +251,8 @@ export const createPlaceholderEmbeddableLabel = (
 export const actionSetEmbeddableAsActiveTool = register({
   name: "setEmbeddableAsActiveTool",
   trackEvent: { category: "toolbar" },
+  target: "Tool",
+  label: "toolBar.embeddable",
   perform: (elements, appState, _, app) => {
     const nextActiveTool = updateActiveTool(appState, {
       type: "embeddable",

+ 18 - 0
packages/excalidraw/hooks/useStableCallback.ts

@@ -0,0 +1,18 @@
+import { useRef } from "react";
+
+/**
+ * Returns a stable function of the same type.
+ */
+export const useStableCallback = <T extends (...args: any[]) => any>(
+  userFn: T,
+) => {
+  const stableRef = useRef<{ userFn: T; stableFn?: T }>({ userFn });
+  stableRef.current.userFn = userFn;
+
+  if (!stableRef.current.stableFn) {
+    stableRef.current.stableFn = ((...args: any[]) =>
+      stableRef.current.userFn(...args)) as T;
+  }
+
+  return stableRef.current.stableFn as T;
+};

+ 1 - 0
packages/excalidraw/keys.ts

@@ -45,6 +45,7 @@ export const KEYS = {
   PERIOD: ".",
   COMMA: ",",
   SUBTRACT: "-",
+  SLASH: "/",
 
   A: "a",
   C: "c",

+ 24 - 2
packages/excalidraw/locales/en.json

@@ -21,7 +21,9 @@
     "copyStyles": "Copy styles",
     "pasteStyles": "Paste styles",
     "stroke": "Stroke",
+    "changeStroke": "Change stroke color",
     "background": "Background",
+    "changeBackground": "Change background color",
     "fill": "Fill",
     "strokeWidth": "Stroke width",
     "strokeStyle": "Stroke style",
@@ -72,6 +74,7 @@
     "canvasColors": "Used on canvas",
     "canvasBackground": "Canvas background",
     "drawingCanvas": "Drawing canvas",
+    "clearCanvas": "Clear canvas",
     "layers": "Layers",
     "actions": "Actions",
     "language": "Language",
@@ -90,6 +93,7 @@
     "libraryLoadingMessage": "Loading library…",
     "libraries": "Browse libraries",
     "loadingScene": "Loading scene…",
+    "loadScene": "Load scene from file",
     "align": "Align",
     "alignTop": "Align top",
     "alignBottom": "Align bottom",
@@ -105,7 +109,7 @@
     "share": "Share",
     "showStroke": "Show stroke color picker",
     "showBackground": "Show background color picker",
-    "toggleTheme": "Toggle theme",
+    "toggleTheme": "Toggle light/dark theme",
     "personalLib": "Personal Library",
     "excalidrawLib": "Excalidraw Library",
     "decreaseFontSize": "Decrease font size",
@@ -140,7 +144,10 @@
     "textToDiagram": "Text to diagram",
     "prompt": "Prompt",
     "followUs": "Follow us",
-    "discordChat": "Discord chat"
+    "discordChat": "Discord chat",
+    "zoomToFitViewport": "Zoom to fit in viewport",
+    "zoomToFitSelection": "Zoom to fit selection",
+    "zoomToFit": "Zoom to fit all elements"
   },
   "library": {
     "noItems": "No items added yet...",
@@ -539,5 +546,20 @@
       "micMuted": "User's microphone is muted",
       "isSpeaking": "User is speaking"
     }
+  },
+  "commandPalette": {
+    "title": "Command palette",
+    "shortcuts": {
+      "select": "Select",
+      "confirm": "Confirm",
+      "close": "Close"
+    },
+    "recents": "Recently used",
+    "search": {
+      "placeholder": "Search menus, commands, and discover hidden gems",
+      "noMatch": "No matching commands..."
+    },
+    "itemNotAvailable": "Command is not available...",
+    "shortcutHint": "For Command palette, use {{shortcutOne}} or {{shortcutTwo}}"
   }
 }

+ 3 - 2
packages/excalidraw/package.json

@@ -67,6 +67,7 @@
     "canvas-roundrect-polyfill": "0.0.1",
     "clsx": "1.1.1",
     "cross-env": "7.0.3",
+    "fuzzy": "0.1.3",
     "image-blob-reduce": "3.0.1",
     "jotai": "1.13.1",
     "lodash.throttle": "4.1.1",
@@ -94,6 +95,8 @@
     "@babel/preset-react": "7.18.6",
     "@babel/preset-typescript": "7.18.6",
     "@size-limit/preset-big-lib": "9.0.0",
+    "@testing-library/jest-dom": "5.16.2",
+    "@testing-library/react": "12.1.5",
     "@types/pako": "1.0.3",
     "@types/pica": "5.1.3",
     "@types/resize-observer-browser": "0.1.7",
@@ -116,8 +119,6 @@
     "sass-loader": "13.0.2",
     "size-limit": "9.0.0",
     "style-loader": "3.3.3",
-    "@testing-library/jest-dom": "5.16.2",
-    "@testing-library/react": "12.1.5",
     "ts-loader": "9.3.1",
     "typescript": "4.9.4"
   },

+ 1 - 14
packages/excalidraw/tests/MermaidToExcalidraw.test.tsx

@@ -1,4 +1,4 @@
-import { act, fireEvent, render, waitFor } from "./test-utils";
+import { act, render, waitFor } from "./test-utils";
 import { Excalidraw } from "../index";
 import React from "react";
 import { expect, vi } from "vitest";
@@ -115,19 +115,6 @@ describe("Test <MermaidToExcalidraw/>", () => {
     expect(dialog.outerHTML).toMatchSnapshot();
   });
 
-  it("should close the popup and set the tool to selection when close button clicked", () => {
-    const dialog = document.querySelector(".ttd-dialog")!;
-    const closeBtn = dialog.querySelector(".Dialog__close")!;
-    fireEvent.click(closeBtn);
-    expect(document.querySelector(".ttd-dialog")).toBe(null);
-    expect(window.h.state.activeTool).toStrictEqual({
-      customType: null,
-      lastActiveTool: null,
-      locked: false,
-      type: "selection",
-    });
-  });
-
   it("should show error in preview when mermaid library throws error", async () => {
     const dialog = document.querySelector(".ttd-dialog")!;
 

+ 1 - 1
packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap

@@ -1,7 +1,7 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
-"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><button class="Dialog__close" title="Close" aria-label="Close"><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g clip-path="url(#a)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 5 15M5 5l10 10"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h20v20H0z"></path></clipPath></defs></svg></button><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style="animation-duration: 0s;"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
+"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style="animation-duration: 0s;"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
  A[Christmas] --&gt;|Get money| B(Go shopping)
  B --&gt; C{Let me think}
  C --&gt;|One| D[Laptop]

Різницю між файлами не показано, бо вона завелика
+ 697 - 35
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap


+ 3 - 1
packages/excalidraw/types.ts

@@ -281,7 +281,8 @@ export interface AppState {
           | "settings"; // when AI settings dialog is explicitly invoked
         tab: "text-to-diagram" | "diagram-to-code";
       }
-    | { name: "ttd"; tab: "text-to-diagram" | "mermaid" };
+    | { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
+    | { name: "commandPalette" };
   /**
    * Reflects user preference for whether the default sidebar should be docked.
    *
@@ -580,6 +581,7 @@ export type AppClassProperties = {
   addFiles: App["addFiles"];
   addElementsFromPasteOrLibrary: App["addElementsFromPasteOrLibrary"];
   togglePenMode: App["togglePenMode"];
+  toggleLock: App["toggleLock"];
   setActiveTool: App["setActiveTool"];
   setOpenDialog: App["setOpenDialog"];
   insertEmbeddableElement: App["insertEmbeddableElement"];

+ 5 - 0
yarn.lock

@@ -6586,6 +6586,11 @@ functions-have-names@^1.2.2:
   resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
   integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
 
[email protected]:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/fuzzy/-/fuzzy-0.1.3.tgz#4c76ec2ff0ac1a36a9dccf9a00df8623078d4ed8"
+  integrity sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==
+
 gensync@^1.0.0-beta.2:
   version "1.0.0-beta.2"
   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"

Деякі файли не було показано, через те що забагато файлів було змінено