From 550a388b2ba435211e3e91764beaf2ed421686d6 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 29 Mar 2024 00:16:32 +0800 Subject: [PATCH] feat: command palette (#7804) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 207 ++ excalidraw-app/components/AppMainMenu.tsx | 2 +- .../components/TopErrorBoundary.tsx | 2 + excalidraw-app/share/ShareDialog.tsx | 13 +- .../excalidraw/actions/actionAddToLibrary.ts | 2 +- packages/excalidraw/actions/actionAlign.tsx | 16 +- .../excalidraw/actions/actionBoundText.tsx | 6 +- packages/excalidraw/actions/actionCanvas.tsx | 38 +- .../excalidraw/actions/actionClipboard.tsx | 20 +- .../actions/actionDeleteSelected.tsx | 3 +- .../excalidraw/actions/actionDistribute.tsx | 2 + .../actions/actionDuplicateSelection.tsx | 3 +- .../excalidraw/actions/actionElementLock.ts | 53 +- packages/excalidraw/actions/actionExport.tsx | 12 +- .../excalidraw/actions/actionFinalize.tsx | 1 + packages/excalidraw/actions/actionFlip.ts | 7 +- packages/excalidraw/actions/actionFrame.ts | 17 +- packages/excalidraw/actions/actionGroup.tsx | 6 +- packages/excalidraw/actions/actionHistory.tsx | 6 + .../excalidraw/actions/actionLinearEditor.ts | 20 +- packages/excalidraw/actions/actionLink.tsx | 4 +- packages/excalidraw/actions/actionMenu.tsx | 6 +- .../excalidraw/actions/actionNavigate.tsx | 1 + .../excalidraw/actions/actionProperties.tsx | 18 + .../excalidraw/actions/actionSelectAll.ts | 5 +- packages/excalidraw/actions/actionStyles.ts | 7 +- .../actions/actionToggleGridMode.tsx | 2 +- .../actions/actionToggleObjectsSnapMode.tsx | 6 +- .../excalidraw/actions/actionToggleStats.tsx | 5 +- .../actions/actionToggleViewMode.tsx | 5 +- .../actions/actionToggleZenMode.tsx | 5 +- packages/excalidraw/actions/actionZindex.tsx | 12 +- packages/excalidraw/actions/shortcuts.ts | 37 +- packages/excalidraw/actions/types.ts | 32 +- packages/excalidraw/components/Actions.tsx | 57 +- packages/excalidraw/components/App.tsx | 28 +- .../CommandPalette/CommandPalette.scss | 137 + .../CommandPalette/CommandPalette.tsx | 915 +++++ .../defaultCommandPaletteItems.ts | 11 + .../components/CommandPalette/types.ts | 26 + .../excalidraw/components/ContextMenu.tsx | 8 +- packages/excalidraw/components/Dialog.scss | 6 + packages/excalidraw/components/Dialog.tsx | 32 +- .../excalidraw/components/FilledButton.scss | 6 +- packages/excalidraw/components/HelpDialog.tsx | 8 + packages/excalidraw/components/InlineIcon.tsx | 2 +- packages/excalidraw/components/Modal.scss | 21 +- packages/excalidraw/components/Modal.tsx | 9 +- .../dropdownMenu/DropdownMenuContent.tsx | 8 +- .../components/hyperlink/Hyperlink.tsx | 6 +- packages/excalidraw/components/icons.tsx | 244 +- .../components/main-menu/DefaultItems.tsx | 23 +- packages/excalidraw/deburr.ts | 93 + packages/excalidraw/element/embeddable.ts | 2 + .../excalidraw/hooks/useStableCallback.ts | 18 + packages/excalidraw/keys.ts | 1 + packages/excalidraw/locales/en.json | 26 +- packages/excalidraw/package.json | 5 +- .../tests/MermaidToExcalidraw.test.tsx | 15 +- .../MermaidToExcalidraw.test.tsx.snap | 2 +- .../__snapshots__/contextmenu.test.tsx.snap | 3244 ++++++++++++++++- packages/excalidraw/types.ts | 4 +- yarn.lock | 5 + 63 files changed, 5226 insertions(+), 317 deletions(-) create mode 100644 packages/excalidraw/components/CommandPalette/CommandPalette.scss create mode 100644 packages/excalidraw/components/CommandPalette/CommandPalette.tsx create mode 100644 packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts create mode 100644 packages/excalidraw/components/CommandPalette/types.ts create mode 100644 packages/excalidraw/deburr.ts create mode 100644 packages/excalidraw/hooks/useStableCallback.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 703599634..c335b960b 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 6806c969c..813d620c8 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 f796906d6..3dbf12ceb 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 68096417b..61df3a35f 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 1686554e4..ccb7fad62 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 8d7d36217..ddcb1415f 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 daefa5691..c5e07d12d 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 ab5f8cfd7..8c052a4a4 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 dbc3c8751..bb488245c 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 65f751d93..602d73725 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 be48bc870..f3075e5a3 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 86391f9e3..014d1c65c 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 7cb6b1291..eaa1d514f 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 9dad4ef91..a5f228f0f 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 ee4a6f0f5..be5e1a7aa 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 8232db3cd..019533c59 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 44e590bc2..a605f4f27 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 2e0f4c091..147366e30 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 5f1e672cb..5b76868f6 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 f7710874e..21e3a4e1a 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 fa8dcbea7..45a97eeba 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 5c60a029d..c60185657 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 8f2c350d6..562f04b35 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 398416f0c..2d682166f 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 538375031..8c0bc5370 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 e4f930bff..412da0119 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 60986137b..2f9a148c0 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 71ba6bef1..74d0e0410 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 dc9db0c37..f3c5e4da6 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 28956640c..fd397582a 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 17ecde1a6..9f9a162f0 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 20ab9f7b4..cbc25289f 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 118a5b233..18503363f 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 acff6aaa3..dd224e104 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 b920a1037..00f60c23f 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 000000000..ebb7e4fa5 --- /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 000000000..e8fdb1b96 --- /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 ( +
+
{item}
+
+ ); + })} +
{children}
+
+ ); +}; + +const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => { + return ( + !event.altKey && + event[KEYS.CTRL_OR_CMD] && + ((event.shiftKey && event.key.toLowerCase() === KEYS.P) || + event.key === KEYS.SLASH) + ); +}; + +type CommandPaletteProps = { + customCommandPaletteItems?: CommandPaletteItem[]; +}; + +export const CommandPalette = Object.assign( + (props: CommandPaletteProps) => { + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + + useEffect(() => { + const commandPaletteShortcut = (event: KeyboardEvent) => { + if (isCommandPaletteToggleShortcut(event)) { + event.preventDefault(); + event.stopPropagation(); + setAppState((appState) => ({ + openDialog: + appState.openDialog?.name === "commandPalette" + ? null + : { name: "commandPalette" }, + })); + } + }; + window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, { + capture: true, + }); + }, [setAppState]); + + if (uiAppState.openDialog?.name !== "commandPalette") { + return null; + } + + return ; + }, + { + defaultItems, + }, +); + +function CommandPaletteInner({ + customCommandPaletteItems, +}: CommandPaletteProps) { + const app = useApp(); + const uiAppState = useUIAppState(); + const setAppState = useExcalidrawSetAppState(); + const appProps = useAppProps(); + const actionManager = useExcalidrawActionManager(); + + const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem); + const [allCommands, setAllCommands] = useState< + MarkRequired[] | null + >(null); + + const inputRef = useRef(null); + + useEffect(() => { + if (!uiAppState || !app.scene || !actionManager) { + return; + } + const getActionLabel = (action: Action) => { + let label = ""; + if (action.label) { + if (typeof action.label === "function") { + label = t( + action.label( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + app, + ) as unknown as TranslationKeys, + ); + } else { + label = t(action.label as unknown as TranslationKeys); + } + } + return label; + }; + + const getActionIcon = (action: Action) => { + if (typeof action.icon === "function") { + return action.icon(uiAppState, app.scene.getNonDeletedElements()); + } + return action.icon; + }; + + let commandsFromActions: CommandPaletteItem[] = []; + + const actionToCommand = ( + action: Action, + category: string, + transformer?: ( + command: CommandPaletteItem, + action: Action, + ) => CommandPaletteItem, + ): CommandPaletteItem => { + const command: CommandPaletteItem = { + label: getActionLabel(action), + icon: getActionIcon(action), + category, + shortcut: getShortcutFromShortcutName(action.name as ShortcutName), + keywords: action.keywords, + predicate: action.predicate, + viewMode: action.viewMode, + perform: () => { + actionManager.executeAction(action, "commandPalette"); + }, + }; + + return transformer ? transformer(command, action) : command; + }; + + if (uiAppState && app.scene && actionManager) { + const elementsCommands: CommandPaletteItem[] = [ + actionManager.actions.group, + actionManager.actions.ungroup, + actionManager.actions.cut, + actionManager.actions.copy, + actionManager.actions.deleteSelectedElements, + actionManager.actions.copyStyles, + actionManager.actions.pasteStyles, + actionManager.actions.sendBackward, + actionManager.actions.sendToBack, + actionManager.actions.bringForward, + actionManager.actions.bringToFront, + actionManager.actions.alignTop, + actionManager.actions.alignBottom, + actionManager.actions.alignLeft, + actionManager.actions.alignRight, + actionManager.actions.alignVerticallyCentered, + actionManager.actions.alignHorizontallyCentered, + actionManager.actions.duplicateSelection, + actionManager.actions.flipHorizontal, + actionManager.actions.flipVertical, + actionManager.actions.zoomToFitSelection, + actionManager.actions.zoomToFitSelectionInViewport, + actionManager.actions.increaseFontSize, + actionManager.actions.decreaseFontSize, + actionManager.actions.toggleLinearEditor, + actionLink, + ].map((action: Action) => + actionToCommand( + action, + DEFAULT_CATEGORIES.elements, + (command, action) => ({ + ...command, + predicate: action.predicate + ? action.predicate + : (elements, appState, appProps, app) => { + const selectedElements = getSelectedElements( + elements, + appState, + ); + return selectedElements.length > 0; + }, + }), + ), + ); + const toolCommands: CommandPaletteItem[] = [ + actionManager.actions.toggleHandTool, + actionManager.actions.setFrameAsActiveTool, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools)); + + const editorCommands: CommandPaletteItem[] = [ + actionManager.actions.undo, + actionManager.actions.redo, + actionManager.actions.zoomIn, + actionManager.actions.zoomOut, + actionManager.actions.resetZoom, + actionManager.actions.zoomToFit, + actionManager.actions.zenMode, + actionManager.actions.viewMode, + actionManager.actions.objectsSnapMode, + actionManager.actions.toggleShortcuts, + actionManager.actions.selectAll, + actionManager.actions.toggleElementLock, + actionManager.actions.unlockAllElements, + actionManager.actions.stats, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor)); + + const exportCommands: CommandPaletteItem[] = [ + actionManager.actions.saveToActiveFile, + actionManager.actions.saveFileToDisk, + actionManager.actions.copyAsPng, + actionManager.actions.copyAsSvg, + ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export)); + + commandsFromActions = [ + ...elementsCommands, + ...editorCommands, + { + label: getActionLabel(actionClearCanvas), + icon: getActionIcon(actionClearCanvas), + shortcut: getShortcutFromShortcutName( + actionClearCanvas.name as ShortcutName, + ), + category: DEFAULT_CATEGORIES.editor, + keywords: ["delete", "destroy"], + viewMode: false, + perform: () => { + jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); + }, + }, + { + label: t("buttons.exportImage"), + category: DEFAULT_CATEGORIES.export, + icon: ExportImageIcon, + shortcut: getShortcutFromShortcutName("imageExport"), + keywords: [ + "export", + "image", + "png", + "jpeg", + "svg", + "clipboard", + "picture", + ], + perform: () => { + setAppState({ openDialog: { name: "imageExport" } }); + }, + }, + ...exportCommands, + ]; + + const additionalCommands: CommandPaletteItem[] = [ + { + label: t("toolBar.library"), + category: DEFAULT_CATEGORIES.app, + icon: LibraryIcon, + viewMode: false, + perform: () => { + if (uiAppState.openSidebar) { + setAppState({ + openSidebar: null, + }); + } else { + setAppState({ + openSidebar: { + name: DEFAULT_SIDEBAR.name, + tab: DEFAULT_SIDEBAR.defaultTab, + }, + }); + } + }, + }, + { + label: t("labels.changeStroke"), + keywords: ["color", "outline"], + category: DEFAULT_CATEGORIES.elements, + icon: bucketFillIcon, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeStrokeColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementStroke", + })); + }, + }, + { + label: t("labels.changeBackground"), + keywords: ["color", "fill"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.elements, + viewMode: false, + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return ( + selectedElements.length > 0 && + canChangeBackgroundColor(appState, selectedElements) + ); + }, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "shape" ? null : "shape", + openPopup: "elementBackground", + })); + }, + }, + { + label: t("labels.canvasBackground"), + keywords: ["color"], + icon: bucketFillIcon, + category: DEFAULT_CATEGORIES.editor, + viewMode: false, + perform: () => { + setAppState((prevState) => ({ + openMenu: prevState.openMenu === "canvas" ? null : "canvas", + openPopup: "canvasBackground", + })); + }, + }, + ...SHAPES.reduce((acc: CommandPaletteItem[], shape) => { + const { value, icon, key, numericKey } = shape; + + if ( + appProps.UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return acc; + } + + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter || numericKey; + + const command: CommandPaletteItem = { + label: t(`toolBar.${value}`), + category: DEFAULT_CATEGORIES.tools, + shortcut, + icon, + keywords: ["toolbar"], + viewMode: false, + perform: ({ event }) => { + if (value === "image") { + app.setActiveTool({ + type: value, + insertOnCanvasDirectly: event.type === EVENT.KEYDOWN, + }); + } else { + app.setActiveTool({ type: value }); + } + }, + }; + + acc.push(command); + + return acc; + }, []), + ...toolCommands, + { + label: t("toolBar.lock"), + category: DEFAULT_CATEGORIES.tools, + icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon, + shortcut: KEYS.Q.toLocaleUpperCase(), + viewMode: false, + perform: () => { + app.toggleLock(); + }, + }, + { + label: `${t("labels.textToDiagram")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: brainIconThin, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "text-to-diagram", + }, + })); + }, + }, + { + label: `${t("toolBar.mermaidToExcalidraw")}...`, + category: DEFAULT_CATEGORIES.tools, + icon: mermaidLogoIcon, + viewMode: false, + predicate: appProps.aiEnabled, + perform: () => { + setAppState((state) => ({ + ...state, + openDialog: { + name: "ttd", + tab: "mermaid", + }, + })); + }, + }, + // { + // label: `${t("toolBar.magicframe")}...`, + // category: DEFAULT_CATEGORIES.tools, + // icon: MagicIconThin, + // viewMode: false, + // predicate: appProps.aiEnabled, + // perform: () => { + // app.onMagicframeToolSelect(); + // }, + // }, + ]; + + const allCommands = [ + ...commandsFromActions, + ...additionalCommands, + ...(customCommandPaletteItems || []), + ].map((command) => { + return { + ...command, + icon: command.icon || boltIcon, + order: command.order ?? getCategoryOrder(command.category), + haystack: `${deburr(command.label)} ${ + command.keywords?.join(" ") || "" + }`, + }; + }); + + setAllCommands(allCommands); + setLastUsed( + allCommands.find((command) => command.label === lastUsed?.label) ?? + null, + ); + } + }, [ + app, + appProps, + uiAppState, + actionManager, + setAllCommands, + lastUsed?.label, + setLastUsed, + setAppState, + customCommandPaletteItems, + ]); + + const [commandSearch, setCommandSearch] = useState(""); + const [currentCommand, setCurrentCommand] = + useState(null); + const [commandsByCategory, setCommandsByCategory] = useState< + Record + >({}); + + const closeCommandPalette = (cb?: () => void) => { + setAppState( + { + openDialog: null, + }, + cb, + ); + setCommandSearch(""); + }; + + const executeCommand = ( + command: CommandPaletteItem, + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent, + ) => { + if (uiAppState.openDialog?.name === "commandPalette") { + event.stopPropagation(); + event.preventDefault(); + document.body.classList.add("excalidraw-animations-disabled"); + closeCommandPalette(() => { + command.perform({ actionManager, event }); + setLastUsed(command); + + requestAnimationFrame(() => { + document.body.classList.remove("excalidraw-animations-disabled"); + }); + }); + } + }; + + const isCommandAvailable = useStableCallback( + (command: CommandPaletteItem) => { + if (command.viewMode === false && uiAppState.viewModeEnabled) { + return false; + } + + return typeof command.predicate === "function" + ? command.predicate( + app.scene.getNonDeletedElements(), + uiAppState as AppState, + appProps, + app, + ) + : command.predicate === undefined || command.predicate; + }, + ); + + const handleKeyDown = useStableCallback((event: KeyboardEvent) => { + const ignoreAlphanumerics = + isWritableElement(event.target) || + isCommandPaletteToggleShortcut(event) || + event.key === KEYS.ESCAPE; + + if ( + ignoreAlphanumerics && + event.key !== KEYS.ARROW_UP && + event.key !== KEYS.ARROW_DOWN && + event.key !== KEYS.ENTER + ) { + return; + } + + const matchingCommands = Object.values(commandsByCategory).flat(); + const shouldConsiderLastUsed = + lastUsed && !commandSearch && isCommandAvailable(lastUsed); + + if (event.key === KEYS.ARROW_UP) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (index === 0) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[matchingCommands.length - 1]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + let nextIndex; + + if (index === -1) { + nextIndex = matchingCommands.length - 1; + } else { + nextIndex = + index === 0 + ? matchingCommands.length - 1 + : (index - 1) % matchingCommands.length; + } + + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ARROW_DOWN) { + event.preventDefault(); + const index = matchingCommands.findIndex( + (item) => item.label === currentCommand?.label, + ); + + if (shouldConsiderLastUsed) { + if (!currentCommand || index === matchingCommands.length - 1) { + setCurrentCommand(lastUsed); + return; + } + + if (currentCommand === lastUsed) { + const nextItem = matchingCommands[0]; + if (nextItem) { + setCurrentCommand(nextItem); + } + return; + } + } + + const nextIndex = (index + 1) % matchingCommands.length; + const nextItem = matchingCommands[nextIndex]; + if (nextItem) { + setCurrentCommand(nextItem); + } + + return; + } + + if (event.key === KEYS.ENTER) { + if (currentCommand) { + setTimeout(() => { + executeCommand(currentCommand, event); + }); + } + } + + if (ignoreAlphanumerics) { + return; + } + + // prevent regular editor shortcuts + event.stopPropagation(); + + // if alphanumeric keypress and we're not inside the input, focus it + if (/^[a-zA-Z0-9]$/.test(event.key)) { + inputRef?.current?.focus(); + return; + } + + event.preventDefault(); + }); + + useEffect(() => { + window.addEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + return () => + window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, { + capture: true, + }); + }, [handleKeyDown]); + + useEffect(() => { + if (!allCommands) { + return; + } + + const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => { + const nextCommandsByCategory: Record = {}; + for (const command of commands) { + if (nextCommandsByCategory[command.category]) { + nextCommandsByCategory[command.category].push(command); + } else { + nextCommandsByCategory[command.category] = [command]; + } + } + + return nextCommandsByCategory; + }; + + let matchingCommands = allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); + + const showLastUsed = + !commandSearch && lastUsed && isCommandAvailable(lastUsed); + + if (!commandSearch) { + setCommandsByCategory( + getNextCommandsByCategory( + showLastUsed + ? matchingCommands.filter( + (command) => command.label !== lastUsed?.label, + ) + : matchingCommands, + ), + ); + setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null); + return; + } + + const _query = deburr(commandSearch.replace(/[<>-_| ]/g, "")); + matchingCommands = fuzzy + .filter(_query, matchingCommands, { + extract: (command) => command.haystack, + }) + .sort((a, b) => b.score - a.score) + .map((item) => item.original); + + setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); + setCurrentCommand(matchingCommands[0] ?? null); + }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + + return ( + closeCommandPalette()} + closeOnClickOutside + title={false} + size={720} + autofocus + className="command-palette-dialog" + > + { + setCommandSearch(value); + }} + selectOnRender + ref={inputRef} + /> + + {!app.device.viewport.isMobile && ( +
+ + {t("commandPalette.shortcuts.select")} + + + {t("commandPalette.shortcuts.confirm")} + + + {t("commandPalette.shortcuts.close")} + +
+ )} + +
+ {lastUsed && !commandSearch && ( +
+
+ {t("commandPalette.recents")} +
+ {clockIcon} +
+
+ executeCommand(lastUsed, event)} + disabled={!isCommandAvailable(lastUsed)} + onMouseMove={() => setCurrentCommand(lastUsed)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> +
+ )} + + {Object.keys(commandsByCategory).length > 0 ? ( + Object.keys(commandsByCategory).map((category, idx) => { + return ( +
+
{category}
+ {commandsByCategory[category].map((command) => ( + executeCommand(command, event)} + onMouseMove={() => setCurrentCommand(command)} + showShortcut={!app.device.viewport.isMobile} + appState={uiAppState} + /> + ))} +
+ ); + }) + ) : allCommands ? ( +
+
{searchIcon}
{" "} + {t("commandPalette.search.noMatch")} +
+ ) : null} +
+
+ ); +} + +const CommandItem = ({ + command, + isSelected, + disabled, + onMouseMove, + onClick, + showShortcut, + appState, +}: { + command: CommandPaletteItem; + isSelected: boolean; + disabled?: boolean; + onMouseMove: () => void; + onClick: (event: React.MouseEvent) => void; + showShortcut: boolean; + appState: UIAppState; +}) => { + const noop = () => {}; + + return ( +
{ + if (isSelected && !disabled) { + ref?.scrollIntoView?.({ + block: "nearest", + }); + } + }} + onClick={disabled ? noop : onClick} + onMouseMove={disabled ? noop : onMouseMove} + title={disabled ? t("commandPalette.itemNotAvailable") : ""} + > +
+ {command.icon && ( + + )} + {command.label} +
+ {showShortcut && command.shortcut && ( + + )} +
+ ); +}; diff --git a/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts b/packages/excalidraw/components/CommandPalette/defaultCommandPaletteItems.ts new file mode 100644 index 000000000..831a585ae --- /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 000000000..59e306d2d --- /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 ebabae83b..23959a990 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 9dbc17ca1..622d30404 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 ae7a39282..667e681c6 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 70f75cbbb..d23c9d104 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 961158c0c..85f4fbaa6 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 c87ff773c..779305416 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 063253f69..a81ba6281 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 cc74e4c74..7cb0fe16e 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 000000000..ba95eddc8 --- /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 fb51c7283..e17177040 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 000000000..9920a73f6 --- /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 f7bf54db5..755ce3a84 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 1213bc318..42ebb0b9f 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 0b12d46fa..dd3593181 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 21946bab1..1e782cfb2 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 0e25dc33d..1850f074f 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`] = ` -"