diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
index 7035996344..c335b960b8 100644
--- a/excalidraw-app/App.tsx
+++ b/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:
{ExcalLogo}
,
+ 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: {ExcalLogo}
,
+ 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 (
{
{errorMessage}
)}
+
+ {
+ 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,
+ ]}
+ />
);
diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx
index 6806c969cb..813d620c86 100644
--- a/excalidraw-app/components/AppMainMenu.tsx
+++ b/excalidraw-app/components/AppMainMenu.tsx
@@ -20,7 +20,7 @@ export const AppMainMenu: React.FC<{
onSelect={() => props.onCollabDialogOpen()}
/>
)}
-
+
diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx
index f796906d64..3dbf12cebf 100644
--- a/excalidraw-app/components/TopErrorBoundary.tsx
+++ b/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",
);
}
diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx
index 68096417b7..61df3a35f1 100644
--- a/excalidraw-app/share/ShareDialog.tsx
+++ b/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}
- >
+ />
);
};
diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts
index 1686554e4f..ccb7fad629 100644
--- a/packages/excalidraw/actions/actionAddToLibrary.ts
+++ b/packages/excalidraw/actions/actionAddToLibrary.ts
@@ -58,5 +58,5 @@ export const actionAddToLibrary = register({
};
});
},
- contextItemLabel: "labels.addToLibrary",
+ label: "labels.addToLibrary",
});
diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx
index 8d7d362172..ddcb1415f3 100644
--- a/packages/excalidraw/actions/actionAlign.tsx
+++ b/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) => {
diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx
index daefa56915..c5e07d12d6 100644
--- a/packages/excalidraw/actions/actionBoundText.tsx
+++ b/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);
diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx
index ab5f8cfd78..8c052a4a4e 100644
--- a/packages/excalidraw/actions/actionCanvas.tsx
+++ b/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"];
diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx
index dbc3c87516..bb488245c5 100644
--- a/packages/excalidraw/actions/actionClipboard.tsx
+++ b/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"],
});
diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx
index 65f751d93a..602d737250 100644
--- a/packages/excalidraw/actions/actionDeleteSelected.tsx
+++ b/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],
diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx
index be48bc8708..f3075e5a3c 100644
--- a/packages/excalidraw/actions/actionDistribute.tsx
+++ b/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 {
diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx
index 86391f9e32..014d1c65c4 100644
--- a/packages/excalidraw/actions/actionDuplicateSelection.tsx
+++ b/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 }) => (
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",
});
diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx
index 7cb6b1291c..eaa1d514fd 100644
--- a/packages/excalidraw/actions/actionExport.tsx
+++ b/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 {
diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx
index 9dad4ef918..a5f228f0f0 100644
--- a/packages/excalidraw/actions/actionFinalize.tsx
+++ b/packages/excalidraw/actions/actionFinalize.tsx
@@ -19,6 +19,7 @@ import { resetCursor } from "../cursor";
export const actionFinalize = register({
name: "finalize",
+ label: "",
trackEvent: false,
perform: (
elements,
diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts
index ee4a6f0f54..be5e1a7aaf 100644
--- a/packages/excalidraw/actions/actionFlip.ts
+++ b/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 = (
diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts
index 8232db3cd9..019533c599 100644
--- a/packages/excalidraw/actions/actionFrame.ts
+++ b/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",
diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx
index 44e590bc26..a605f4f278 100644
--- a/packages/excalidraw/actions/actionGroup.tsx
+++ b/packages/excalidraw/actions/actionGroup.tsx
@@ -61,6 +61,8 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
+ label: "labels.group",
+ icon: (appState) => ,
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) => ,
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 }) => (
diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx
index 2e0f4c0916..147366e30d 100644
--- a/packages/excalidraw/actions/actionHistory.tsx
+++ b/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) =>
diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts
index 5f1e672cbe..5b76868f67 100644
--- a/packages/excalidraw/actions/actionLinearEditor.ts
+++ b/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";
- },
});
diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx
index f7710874e1..21e3a4e1a2 100644
--- a/packages/excalidraw/actions/actionLink.tsx
+++ b/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;
diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx
index fa8dcbea70..45a97eeba5 100644
--- a/packages/excalidraw/actions/actionMenu.tsx
+++ b/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 }) => {
diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx
index 5c60a029d2..c601856571 100644
--- a/packages/excalidraw/actions/actionNavigate.tsx
+++ b/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) => {
diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx
index 8f2c350d68..562f04b35a 100644
--- a/packages/excalidraw/actions/actionProperties.tsx
+++ b/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,
diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts
index 398416f0c2..2d682166f0 100644
--- a/packages/excalidraw/actions/actionSelectAll.ts
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts
index 538375031c..8c0bc5370e 100644
--- a/packages/excalidraw/actions/actionStyles.ts
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx
index e4f930bff1..412da0119a 100644
--- a/packages/excalidraw/actions/actionToggleGridMode.tsx
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
index 60986137b2..2f9a148c0b 100644
--- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx
index 71ba6bef16..74d0e0410e 100644
--- a/packages/excalidraw/actions/actionToggleStats.tsx
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx
index dc9db0c373..f3c5e4da64 100644
--- a/packages/excalidraw/actions/actionToggleViewMode.tsx
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx
index 28956640c2..fd397582a6 100644
--- a/packages/excalidraw/actions/actionToggleZenMode.tsx
+++ b/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,
});
diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx
index 17ecde1a63..9f9a162f06 100644
--- a/packages/excalidraw/actions/actionZindex.tsx
+++ b/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] &&
diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts
index 20ab9f7b44..cbc25289f4 100644
--- a/packages/excalidraw/actions/shortcuts.ts
+++ b/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 = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -46,6 +59,10 @@ const shortcutMap: Record = {
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 = {
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]
+ : "";
};
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index 118a5b2334..18503363f7 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/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,
+ app: AppClassProperties,
+ ) => string);
+ keywords?: string[];
+ icon?:
+ | React.ReactNode
+ | ((
+ appState: UIAppState,
+ elements: readonly ExcalidrawElement[],
+ ) => React.ReactNode);
PanelComponent?: React.FC;
perform: ActionFn;
keyPriority?: number;
@@ -146,13 +167,6 @@ export interface Action {
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
- contextItemLabel?:
- | string
- | ((
- elements: readonly ExcalidrawElement[],
- appState: Readonly,
- app: AppClassProperties,
- ) => string);
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx
index acff6aaa30..dd224e104a 100644
--- a/packages/excalidraw/components/Actions.tsx
+++ b/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 (
- {((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")}
- {showChangeBackgroundIcons && (
+ {canChangeBackgroundColor(appState, targetElements) && (
{renderAction("changeBackgroundColor")}
)}
{showFillIcons && renderAction("changeFillStyle")}
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index b920a10375..00f60c23ff 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -413,6 +413,7 @@ import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
+import { getShortcutFromShortcutName } from "../actions/shortcuts";
const AppContext = React.createContext
(null!);
const AppPropsContext = React.createContext(null!);
@@ -3746,6 +3747,22 @@ class App extends React.Component {
});
}
+ 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 {
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 {
}
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;
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss
new file mode 100644
index 0000000000..ebb7e4fa5e
--- /dev/null
+++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss
@@ -0,0 +1,137 @@
+@import "../../css/variables.module.scss";
+
+$verticalBreakpoint: 861px;
+
+.excalidraw {
+ .command-palette-dialog {
+ user-select: none;
+
+ .Modal__content {
+ height: auto;
+ max-height: 100%;
+
+ @media screen and (min-width: $verticalBreakpoint) {
+ max-height: 750px;
+ height: 100%;
+ }
+
+ .Island {
+ height: 100%;
+ padding: 1.5rem;
+ }
+
+ .Dialog__content {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ }
+
+ .shortcuts-wrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 12px;
+ gap: 1.5rem;
+ }
+
+ .shortcut {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 16px;
+ font-size: 10px;
+ gap: 0.25rem;
+
+ .shortcut-wrapper {
+ display: flex;
+ }
+
+ .shortcut-plus {
+ margin: 0px 4px;
+ }
+
+ .shortcut-key {
+ padding: 0px 4px;
+ height: 16px;
+ border-radius: 4px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--color-primary-light);
+ }
+
+ .shortcut-desc {
+ margin-left: 4px;
+ color: var(--color-gray-50);
+ }
+ }
+
+ .commands {
+ overflow-y: auto;
+ box-sizing: border-box;
+ margin-top: 12px;
+ color: var(--popup-text-color);
+ user-select: none;
+
+ .command-category {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 0px;
+ margin-right: 0.25rem;
+ }
+
+ .command-category-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ }
+
+ .command-item {
+ color: var(--popup-text-color);
+ height: 2.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-sizing: border-box;
+ padding: 0 0.5rem;
+ border-radius: var(--border-radius-lg);
+ cursor: pointer;
+
+ &:active {
+ background-color: var(--color-surface-low);
+ }
+
+ .name {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+ }
+
+ .item-selected {
+ background-color: var(--color-surface-mid);
+ }
+
+ .item-disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+
+ .no-match {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 36px;
+ }
+ }
+
+ .icon {
+ width: 16px;
+ height: 16px;
+ margin-right: 6px;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx
new file mode 100644
index 0000000000..e8fdb1b96d
--- /dev/null
+++ b/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(null);
+
+export const DEFAULT_CATEGORIES = {
+ app: "App",
+ export: "Export",
+ tools: "Tools",
+ editor: "Editor",
+ elements: "Elements",
+ links: "Links",
+};
+
+const getCategoryOrder = (category: string) => {
+ switch (category) {
+ case DEFAULT_CATEGORIES.app:
+ return 1;
+ case DEFAULT_CATEGORIES.export:
+ return 2;
+ case DEFAULT_CATEGORIES.editor:
+ return 3;
+ case DEFAULT_CATEGORIES.tools:
+ return 4;
+ case DEFAULT_CATEGORIES.elements:
+ return 5;
+ case DEFAULT_CATEGORIES.links:
+ return 6;
+ default:
+ return 10;
+ }
+};
+
+const CommandShortcutHint = ({
+ shortcut,
+ className,
+ children,
+}: {
+ shortcut: string;
+ className?: string;
+ children?: React.ReactNode;
+}) => {
+ const shortcuts = shortcut.split(/(?
+ {shortcuts.map((item) => {
+ return (
+
+ );
+ })}
+ {children}
+
+ );
+};
+
+const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => {
+ return (
+ !event.altKey &&
+ event[KEYS.CTRL_OR_CMD] &&
+ ((event.shiftKey && event.key.toLowerCase() === KEYS.P) ||
+ event.key === KEYS.SLASH)
+ );
+};
+
+type CommandPaletteProps = {
+ customCommandPaletteItems?: CommandPaletteItem[];
+};
+
+export const CommandPalette = Object.assign(
+ (props: CommandPaletteProps) => {
+ const uiAppState = useUIAppState();
+ const setAppState = useExcalidrawSetAppState();
+
+ useEffect(() => {
+ const commandPaletteShortcut = (event: KeyboardEvent) => {
+ if (isCommandPaletteToggleShortcut(event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ setAppState((appState) => ({
+ openDialog:
+ appState.openDialog?.name === "commandPalette"
+ ? null
+ : { name: "commandPalette" },
+ }));
+ }
+ };
+ window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
+ capture: true,
+ });
+ return () =>
+ window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
+ capture: true,
+ });
+ }, [setAppState]);
+
+ if (uiAppState.openDialog?.name !== "commandPalette") {
+ return null;
+ }
+
+ return ;
+ },
+ {
+ defaultItems,
+ },
+);
+
+function CommandPaletteInner({
+ customCommandPaletteItems,
+}: CommandPaletteProps) {
+ const app = useApp();
+ const uiAppState = useUIAppState();
+ const setAppState = useExcalidrawSetAppState();
+ const appProps = useAppProps();
+ const actionManager = useExcalidrawActionManager();
+
+ const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem);
+ const [allCommands, setAllCommands] = useState<
+ MarkRequired[] | null
+ >(null);
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (!uiAppState || !app.scene || !actionManager) {
+ return;
+ }
+ const getActionLabel = (action: Action) => {
+ let label = "";
+ if (action.label) {
+ if (typeof action.label === "function") {
+ label = t(
+ action.label(
+ app.scene.getNonDeletedElements(),
+ uiAppState as AppState,
+ app,
+ ) as unknown as TranslationKeys,
+ );
+ } else {
+ label = t(action.label as unknown as TranslationKeys);
+ }
+ }
+ return label;
+ };
+
+ const getActionIcon = (action: Action) => {
+ if (typeof action.icon === "function") {
+ return action.icon(uiAppState, app.scene.getNonDeletedElements());
+ }
+ return action.icon;
+ };
+
+ let commandsFromActions: CommandPaletteItem[] = [];
+
+ const actionToCommand = (
+ action: Action,
+ category: string,
+ transformer?: (
+ command: CommandPaletteItem,
+ action: Action,
+ ) => CommandPaletteItem,
+ ): CommandPaletteItem => {
+ const command: CommandPaletteItem = {
+ label: getActionLabel(action),
+ icon: getActionIcon(action),
+ category,
+ shortcut: getShortcutFromShortcutName(action.name as ShortcutName),
+ keywords: action.keywords,
+ predicate: action.predicate,
+ viewMode: action.viewMode,
+ perform: () => {
+ actionManager.executeAction(action, "commandPalette");
+ },
+ };
+
+ return transformer ? transformer(command, action) : command;
+ };
+
+ if (uiAppState && app.scene && actionManager) {
+ const elementsCommands: CommandPaletteItem[] = [
+ actionManager.actions.group,
+ actionManager.actions.ungroup,
+ actionManager.actions.cut,
+ actionManager.actions.copy,
+ actionManager.actions.deleteSelectedElements,
+ actionManager.actions.copyStyles,
+ actionManager.actions.pasteStyles,
+ actionManager.actions.sendBackward,
+ actionManager.actions.sendToBack,
+ actionManager.actions.bringForward,
+ actionManager.actions.bringToFront,
+ actionManager.actions.alignTop,
+ actionManager.actions.alignBottom,
+ actionManager.actions.alignLeft,
+ actionManager.actions.alignRight,
+ actionManager.actions.alignVerticallyCentered,
+ actionManager.actions.alignHorizontallyCentered,
+ actionManager.actions.duplicateSelection,
+ actionManager.actions.flipHorizontal,
+ actionManager.actions.flipVertical,
+ actionManager.actions.zoomToFitSelection,
+ actionManager.actions.zoomToFitSelectionInViewport,
+ actionManager.actions.increaseFontSize,
+ actionManager.actions.decreaseFontSize,
+ actionManager.actions.toggleLinearEditor,
+ actionLink,
+ ].map((action: Action) =>
+ actionToCommand(
+ action,
+ DEFAULT_CATEGORIES.elements,
+ (command, action) => ({
+ ...command,
+ predicate: action.predicate
+ ? action.predicate
+ : (elements, appState, appProps, app) => {
+ const selectedElements = getSelectedElements(
+ elements,
+ appState,
+ );
+ return selectedElements.length > 0;
+ },
+ }),
+ ),
+ );
+ const toolCommands: CommandPaletteItem[] = [
+ actionManager.actions.toggleHandTool,
+ actionManager.actions.setFrameAsActiveTool,
+ ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
+
+ const editorCommands: CommandPaletteItem[] = [
+ actionManager.actions.undo,
+ actionManager.actions.redo,
+ actionManager.actions.zoomIn,
+ actionManager.actions.zoomOut,
+ actionManager.actions.resetZoom,
+ actionManager.actions.zoomToFit,
+ actionManager.actions.zenMode,
+ actionManager.actions.viewMode,
+ actionManager.actions.objectsSnapMode,
+ actionManager.actions.toggleShortcuts,
+ actionManager.actions.selectAll,
+ actionManager.actions.toggleElementLock,
+ actionManager.actions.unlockAllElements,
+ actionManager.actions.stats,
+ ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor));
+
+ const exportCommands: CommandPaletteItem[] = [
+ actionManager.actions.saveToActiveFile,
+ actionManager.actions.saveFileToDisk,
+ actionManager.actions.copyAsPng,
+ actionManager.actions.copyAsSvg,
+ ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export));
+
+ commandsFromActions = [
+ ...elementsCommands,
+ ...editorCommands,
+ {
+ label: getActionLabel(actionClearCanvas),
+ icon: getActionIcon(actionClearCanvas),
+ shortcut: getShortcutFromShortcutName(
+ actionClearCanvas.name as ShortcutName,
+ ),
+ category: DEFAULT_CATEGORIES.editor,
+ keywords: ["delete", "destroy"],
+ viewMode: false,
+ perform: () => {
+ jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
+ },
+ },
+ {
+ label: t("buttons.exportImage"),
+ category: DEFAULT_CATEGORIES.export,
+ icon: ExportImageIcon,
+ shortcut: getShortcutFromShortcutName("imageExport"),
+ keywords: [
+ "export",
+ "image",
+ "png",
+ "jpeg",
+ "svg",
+ "clipboard",
+ "picture",
+ ],
+ perform: () => {
+ setAppState({ openDialog: { name: "imageExport" } });
+ },
+ },
+ ...exportCommands,
+ ];
+
+ const additionalCommands: CommandPaletteItem[] = [
+ {
+ label: t("toolBar.library"),
+ category: DEFAULT_CATEGORIES.app,
+ icon: LibraryIcon,
+ viewMode: false,
+ perform: () => {
+ if (uiAppState.openSidebar) {
+ setAppState({
+ openSidebar: null,
+ });
+ } else {
+ setAppState({
+ openSidebar: {
+ name: DEFAULT_SIDEBAR.name,
+ tab: DEFAULT_SIDEBAR.defaultTab,
+ },
+ });
+ }
+ },
+ },
+ {
+ label: t("labels.changeStroke"),
+ keywords: ["color", "outline"],
+ category: DEFAULT_CATEGORIES.elements,
+ icon: bucketFillIcon,
+ viewMode: false,
+ predicate: (elements, appState) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return (
+ selectedElements.length > 0 &&
+ canChangeStrokeColor(appState, selectedElements)
+ );
+ },
+ perform: () => {
+ setAppState((prevState) => ({
+ openMenu: prevState.openMenu === "shape" ? null : "shape",
+ openPopup: "elementStroke",
+ }));
+ },
+ },
+ {
+ label: t("labels.changeBackground"),
+ keywords: ["color", "fill"],
+ icon: bucketFillIcon,
+ category: DEFAULT_CATEGORIES.elements,
+ viewMode: false,
+ predicate: (elements, appState) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ return (
+ selectedElements.length > 0 &&
+ canChangeBackgroundColor(appState, selectedElements)
+ );
+ },
+ perform: () => {
+ setAppState((prevState) => ({
+ openMenu: prevState.openMenu === "shape" ? null : "shape",
+ openPopup: "elementBackground",
+ }));
+ },
+ },
+ {
+ label: t("labels.canvasBackground"),
+ keywords: ["color"],
+ icon: bucketFillIcon,
+ category: DEFAULT_CATEGORIES.editor,
+ viewMode: false,
+ perform: () => {
+ setAppState((prevState) => ({
+ openMenu: prevState.openMenu === "canvas" ? null : "canvas",
+ openPopup: "canvasBackground",
+ }));
+ },
+ },
+ ...SHAPES.reduce((acc: CommandPaletteItem[], shape) => {
+ const { value, icon, key, numericKey } = shape;
+
+ if (
+ appProps.UIOptions.tools?.[
+ value as Extract<
+ typeof value,
+ keyof AppProps["UIOptions"]["tools"]
+ >
+ ] === false
+ ) {
+ return acc;
+ }
+
+ const letter =
+ key && capitalizeString(typeof key === "string" ? key : key[0]);
+ const shortcut = letter || numericKey;
+
+ const command: CommandPaletteItem = {
+ label: t(`toolBar.${value}`),
+ category: DEFAULT_CATEGORIES.tools,
+ shortcut,
+ icon,
+ keywords: ["toolbar"],
+ viewMode: false,
+ perform: ({ event }) => {
+ if (value === "image") {
+ app.setActiveTool({
+ type: value,
+ insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
+ });
+ } else {
+ app.setActiveTool({ type: value });
+ }
+ },
+ };
+
+ acc.push(command);
+
+ return acc;
+ }, []),
+ ...toolCommands,
+ {
+ label: t("toolBar.lock"),
+ category: DEFAULT_CATEGORIES.tools,
+ icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon,
+ shortcut: KEYS.Q.toLocaleUpperCase(),
+ viewMode: false,
+ perform: () => {
+ app.toggleLock();
+ },
+ },
+ {
+ label: `${t("labels.textToDiagram")}...`,
+ category: DEFAULT_CATEGORIES.tools,
+ icon: brainIconThin,
+ viewMode: false,
+ predicate: appProps.aiEnabled,
+ perform: () => {
+ setAppState((state) => ({
+ ...state,
+ openDialog: {
+ name: "ttd",
+ tab: "text-to-diagram",
+ },
+ }));
+ },
+ },
+ {
+ label: `${t("toolBar.mermaidToExcalidraw")}...`,
+ category: DEFAULT_CATEGORIES.tools,
+ icon: mermaidLogoIcon,
+ viewMode: false,
+ predicate: appProps.aiEnabled,
+ perform: () => {
+ setAppState((state) => ({
+ ...state,
+ openDialog: {
+ name: "ttd",
+ tab: "mermaid",
+ },
+ }));
+ },
+ },
+ // {
+ // label: `${t("toolBar.magicframe")}...`,
+ // category: DEFAULT_CATEGORIES.tools,
+ // icon: MagicIconThin,
+ // viewMode: false,
+ // predicate: appProps.aiEnabled,
+ // perform: () => {
+ // app.onMagicframeToolSelect();
+ // },
+ // },
+ ];
+
+ const allCommands = [
+ ...commandsFromActions,
+ ...additionalCommands,
+ ...(customCommandPaletteItems || []),
+ ].map((command) => {
+ return {
+ ...command,
+ icon: command.icon || boltIcon,
+ order: command.order ?? getCategoryOrder(command.category),
+ haystack: `${deburr(command.label)} ${
+ command.keywords?.join(" ") || ""
+ }`,
+ };
+ });
+
+ setAllCommands(allCommands);
+ setLastUsed(
+ allCommands.find((command) => command.label === lastUsed?.label) ??
+ null,
+ );
+ }
+ }, [
+ app,
+ appProps,
+ uiAppState,
+ actionManager,
+ setAllCommands,
+ lastUsed?.label,
+ setLastUsed,
+ setAppState,
+ customCommandPaletteItems,
+ ]);
+
+ const [commandSearch, setCommandSearch] = useState("");
+ const [currentCommand, setCurrentCommand] =
+ useState(null);
+ const [commandsByCategory, setCommandsByCategory] = useState<
+ Record
+ >({});
+
+ const closeCommandPalette = (cb?: () => void) => {
+ setAppState(
+ {
+ openDialog: null,
+ },
+ cb,
+ );
+ setCommandSearch("");
+ };
+
+ const executeCommand = (
+ command: CommandPaletteItem,
+ event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent,
+ ) => {
+ if (uiAppState.openDialog?.name === "commandPalette") {
+ event.stopPropagation();
+ event.preventDefault();
+ document.body.classList.add("excalidraw-animations-disabled");
+ closeCommandPalette(() => {
+ command.perform({ actionManager, event });
+ setLastUsed(command);
+
+ requestAnimationFrame(() => {
+ document.body.classList.remove("excalidraw-animations-disabled");
+ });
+ });
+ }
+ };
+
+ const isCommandAvailable = useStableCallback(
+ (command: CommandPaletteItem) => {
+ if (command.viewMode === false && uiAppState.viewModeEnabled) {
+ return false;
+ }
+
+ return typeof command.predicate === "function"
+ ? command.predicate(
+ app.scene.getNonDeletedElements(),
+ uiAppState as AppState,
+ appProps,
+ app,
+ )
+ : command.predicate === undefined || command.predicate;
+ },
+ );
+
+ const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
+ const ignoreAlphanumerics =
+ isWritableElement(event.target) ||
+ isCommandPaletteToggleShortcut(event) ||
+ event.key === KEYS.ESCAPE;
+
+ if (
+ ignoreAlphanumerics &&
+ event.key !== KEYS.ARROW_UP &&
+ event.key !== KEYS.ARROW_DOWN &&
+ event.key !== KEYS.ENTER
+ ) {
+ return;
+ }
+
+ const matchingCommands = Object.values(commandsByCategory).flat();
+ const shouldConsiderLastUsed =
+ lastUsed && !commandSearch && isCommandAvailable(lastUsed);
+
+ if (event.key === KEYS.ARROW_UP) {
+ event.preventDefault();
+ const index = matchingCommands.findIndex(
+ (item) => item.label === currentCommand?.label,
+ );
+
+ if (shouldConsiderLastUsed) {
+ if (index === 0) {
+ setCurrentCommand(lastUsed);
+ return;
+ }
+
+ if (currentCommand === lastUsed) {
+ const nextItem = matchingCommands[matchingCommands.length - 1];
+ if (nextItem) {
+ setCurrentCommand(nextItem);
+ }
+ return;
+ }
+ }
+
+ let nextIndex;
+
+ if (index === -1) {
+ nextIndex = matchingCommands.length - 1;
+ } else {
+ nextIndex =
+ index === 0
+ ? matchingCommands.length - 1
+ : (index - 1) % matchingCommands.length;
+ }
+
+ const nextItem = matchingCommands[nextIndex];
+ if (nextItem) {
+ setCurrentCommand(nextItem);
+ }
+
+ return;
+ }
+
+ if (event.key === KEYS.ARROW_DOWN) {
+ event.preventDefault();
+ const index = matchingCommands.findIndex(
+ (item) => item.label === currentCommand?.label,
+ );
+
+ if (shouldConsiderLastUsed) {
+ if (!currentCommand || index === matchingCommands.length - 1) {
+ setCurrentCommand(lastUsed);
+ return;
+ }
+
+ if (currentCommand === lastUsed) {
+ const nextItem = matchingCommands[0];
+ if (nextItem) {
+ setCurrentCommand(nextItem);
+ }
+ return;
+ }
+ }
+
+ const nextIndex = (index + 1) % matchingCommands.length;
+ const nextItem = matchingCommands[nextIndex];
+ if (nextItem) {
+ setCurrentCommand(nextItem);
+ }
+
+ return;
+ }
+
+ if (event.key === KEYS.ENTER) {
+ if (currentCommand) {
+ setTimeout(() => {
+ executeCommand(currentCommand, event);
+ });
+ }
+ }
+
+ if (ignoreAlphanumerics) {
+ return;
+ }
+
+ // prevent regular editor shortcuts
+ event.stopPropagation();
+
+ // if alphanumeric keypress and we're not inside the input, focus it
+ if (/^[a-zA-Z0-9]$/.test(event.key)) {
+ inputRef?.current?.focus();
+ return;
+ }
+
+ event.preventDefault();
+ });
+
+ useEffect(() => {
+ window.addEventListener(EVENT.KEYDOWN, handleKeyDown, {
+ capture: true,
+ });
+ return () =>
+ window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, {
+ capture: true,
+ });
+ }, [handleKeyDown]);
+
+ useEffect(() => {
+ if (!allCommands) {
+ return;
+ }
+
+ const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => {
+ const nextCommandsByCategory: Record = {};
+ for (const command of commands) {
+ if (nextCommandsByCategory[command.category]) {
+ nextCommandsByCategory[command.category].push(command);
+ } else {
+ nextCommandsByCategory[command.category] = [command];
+ }
+ }
+
+ return nextCommandsByCategory;
+ };
+
+ let matchingCommands = allCommands
+ .filter(isCommandAvailable)
+ .sort((a, b) => a.order - b.order);
+
+ const showLastUsed =
+ !commandSearch && lastUsed && isCommandAvailable(lastUsed);
+
+ if (!commandSearch) {
+ setCommandsByCategory(
+ getNextCommandsByCategory(
+ showLastUsed
+ ? matchingCommands.filter(
+ (command) => command.label !== lastUsed?.label,
+ )
+ : matchingCommands,
+ ),
+ );
+ setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null);
+ return;
+ }
+
+ const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
+ matchingCommands = fuzzy
+ .filter(_query, matchingCommands, {
+ extract: (command) => command.haystack,
+ })
+ .sort((a, b) => b.score - a.score)
+ .map((item) => item.original);
+
+ setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
+ setCurrentCommand(matchingCommands[0] ?? null);
+ }, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
+
+ return (
+
+ );
+}
+
+const CommandItem = ({
+ command,
+ isSelected,
+ disabled,
+ onMouseMove,
+ onClick,
+ showShortcut,
+ appState,
+}: {
+ command: CommandPaletteItem;
+ isSelected: boolean;
+ disabled?: boolean;
+ onMouseMove: () => void;
+ onClick: (event: React.MouseEvent) => void;
+ showShortcut: boolean;
+ appState: UIAppState;
+}) => {
+ const noop = () => {};
+
+ return (
+ {
+ if (isSelected && !disabled) {
+ ref?.scrollIntoView?.({
+ block: "nearest",
+ });
+ }
+ }}
+ onClick={disabled ? noop : onClick}
+ onMouseMove={disabled ? noop : onMouseMove}
+ title={disabled ? t("commandPalette.itemNotAvailable") : ""}
+ >
+
+ {command.icon && (
+
+ )}
+ {command.label}
+
+ {showShortcut && command.shortcut && (
+
+ )}
+
+ );
+};
diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts
new file mode 100644
index 0000000000..831a585ae0
--- /dev/null
+++ b/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");
+ },
+};
diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts
new file mode 100644
index 0000000000..59e306d2d6
--- /dev/null
+++ b/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;
+};
diff --git a/packages/excalidraw/components/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx
index ebabae83b2..23959a990b 100644
--- a/packages/excalidraw/components/ContextMenu.tsx
+++ b/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);
}
}
diff --git a/packages/excalidraw/components/Dialog.scss b/packages/excalidraw/components/Dialog.scss
index 9dbc17ca1a..622d304044 100644
--- a/packages/excalidraw/components/Dialog.scss
+++ b/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 {
diff --git a/packages/excalidraw/components/Dialog.tsx b/packages/excalidraw/components/Dialog.tsx
index ae7a39282b..667e681c6d 100644
--- a/packages/excalidraw/components/Dialog.tsx
+++ b/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) => {
{props.title}
)}
-
+ {isFullscreen && (
+
+ )}
{props.children}
diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss
index 70f75cbbb8..d23c9d1049 100644
--- a/packages/excalidraw/components/FilledButton.scss
+++ b/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;
diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx
index 961158c0c9..85f4fbaa6c 100644
--- a/packages/excalidraw/components/HelpDialog.tsx
+++ b/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 = () => (
@@ -278,6 +279,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
+
{
+export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
return (
{
- document.removeEventListener(EVENT.KEYDOWN, onKeyDown);
+ document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
};
}, [callbacksRef]);
diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx
index c87ff773cc..779305416f 100644
--- a/packages/excalidraw/components/hyperlink/Hyperlink.tsx
+++ b/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"
diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx
index 063253f69d..a81ba62815 100644
--- a/packages/excalidraw/components/icons.tsx
+++ b/packages/excalidraw/components/icons.tsx
@@ -85,7 +85,7 @@ export const PlusPromoIcon = createIcon(
// tabler-icons: book
export const LibraryIcon = createIcon(
-
+
@@ -386,6 +386,16 @@ export const ZoomOutIcon = createIcon(
modifiedTablerIconProps,
);
+export const ZoomResetIcon = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const TrashIcon = createIcon(
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const ExternalLinkIcon = createIcon(
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const ExportImageIcon = createIcon(
@@ -613,6 +643,16 @@ export const shareIOS = createIcon(
{ width: 24, height: 24 },
);
+export const exportToPlus = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const shareWindows = createIcon(
<>
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const FontFamilyNormalIcon = createIcon(
<>
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const helpIcon = createIcon(
<>
@@ -1773,6 +1832,17 @@ export const MagicIcon = createIcon(
tablerIconProps,
);
+export const MagicIconThin = createIcon(
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const OpenAIIcon = createIcon(
@@ -1829,6 +1899,19 @@ export const brainIcon = createIcon(
tablerIconProps,
);
+export const brainIconThin = createIcon(
+
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const searchIcon = createIcon(
@@ -1838,6 +1921,16 @@ export const searchIcon = createIcon(
tablerIconProps,
);
+export const clockIcon = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
export const microphoneIcon = createIcon(
@@ -1860,3 +1953,142 @@ export const microphoneMutedIcon = createIcon(
,
tablerIconProps,
);
+
+export const boltIcon = createIcon(
+
+
+
+ ,
+ tablerIconProps,
+);
+export const selectAllIcon = createIcon(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const abacusIcon = createIcon(
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const flipVertical = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const flipHorizontal = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const paintIcon = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const zoomAreaIcon = createIcon(
+
+
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const svgIcon = createIcon(
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const pngIcon = createIcon(
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const magnetIcon = createIcon(
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
+
+export const coffeeIcon = createIcon(
+
+
+
+
+
+
+
+ ,
+ tablerIconProps,
+);
diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx
index cc74e4c74f..7cb0fe16ef 100644
--- a/packages/excalidraw/components/main-menu/DefaultItems.tsx
+++ b/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 (
+ setAppState({ openDialog: { name: "commandPalette" } })}
+ shortcut={getShortcutFromShortcutName("commandPalette")}
+ aria-label={t("commandPalette.title")}
+ >
+ {t("commandPalette.title")}
+
+ );
+};
+CommandPalette.displayName = "CommandPalette";
+
export const Help = () => {
const { t } = useI18n();
diff --git a/packages/excalidraw/deburr.ts b/packages/excalidraw/deburr.ts
new file mode 100644
index 0000000000..ba95eddc81
--- /dev/null
+++ b/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, "");
+};
diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts
index fb51c7283b..e171770400 100644
--- a/packages/excalidraw/element/embeddable.ts
+++ b/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",
diff --git a/packages/excalidraw/hooks/useStableCallback.ts b/packages/excalidraw/hooks/useStableCallback.ts
new file mode 100644
index 0000000000..9920a73f63
--- /dev/null
+++ b/packages/excalidraw/hooks/useStableCallback.ts
@@ -0,0 +1,18 @@
+import { useRef } from "react";
+
+/**
+ * Returns a stable function of the same type.
+ */
+export const useStableCallback = 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;
+};
diff --git a/packages/excalidraw/keys.ts b/packages/excalidraw/keys.ts
index f7bf54db5e..755ce3a848 100644
--- a/packages/excalidraw/keys.ts
+++ b/packages/excalidraw/keys.ts
@@ -45,6 +45,7 @@ export const KEYS = {
PERIOD: ".",
COMMA: ",",
SUBTRACT: "-",
+ SLASH: "/",
A: "a",
C: "c",
diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json
index 1213bc3188..42ebb0b9f4 100644
--- a/packages/excalidraw/locales/en.json
+++ b/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}}"
}
}
diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json
index 0b12d46fa6..dd35931811 100644
--- a/packages/excalidraw/package.json
+++ b/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"
},
diff --git a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx b/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx
index 21946bab12..1e782cfb2b 100644
--- a/packages/excalidraw/tests/MermaidToExcalidraw.test.tsx
+++ b/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 ", () => {
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")!;
diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
index 0e25dc33d5..1850f074f0 100644
--- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
+++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `
-"Mermaid to Excalidraw
Currently only
Flowchart,
Sequence, and
Class Diagrams are supported. The other types will be rendered as image in Excalidraw.