diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index f846c6fb1da3..57e7edaec08f 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -50,6 +50,7 @@ import { } from "../packages/excalidraw/utils"; import { FIREBASE_STORAGE_PREFIXES, + isExcalidrawPlusSignedUser, STORAGE_KEYS, SYNC_BROWSER_TABS_TIMEOUT, } from "./app_constants"; @@ -109,6 +110,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(); @@ -763,6 +777,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: t("labels.signin"), + 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(), + ); + } + }, + }, + ]} + />
); diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index e45a8e22364d..7cffd191a043 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -19,7 +19,7 @@ export const AppMainMenu: React.FC<{ onSelect={() => props.onCollabDialogOpen()} /> )} - + diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx index f796906d6434..3dbf12cebfd3 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 68096417b7f0..61df3a35f16d 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/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 00d6ad52767e..764a4c0e945b 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -23,6 +23,8 @@ Please add the latest change on the top under the correct section. - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) +- Add CommandPalette component for users to quickly find and discover commands to speed up their workflow. Apart from the core commands built into the component, custom command items can be passed as a prop. [#7804](https://github.com/excalidraw/excalidraw/pull/7804) + ### Fixes - Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656) diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index 1686554e4f47..ccb7fad62987 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 8d7d36217238..ddcb1415f318 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 daefa569155b..c5e07d12d685 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 ab5f8cfd7891..8c052a4a4ebf 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 967079e481a9..054e24e3c846 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -14,9 +14,12 @@ import { isTextElement } from "../element"; import { prepareElementsForExport } from "../data/index"; 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({ @@ -41,13 +44,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; @@ -98,24 +101,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) { @@ -157,11 +162,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) { @@ -221,12 +228,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({ @@ -262,5 +270,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 65f751d93a75..602d737250c2 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 be48bc87089b..f3075e5a3c53 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 86391f9e327f..014d1c65c463 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 51e2d15b37a9..170aa92078a9 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 { @@ -99,6 +101,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 { @@ -118,6 +121,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 { @@ -140,6 +144,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 ( @@ -193,6 +199,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) => { @@ -242,6 +250,7 @@ export const actionSaveFileToDisk = register({ export const actionLoadScene = register({ name: "loadScene", + label: "buttons.load", trackEvent: { category: "export" }, predicate: (elements, appState, props, app) => { return ( @@ -279,6 +288,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 9dad4ef9182f..a5f228f0f000 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 ee4a6f0f5422..be5e1a7aafe8 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 8232db3cd9cd..019533c59970 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 44e590bc26e4..a605f4f2784e 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 2e0f4c0916ee..147366e30def 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 5f1e672cbe44..5b76868f6794 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 f7710874e16c..21e3a4e1a282 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 fa8dcbea70e5..45a97eeba53e 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 5c60a029d2b6..c60185657141 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 8f2c350d68b4..562f04b35ad7 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 398416f0c2d2..2d682166f0fa 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 538375031cbd..8c0bc5370e5d 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 e4f930bff146..412da0119a6c 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 60986137b278..2f9a148c0b69 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 71ba6bef1662..74d0e0410ec3 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 dc9db0c37321..f3c5e4da64e2 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 28956640c2c7..fd397582a68b 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 17ecde1a6352..9f9a162f06b5 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 20ab9f7b447e..ef286829655a 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,7 @@ const shortcutMap: Record = { loadScene: [getShortcutKey("CtrlOrCmd+O")], clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")], imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], + commandPalette: [getShortcutKey("CtrlOrCmd+P")], cut: [getShortcutKey("CtrlOrCmd+X")], copy: [getShortcutKey("CtrlOrCmd+C")], paste: [getShortcutKey("CtrlOrCmd+V")], @@ -83,6 +97,18 @@ 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) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 118a5b2334b6..18503363f7d6 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 acff6aaa30c4..dd224e104ac1 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 586e69e37c47..9272fa33aeac 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4686,11 +4686,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; } } @@ -4863,7 +4858,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 000000000000..ebb7e4fa5ef1 --- /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 000000000000..4f895ef36185 --- /dev/null +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -0,0 +1,912 @@ +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, + MagicIconThin, + LibraryIcon, +} from "../icons"; +import fuzzy from "fuzzy"; +import { useUIAppState } from "../../context/ui-appState"; +import { AppProps, AppState } 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 "./CommandPalette.scss"; + +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; + category: string; + order?: number; + predicate?: boolean | Action["predicate"]; + shortcut?: string; + /** if false, command will not show while in view mode */ + viewMode?: boolean; + perform: ( + event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent, + ) => void; +}; + +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[KEYS.CTRL_OR_CMD] && + event.key === KEYS.P && + !event.altKey && + !event.shiftKey + ); +}; + +type CommandPaletteProps = { + customCommandPaletteItems?: CommandPaletteItem[]; +}; + +export const CommandPalette = (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 ; +}; + +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[] + >([]); + + 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.toggleTheme, + 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") { + document.body.classList.add("excalidraw-animations-disabled"); + closeCommandPalette(() => { + command.perform(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) { + 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(() => { + 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} + /> +
+ )} + + {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} + /> + ))} +
+ ); + }) + ) : ( +
+
{searchIcon}
{" "} + {t("commandPalette.search.noMatch")} +
+ )} +
+
+ ); +} + +const CommandItem = ({ + command, + isSelected, + disabled, + onMouseMove, + onClick, + showShortcut, +}: { + command: CommandPaletteItem; + isSelected: boolean; + disabled?: boolean; + onMouseMove: () => void; + onClick: (event: React.MouseEvent) => void; + showShortcut: boolean; +}) => { + 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/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx index ebabae83b23c..23959a990ba5 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 9dbc17ca1a63..622d304044c7 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 ae7a39282bc0..34e2fc963b19 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; @@ -115,14 +115,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 70f75cbbb8a1..d23c9d104961 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/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index 7d967232d426..75cc29d08d1f 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -1,4 +1,4 @@ -export const InlineIcon = ({ icon }: { icon: JSX.Element }) => { +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 c87ff773cc58..779305416fbf 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 60bf1b045385..6a638d304b1a 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -84,7 +84,7 @@ export const HomeIcon = createIcon( // tabler-icons: book export const LibraryIcon = createIcon( - + @@ -385,6 +385,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( @@ -612,6 +642,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( <> @@ -1772,6 +1831,17 @@ export const MagicIcon = createIcon( tablerIconProps, ); +export const MagicIconThin = createIcon( + + + + + + + , + tablerIconProps, +); + export const OpenAIIcon = createIcon( @@ -1828,6 +1898,19 @@ export const brainIcon = createIcon( tablerIconProps, ); +export const brainIconThin = createIcon( + + + + + + + + + , + tablerIconProps, +); + export const searchIcon = createIcon( @@ -1837,6 +1920,16 @@ export const searchIcon = createIcon( tablerIconProps, ); +export const clockIcon = createIcon( + + + + + + , + tablerIconProps, +); + export const microphoneIcon = createIcon( @@ -1859,3 +1952,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 21637eeace2f..b4cb2e076a83 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, @@ -118,6 +119,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 000000000000..ba95eddc81b0 --- /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 fb51c7283b4f..e17177040092 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 000000000000..9920a73f63e8 --- /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/index.tsx b/packages/excalidraw/index.tsx index df0161f48569..5ca74406a1ee 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -303,3 +303,4 @@ export * as actions from "./actions/index"; export { duplicateElements, duplicateElement } from "./element/newElement"; export { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; +export { CommandPalette } from "./components/CommandPalette/CommandPalette"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 6a149bced64f..b41ef3c4e58c 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,11 @@ "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", + "signin": "Sign in" }, "library": { "noItems": "No items added yet...", @@ -541,5 +549,19 @@ "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..." } } diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index ad8ffcf889b8..df4ccced57a7 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -49,6 +49,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", @@ -76,6 +77,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", @@ -98,8 +101,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 21946bab1222..1e782cfb2baa 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 ca0a342d2c95..3f9947de5f8a 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`] = ` -"