Browse Source

feat: Custom actions and shortcuts

Daniel J. Geiger 2 years ago
parent
commit
8e5d376b49

+ 25 - 0
src/actions/guards.ts

@@ -0,0 +1,25 @@
+import { Action, ActionName, DisableFn, EnableFn } from "./types";
+
+const disablers = {} as Record<ActionName, DisableFn[]>;
+const enablers = {} as Record<Action["name"], EnableFn[]>;
+
+export const getActionDisablers = () => disablers;
+export const getActionEnablers = () => enablers;
+
+export const registerDisableFn = (name: ActionName, disabler: DisableFn) => {
+  if (!(name in disablers)) {
+    disablers[name] = [] as DisableFn[];
+  }
+  if (!disablers[name].includes(disabler)) {
+    disablers[name].push(disabler);
+  }
+};
+
+export const registerEnableFn = (name: Action["name"], enabler: EnableFn) => {
+  if (!(name in enablers)) {
+    enablers[name] = [] as EnableFn[];
+  }
+  if (!enablers[name].includes(enabler)) {
+    enablers[name].push(enabler);
+  }
+};

+ 87 - 4
src/actions/manager.tsx

@@ -6,7 +6,11 @@ import {
   ActionResult,
   ActionResult,
   PanelComponentProps,
   PanelComponentProps,
   ActionSource,
   ActionSource,
+  DisableFn,
+  EnableFn,
+  isActionName,
 } from "./types";
 } from "./types";
+import { getActionDisablers, getActionEnablers } from "./guards";
 import { ExcalidrawElement } from "../element/types";
 import { ExcalidrawElement } from "../element/types";
 import { AppClassProperties, AppState } from "../types";
 import { AppClassProperties, AppState } from "../types";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
@@ -40,7 +44,10 @@ const trackAction = (
 };
 };
 
 
 export class ActionManager {
 export class ActionManager {
-  actions = {} as Record<ActionName, Action>;
+  actions = {} as Record<ActionName | Action["name"], Action>;
+
+  disablers = {} as Record<ActionName, DisableFn[]>;
+  enablers = {} as Record<Action["name"], EnableFn[]>;
 
 
   updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
   updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 
 
@@ -68,6 +75,73 @@ export class ActionManager {
     this.app = app;
     this.app = app;
   }
   }
 
 
+  public registerActionGuards() {
+    const disablers = getActionDisablers();
+    for (const d in disablers) {
+      const dName = d as ActionName;
+      disablers[dName].forEach((disabler) =>
+        this.registerDisableFn(dName, disabler),
+      );
+    }
+    const enablers = getActionEnablers();
+    for (const e in enablers) {
+      const eName = e as Action["name"];
+      enablers[e].forEach((enabler) => this.registerEnableFn(eName, enabler));
+    }
+  }
+
+  public registerDisableFn(name: ActionName, disabler: DisableFn) {
+    if (!(name in this.disablers)) {
+      this.disablers[name] = [] as DisableFn[];
+    }
+    if (!this.disablers[name].includes(disabler)) {
+      this.disablers[name].push(disabler);
+    }
+  }
+
+  public registerEnableFn(name: Action["name"], enabler: EnableFn) {
+    if (!(name in this.enablers)) {
+      this.enablers[name] = [] as EnableFn[];
+    }
+    if (!this.enablers[name].includes(enabler)) {
+      this.enablers[name].push(enabler);
+    }
+  }
+
+  public getCustomActions(): Action[] {
+    // For testing
+    if (this === undefined) {
+      return [];
+    }
+    const customActions: Action[] = [];
+    for (const key in this.actions) {
+      const action = this.actions[key];
+      if (!isActionName(action.name)) {
+        customActions.push(action);
+      }
+    }
+    return customActions;
+  }
+
+  public isActionEnabled(
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+    actionName: Action["name"],
+  ): boolean {
+    if (isActionName(actionName)) {
+      return !(
+        actionName in this.disablers &&
+        this.disablers[actionName].some((fn) =>
+          fn(elements, appState, actionName),
+        )
+      );
+    }
+    return (
+      actionName in this.enablers &&
+      this.enablers[actionName].some((fn) => fn(elements, appState, actionName))
+    );
+  }
+
   registerAction(action: Action) {
   registerAction(action: Action) {
     this.actions[action.name] = action;
     this.actions[action.name] = action;
   }
   }
@@ -84,7 +158,11 @@ export class ActionManager {
         (action) =>
         (action) =>
           (action.name in canvasActions
           (action.name in canvasActions
             ? canvasActions[action.name as keyof typeof canvasActions]
             ? canvasActions[action.name as keyof typeof canvasActions]
-            : true) &&
+            : this.isActionEnabled(
+                this.getElementsIncludingDeleted(),
+                this.getAppState(),
+                action.name,
+              )) &&
           action.keyTest &&
           action.keyTest &&
           action.keyTest(
           action.keyTest(
             event,
             event,
@@ -132,7 +210,7 @@ export class ActionManager {
    * @param data additional data sent to the PanelComponent
    * @param data additional data sent to the PanelComponent
    */
    */
   renderAction = (
   renderAction = (
-    name: ActionName,
+    name: ActionName | Action["name"],
     data?: PanelComponentProps["data"],
     data?: PanelComponentProps["data"],
     isInHamburgerMenu = false,
     isInHamburgerMenu = false,
   ) => {
   ) => {
@@ -143,7 +221,11 @@ export class ActionManager {
       "PanelComponent" in this.actions[name] &&
       "PanelComponent" in this.actions[name] &&
       (name in canvasActions
       (name in canvasActions
         ? canvasActions[name as keyof typeof canvasActions]
         ? canvasActions[name as keyof typeof canvasActions]
-        : true)
+        : this.isActionEnabled(
+            this.getElementsIncludingDeleted(),
+            this.getAppState(),
+            name,
+          ))
     ) {
     ) {
       const action = this.actions[name];
       const action = this.actions[name];
       const PanelComponent = action.PanelComponent!;
       const PanelComponent = action.PanelComponent!;
@@ -165,6 +247,7 @@ export class ActionManager {
 
 
       return (
       return (
         <PanelComponent
         <PanelComponent
+          key={name}
           elements={this.getElementsIncludingDeleted()}
           elements={this.getElementsIncludingDeleted()}
           appState={this.getAppState()}
           appState={this.getAppState()}
           updateData={updateData}
           updateData={updateData}

+ 8 - 2
src/actions/register.ts

@@ -1,8 +1,14 @@
-import { Action } from "./types";
+import { Action, isActionName } from "./types";
 
 
-export let actions: readonly Action[] = [];
+let actions: readonly Action[] = [];
+let customActions: readonly Action[] = [];
+export const getCustomActions = () => customActions;
+export const getActions = () => actions;
 
 
 export const register = <T extends Action>(action: T) => {
 export const register = <T extends Action>(action: T) => {
+  if (!isActionName(action.name)) {
+    customActions = customActions.concat(action);
+  }
   actions = actions.concat(action);
   actions = actions.concat(action);
   return action as T & {
   return action as T & {
     keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
     keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];

+ 17 - 2
src/actions/shortcuts.ts

@@ -80,8 +80,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
   toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
 };
 };
 
 
-export const getShortcutFromShortcutName = (name: ShortcutName) => {
-  const shortcuts = shortcutMap[name];
+export type CustomShortcutName = string;
+
+let customShortcutMap: Record<CustomShortcutName, string[]> = {};
+
+export const registerCustomShortcuts = (
+  shortcuts: Record<CustomShortcutName, string[]>,
+) => {
+  customShortcutMap = { ...customShortcutMap, ...shortcuts };
+};
+
+export const getShortcutFromShortcutName = (
+  name: ShortcutName | CustomShortcutName,
+) => {
+  const shortcuts =
+    name in customShortcutMap
+      ? customShortcutMap[name as CustomShortcutName]
+      : shortcutMap[name as ShortcutName];
   // if multiple shortcuts available, take the first one
   // if multiple shortcuts available, take the first one
   return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
   return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
 };
 };

+ 114 - 80
src/actions/types.ts

@@ -31,88 +31,110 @@ type ActionFn = (
   app: AppClassProperties,
   app: AppClassProperties,
 ) => ActionResult | Promise<ActionResult>;
 ) => ActionResult | Promise<ActionResult>;
 
 
+// Return `true` to indicate the standard Action with name `actionName`
+// should be disabled given `elements` and `appState`.
+export type DisableFn = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  actionName: ActionName,
+) => boolean;
+
+// Return `true` to indicate the custom Action with name `actionName`
+// should be enabled given `elements` and `appState`.
+export type EnableFn = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+  actionName: Action["name"],
+) => boolean;
+
 export type UpdaterFn = (res: ActionResult) => void;
 export type UpdaterFn = (res: ActionResult) => void;
 export type ActionFilterFn = (action: Action) => void;
 export type ActionFilterFn = (action: Action) => void;
 
 
-export type ActionName =
-  | "copy"
-  | "cut"
-  | "paste"
-  | "copyAsPng"
-  | "copyAsSvg"
-  | "copyText"
-  | "sendBackward"
-  | "bringForward"
-  | "sendToBack"
-  | "bringToFront"
-  | "copyStyles"
-  | "selectAll"
-  | "pasteStyles"
-  | "gridMode"
-  | "zenMode"
-  | "stats"
-  | "changeStrokeColor"
-  | "changeBackgroundColor"
-  | "changeFillStyle"
-  | "changeStrokeWidth"
-  | "changeStrokeShape"
-  | "changeSloppiness"
-  | "changeStrokeStyle"
-  | "changeArrowhead"
-  | "changeOpacity"
-  | "changeFontSize"
-  | "toggleCanvasMenu"
-  | "toggleEditMenu"
-  | "undo"
-  | "redo"
-  | "finalize"
-  | "changeProjectName"
-  | "changeExportBackground"
-  | "changeExportEmbedScene"
-  | "changeExportScale"
-  | "saveToActiveFile"
-  | "saveFileToDisk"
-  | "loadScene"
-  | "duplicateSelection"
-  | "deleteSelectedElements"
-  | "changeViewBackgroundColor"
-  | "clearCanvas"
-  | "zoomIn"
-  | "zoomOut"
-  | "resetZoom"
-  | "zoomToFit"
-  | "zoomToSelection"
-  | "changeFontFamily"
-  | "changeTextAlign"
-  | "changeVerticalAlign"
-  | "toggleFullScreen"
-  | "toggleShortcuts"
-  | "group"
-  | "ungroup"
-  | "goToCollaborator"
-  | "addToLibrary"
-  | "changeRoundness"
-  | "alignTop"
-  | "alignBottom"
-  | "alignLeft"
-  | "alignRight"
-  | "alignVerticallyCentered"
-  | "alignHorizontallyCentered"
-  | "distributeHorizontally"
-  | "distributeVertically"
-  | "flipHorizontal"
-  | "flipVertical"
-  | "viewMode"
-  | "exportWithDarkMode"
-  | "toggleTheme"
-  | "increaseFontSize"
-  | "decreaseFontSize"
-  | "unbindText"
-  | "hyperlink"
-  | "eraser"
-  | "bindText"
-  | "toggleLock"
-  | "toggleLinearEditor";
+const actionNames = [
+  "copy",
+  "cut",
+  "paste",
+  "copyAsPng",
+  "copyAsSvg",
+  "copyText",
+  "sendBackward",
+  "bringForward",
+  "sendToBack",
+  "bringToFront",
+  "copyStyles",
+  "selectAll",
+  "pasteStyles",
+  "gridMode",
+  "zenMode",
+  "stats",
+  "changeStrokeColor",
+  "changeBackgroundColor",
+  "changeFillStyle",
+  "changeStrokeWidth",
+  "changeStrokeShape",
+  "changeSloppiness",
+  "changeStrokeStyle",
+  "changeArrowhead",
+  "changeOpacity",
+  "changeFontSize",
+  "toggleCanvasMenu",
+  "toggleEditMenu",
+  "undo",
+  "redo",
+  "finalize",
+  "changeProjectName",
+  "changeExportBackground",
+  "changeExportEmbedScene",
+  "changeExportScale",
+  "saveToActiveFile",
+  "saveFileToDisk",
+  "loadScene",
+  "duplicateSelection",
+  "deleteSelectedElements",
+  "changeViewBackgroundColor",
+  "clearCanvas",
+  "zoomIn",
+  "zoomOut",
+  "resetZoom",
+  "zoomToFit",
+  "zoomToSelection",
+  "changeFontFamily",
+  "changeTextAlign",
+  "changeVerticalAlign",
+  "toggleFullScreen",
+  "toggleShortcuts",
+  "group",
+  "ungroup",
+  "goToCollaborator",
+  "addToLibrary",
+  "changeRoundness",
+  "alignTop",
+  "alignBottom",
+  "alignLeft",
+  "alignRight",
+  "alignVerticallyCentered",
+  "alignHorizontallyCentered",
+  "distributeHorizontally",
+  "distributeVertically",
+  "flipHorizontal",
+  "flipVertical",
+  "viewMode",
+  "exportWithDarkMode",
+  "toggleTheme",
+  "increaseFontSize",
+  "decreaseFontSize",
+  "unbindText",
+  "hyperlink",
+  "eraser",
+  "bindText",
+  "toggleLock",
+  "toggleLinearEditor",
+] as const;
+
+// So we can have the `isActionName` type guard
+export type ActionName = typeof actionNames[number];
+export const isActionName = (n: any): n is ActionName =>
+  actionNames.includes(n);
 
 
 export type PanelComponentProps = {
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
@@ -123,10 +145,14 @@ export type PanelComponentProps = {
 };
 };
 
 
 export interface Action {
 export interface Action {
-  name: ActionName;
+  name: string;
   PanelComponent?: React.FC<
   PanelComponent?: React.FC<
     PanelComponentProps & { isInHamburgerMenu: boolean }
     PanelComponentProps & { isInHamburgerMenu: boolean }
   >;
   >;
+  panelComponentPredicate?: (
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+  ) => boolean;
   perform: ActionFn;
   perform: ActionFn;
   keyPriority?: number;
   keyPriority?: number;
   keyTest?: (
   keyTest?: (
@@ -134,6 +160,13 @@ export interface Action {
     appState: AppState,
     appState: AppState,
     elements: readonly ExcalidrawElement[],
     elements: readonly ExcalidrawElement[],
   ) => boolean;
   ) => boolean;
+  customPredicate?: (
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+    appProps: ExcalidrawProps,
+    app: AppClassProperties,
+    data?: Record<string, any>,
+  ) => boolean;
   contextItemLabel?:
   contextItemLabel?:
     | string
     | string
     | ((
     | ((
@@ -145,6 +178,7 @@ export interface Action {
     appState: AppState,
     appState: AppState,
     appProps: ExcalidrawProps,
     appProps: ExcalidrawProps,
     app: AppClassProperties,
     app: AppClassProperties,
+    data?: Record<string, any>,
   ) => boolean;
   ) => boolean;
   checked?: (appState: Readonly<AppState>) => boolean;
   checked?: (appState: Readonly<AppState>) => boolean;
   trackEvent:
   trackEvent:

+ 16 - 0
src/components/Actions.tsx

@@ -36,10 +36,12 @@ export const SelectedShapeActions = ({
   appState,
   appState,
   elements,
   elements,
   renderAction,
   renderAction,
+  getCustomActions,
 }: {
 }: {
   appState: AppState;
   appState: AppState;
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
   renderAction: ActionManager["renderAction"];
   renderAction: ActionManager["renderAction"];
+  getCustomActions: ActionManager["getCustomActions"];
 }) => {
 }) => {
   const targetElements = getTargetElements(
   const targetElements = getTargetElements(
     getNonDeletedElements(elements),
     getNonDeletedElements(elements),
@@ -92,6 +94,15 @@ export const SelectedShapeActions = ({
       {showChangeBackgroundIcons && (
       {showChangeBackgroundIcons && (
         <div>{renderAction("changeBackgroundColor")}</div>
         <div>{renderAction("changeBackgroundColor")}</div>
       )}
       )}
+      {getCustomActions().map((action) => {
+        if (
+          action.panelComponentPredicate &&
+          action.panelComponentPredicate(targetElements, appState)
+        ) {
+          return renderAction(action.name);
+        }
+        return null;
+      })}
       {showFillIcons && renderAction("changeFillStyle")}
       {showFillIcons && renderAction("changeFillStyle")}
 
 
       {(hasStrokeWidth(appState.activeTool.type) ||
       {(hasStrokeWidth(appState.activeTool.type) ||
@@ -209,12 +220,14 @@ export const ShapesSwitcher = ({
   setAppState,
   setAppState,
   onImageAction,
   onImageAction,
   appState,
   appState,
+  onContextMenu,
 }: {
 }: {
   canvas: HTMLCanvasElement | null;
   canvas: HTMLCanvasElement | null;
   activeTool: AppState["activeTool"];
   activeTool: AppState["activeTool"];
   setAppState: React.Component<any, AppState>["setState"];
   setAppState: React.Component<any, AppState>["setState"];
   onImageAction: (data: { pointerType: PointerType | null }) => void;
   onImageAction: (data: { pointerType: PointerType | null }) => void;
   appState: AppState;
   appState: AppState;
+  onContextMenu?: (event: React.MouseEvent, source: string) => void;
 }) => (
 }) => (
   <>
   <>
     {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
     {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -264,6 +277,9 @@ export const ShapesSwitcher = ({
               onImageAction({ pointerType });
               onImageAction({ pointerType });
             }
             }
           }}
           }}
+          onContextMenu={(event, source) => {
+            onContextMenu && onContextMenu(event, source);
+          }}
         />
         />
       );
       );
     })}
     })}

+ 64 - 9
src/components/App.tsx

@@ -38,7 +38,7 @@ import {
 } from "../actions";
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
 import { ActionManager } from "../actions/manager";
-import { actions } from "../actions/register";
+import { getActions } from "../actions/register";
 import { ActionResult } from "../actions/types";
 import { ActionResult } from "../actions/types";
 import { trackEvent } from "../analytics";
 import { trackEvent } from "../analytics";
 import { getDefaultAppState, isEraserActive } from "../appState";
 import { getDefaultAppState, isEraserActive } from "../appState";
@@ -418,6 +418,12 @@ class App extends React.Component<AppProps, AppState> {
     this.id = nanoid();
     this.id = nanoid();
 
 
     this.library = new Library(this);
     this.library = new Library(this);
+    this.actionManager = new ActionManager(
+      this.syncActionResult,
+      () => this.state,
+      () => this.scene.getElementsIncludingDeleted(),
+      this,
+    );
     if (excalidrawRef) {
     if (excalidrawRef) {
       const readyPromise =
       const readyPromise =
         ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
         ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
@@ -438,6 +444,7 @@ class App extends React.Component<AppProps, AppState> {
         getSceneElements: this.getSceneElements,
         getSceneElements: this.getSceneElements,
         getAppState: () => this.state,
         getAppState: () => this.state,
         getFiles: () => this.files,
         getFiles: () => this.files,
+        actionManager: this.actionManager,
         refresh: this.refresh,
         refresh: this.refresh,
         setToast: this.setToast,
         setToast: this.setToast,
         id: this.id,
         id: this.id,
@@ -465,13 +472,8 @@ class App extends React.Component<AppProps, AppState> {
       onSceneUpdated: this.onSceneUpdated,
       onSceneUpdated: this.onSceneUpdated,
     });
     });
     this.history = new History();
     this.history = new History();
-    this.actionManager = new ActionManager(
-      this.syncActionResult,
-      () => this.state,
-      () => this.scene.getElementsIncludingDeleted(),
-      this,
-    );
-    this.actionManager.registerAll(actions);
+    this.actionManager.registerAll(getActions());
+    this.actionManager.registerActionGuards();
 
 
     this.actionManager.registerAction(createUndoAction(this.history));
     this.actionManager.registerAction(createUndoAction(this.history));
     this.actionManager.registerAction(createRedoAction(this.history));
     this.actionManager.registerAction(createRedoAction(this.history));
@@ -587,6 +589,7 @@ class App extends React.Component<AppProps, AppState> {
                       renderTopRightUI={renderTopRightUI}
                       renderTopRightUI={renderTopRightUI}
                       renderCustomStats={renderCustomStats}
                       renderCustomStats={renderCustomStats}
                       renderCustomSidebar={this.props.renderSidebar}
                       renderCustomSidebar={this.props.renderSidebar}
+                      onContextMenu={this.handleCustomContextMenu}
                       showExitZenModeBtn={
                       showExitZenModeBtn={
                         typeof this.props?.zenModeEnabled === "undefined" &&
                         typeof this.props?.zenModeEnabled === "undefined" &&
                         this.state.zenModeEnabled
                         this.state.zenModeEnabled
@@ -5968,6 +5971,28 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
   };
   };
 
 
+  private handleCustomContextMenu = (
+    event: React.MouseEvent,
+    source: string,
+  ) => {
+    event.preventDefault();
+
+    const container = this.excalidrawContainerRef.current!;
+    const { top: offsetTop, left: offsetLeft } =
+      container.getBoundingClientRect();
+    const left = event.clientX - offsetLeft;
+    const top = event.clientY - offsetTop;
+    this.setState({}, () => {
+      this.setState({
+        contextMenu: {
+          top,
+          left,
+          items: this.getContextMenuItems("custom", source),
+        },
+      });
+    });
+  };
+
   private handleCanvasContextMenu = (
   private handleCanvasContextMenu = (
     event: React.PointerEvent<HTMLCanvasElement>,
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
   ) => {
@@ -6139,9 +6164,39 @@ class App extends React.Component<AppProps, AppState> {
   };
   };
 
 
   private getContextMenuItems = (
   private getContextMenuItems = (
-    type: "canvas" | "element",
+    type: "canvas" | "element" | "custom",
+    source?: string,
   ): ContextMenuItems => {
   ): ContextMenuItems => {
     const options: ContextMenuItems = [];
     const options: ContextMenuItems = [];
+    const allElements = this.actionManager.getElementsIncludingDeleted();
+    const appState = this.actionManager.getAppState();
+    let addedCustom = false;
+    this.actionManager.getCustomActions().forEach((action) => {
+      const predicate =
+        type === "custom"
+          ? action.customPredicate
+          : action.contextItemPredicate;
+      if (
+        predicate &&
+        predicate(
+          allElements,
+          appState,
+          this.actionManager.app.props,
+          this.actionManager.app,
+          type === "custom" ? { source } : undefined,
+        ) &&
+        this.actionManager.isActionEnabled(allElements, appState, action.name)
+      ) {
+        addedCustom = true;
+        options.push(action);
+      }
+    });
+    if (type === "custom") {
+      return options;
+    }
+    if (addedCustom) {
+      options.push(CONTEXT_MENU_SEPARATOR);
+    }
 
 
     options.push(actionCopyAsPng, actionCopyAsSvg);
     options.push(actionCopyAsPng, actionCopyAsSvg);
 
 

+ 4 - 1
src/components/ContextMenu.tsx

@@ -5,6 +5,7 @@ import { t } from "../i18n";
 import "./ContextMenu.scss";
 import "./ContextMenu.scss";
 import {
 import {
   getShortcutFromShortcutName,
   getShortcutFromShortcutName,
+  CustomShortcutName,
   ShortcutName,
   ShortcutName,
 } from "../actions/shortcuts";
 } from "../actions/shortcuts";
 import { Action } from "../actions/types";
 import { Action } from "../actions/types";
@@ -110,7 +111,9 @@ export const ContextMenu = React.memo(
                   <div className="context-menu-item__label">{label}</div>
                   <div className="context-menu-item__label">{label}</div>
                   <kbd className="context-menu-item__shortcut">
                   <kbd className="context-menu-item__shortcut">
                     {actionName
                     {actionName
-                      ? getShortcutFromShortcutName(actionName as ShortcutName)
+                      ? getShortcutFromShortcutName(
+                          actionName as ShortcutName | CustomShortcutName,
+                        )
                       : ""}
                       : ""}
                   </kbd>
                   </kbd>
                 </button>
                 </button>

+ 5 - 0
src/components/LayerUI.tsx

@@ -78,6 +78,7 @@ interface LayerUIProps {
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderWelcomeScreen: boolean;
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
   children?: React.ReactNode;
+  onContextMenu?: (event: React.MouseEvent, source: string) => void;
 }
 }
 
 
 const LayerUI = ({
 const LayerUI = ({
@@ -104,6 +105,7 @@ const LayerUI = ({
   onImageAction,
   onImageAction,
   renderWelcomeScreen,
   renderWelcomeScreen,
   children,
   children,
+  onContextMenu,
 }: LayerUIProps) => {
 }: LayerUIProps) => {
   const device = useDevice();
   const device = useDevice();
 
 
@@ -240,6 +242,7 @@ const LayerUI = ({
           appState={appState}
           appState={appState}
           elements={elements}
           elements={elements}
           renderAction={actionManager.renderAction}
           renderAction={actionManager.renderAction}
+          getCustomActions={actionManager.getCustomActions}
         />
         />
       </Island>
       </Island>
     </Section>
     </Section>
@@ -327,6 +330,7 @@ const LayerUI = ({
                                 insertOnCanvasDirectly: pointerType !== "mouse",
                                 insertOnCanvasDirectly: pointerType !== "mouse",
                               });
                               });
                             }}
                             }}
+                            onContextMenu={onContextMenu}
                           />
                           />
                           {/* {actionManager.renderAction("eraser", {
                           {/* {actionManager.renderAction("eraser", {
                           // size: "small",
                           // size: "small",
@@ -433,6 +437,7 @@ const LayerUI = ({
           renderSidebars={renderSidebars}
           renderSidebars={renderSidebars}
           device={device}
           device={device}
           renderMenu={renderMenu}
           renderMenu={renderMenu}
+          onContextMenu={onContextMenu}
         />
         />
       )}
       )}
 
 

+ 4 - 0
src/components/MobileMenu.tsx

@@ -42,6 +42,7 @@ type MobileMenuProps = {
   device: Device;
   device: Device;
   renderWelcomeScreen?: boolean;
   renderWelcomeScreen?: boolean;
   renderMenu: () => React.ReactNode;
   renderMenu: () => React.ReactNode;
+  onContextMenu?: (event: React.MouseEvent, source: string) => void;
 };
 };
 
 
 export const MobileMenu = ({
 export const MobileMenu = ({
@@ -60,6 +61,7 @@ export const MobileMenu = ({
   device,
   device,
   renderWelcomeScreen,
   renderWelcomeScreen,
   renderMenu,
   renderMenu,
+  onContextMenu,
 }: MobileMenuProps) => {
 }: MobileMenuProps) => {
   const renderToolbar = () => {
   const renderToolbar = () => {
     return (
     return (
@@ -98,6 +100,7 @@ export const MobileMenu = ({
                           insertOnCanvasDirectly: pointerType !== "mouse",
                           insertOnCanvasDirectly: pointerType !== "mouse",
                         });
                         });
                       }}
                       }}
+                      onContextMenu={onContextMenu}
                     />
                     />
                   </Stack.Row>
                   </Stack.Row>
                 </Island>
                 </Island>
@@ -190,6 +193,7 @@ export const MobileMenu = ({
                 appState={appState}
                 appState={appState}
                 elements={elements}
                 elements={elements}
                 renderAction={actionManager.renderAction}
                 renderAction={actionManager.renderAction}
+                getCustomActions={actionManager.getCustomActions}
               />
               />
             </Section>
             </Section>
           ) : null}
           ) : null}

+ 6 - 0
src/components/ToolButton.tsx

@@ -26,6 +26,7 @@ type ToolButtonBaseProps = {
   selected?: boolean;
   selected?: boolean;
   className?: string;
   className?: string;
   isLoading?: boolean;
   isLoading?: boolean;
+  onContextMenu?(event: React.MouseEvent, source: string): void;
 };
 };
 
 
 type ToolButtonProps =
 type ToolButtonProps =
@@ -157,6 +158,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
           lastPointerTypeRef.current = null;
           lastPointerTypeRef.current = null;
         });
         });
       }}
       }}
+      onContextMenu={(event) => {
+        if (props.onContextMenu !== undefined) {
+          props.onContextMenu(event, props.name ?? "");
+        }
+      }}
     >
     >
       <input
       <input
         className={`ToolIcon_type_radio ${sizeCn}`}
         className={`ToolIcon_type_radio ${sizeCn}`}

+ 16 - 0
src/packages/excalidraw/example/App.tsx

@@ -30,6 +30,20 @@ import { NonDeletedExcalidrawElement } from "../../../element/types";
 import { ImportedLibraryData } from "../../../data/types";
 import { ImportedLibraryData } from "../../../data/types";
 import CustomFooter from "./CustomFooter";
 import CustomFooter from "./CustomFooter";
 import MobileFooter from "./MobileFooter";
 import MobileFooter from "./MobileFooter";
+import { Action, EnableFn } from "../../../actions/types";
+
+const exampleAction: Action = {
+  name: "example",
+  trackEvent: false,
+  perform: (elements, appState) => {
+    return { elements, appState, commitToHistory: false };
+  },
+  customPredicate: (elements, appState, appProps, app, data) =>
+    data !== undefined && data.source === "editor-current-shape",
+  contextItemLabel: "labels.untitled",
+};
+const exampleEnableFn: EnableFn = (elements, appState, actionName) =>
+  actionName === "example";
 
 
 declare global {
 declare global {
   interface Window {
   interface Window {
@@ -123,6 +137,8 @@ export default function App() {
     if (!excalidrawAPI) {
     if (!excalidrawAPI) {
       return;
       return;
     }
     }
+    excalidrawAPI.actionManager.registerAction(exampleAction);
+    excalidrawAPI.actionManager.registerEnableFn("example", exampleEnableFn);
     const fetchData = async () => {
     const fetchData = async () => {
       const res = await fetch("/rocket.jpeg");
       const res = await fetch("/rocket.jpeg");
       const imageData = await res.blob();
       const imageData = await res.blob();

+ 85 - 0
src/tests/customActions.test.tsx

@@ -0,0 +1,85 @@
+import { ExcalidrawElement } from "../element/types";
+import { getShortcutKey } from "../utils";
+import { API } from "./helpers/api";
+import {
+  CustomShortcutName,
+  getShortcutFromShortcutName,
+  registerCustomShortcuts,
+} from "../actions/shortcuts";
+import { Action, ActionName, DisableFn, EnableFn } from "../actions/types";
+import {
+  getActionDisablers,
+  getActionEnablers,
+  registerDisableFn,
+  registerEnableFn,
+} from "../actions/guards";
+
+const { h } = window;
+
+describe("regression tests", () => {
+  it("should retrieve custom shortcuts", () => {
+    const shortcuts: Record<CustomShortcutName, string[]> = {
+      test: [getShortcutKey("CtrlOrCmd+1"), getShortcutKey("CtrlOrCmd+2")],
+    };
+    registerCustomShortcuts(shortcuts);
+    expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
+  });
+
+  it("should follow action guards", () => {
+    // Create the test elements
+    const text1 = API.createElement({ type: "rectangle", id: "A", y: 0 });
+    const text2 = API.createElement({ type: "rectangle", id: "B", y: 30 });
+    const text3 = API.createElement({ type: "rectangle", id: "C", y: 60 });
+    const el12: ExcalidrawElement[] = [text1, text2];
+    const el13: ExcalidrawElement[] = [text1, text3];
+    const el23: ExcalidrawElement[] = [text2, text3];
+    const el123: ExcalidrawElement[] = [text1, text2, text3];
+    // Set up the custom Action enablers
+    const enableName = "custom" as Action["name"];
+    const enabler: EnableFn = function (elements) {
+      if (elements.some((el) => el.y === 30)) {
+        return true;
+      }
+      return false;
+    };
+    registerEnableFn(enableName, enabler);
+    // Set up the standard Action disablers
+    const disableName1 = "changeFontFamily" as ActionName;
+    const disableName2 = "changeFontSize" as ActionName;
+    const disabler: DisableFn = function (elements) {
+      if (elements.some((el) => el.y === 0)) {
+        return true;
+      }
+      return false;
+    };
+    registerDisableFn(disableName1, disabler);
+    // Test the custom Action enablers
+    const enablers = getActionEnablers();
+    const isCustomEnabled = function (
+      elements: ExcalidrawElement[],
+      name: string,
+    ) {
+      return (
+        name in enablers &&
+        enablers[name].some((enabler) => enabler(elements, h.state, name))
+      );
+    };
+    expect(isCustomEnabled(el12, enableName)).toBe(true);
+    expect(isCustomEnabled(el13, enableName)).toBe(false);
+    expect(isCustomEnabled(el23, enableName)).toBe(true);
+    // Test the standard Action disablers
+    const disablers = getActionDisablers();
+    const isStandardDisabled = function (
+      elements: ExcalidrawElement[],
+      name: ActionName,
+    ) {
+      return (
+        name in disablers &&
+        disablers[name].some((disabler) => disabler(elements, h.state, name))
+      );
+    };
+    expect(isStandardDisabled(el12, disableName1)).toBe(true);
+    expect(isStandardDisabled(el23, disableName1)).toBe(false);
+    expect(isStandardDisabled(el123, disableName2)).toBe(false);
+  });
+});

+ 1 - 0
src/types.ts

@@ -497,6 +497,7 @@ export type ExcalidrawImperativeAPI = {
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
   getAppState: () => InstanceType<typeof App>["state"];
   getAppState: () => InstanceType<typeof App>["state"];
   getFiles: () => InstanceType<typeof App>["files"];
   getFiles: () => InstanceType<typeof App>["files"];
+  actionManager: InstanceType<typeof App>["actionManager"];
   refresh: InstanceType<typeof App>["refresh"];
   refresh: InstanceType<typeof App>["refresh"];
   setToast: InstanceType<typeof App>["setToast"];
   setToast: InstanceType<typeof App>["setToast"];
   addFiles: (data: BinaryFileData[]) => void;
   addFiles: (data: BinaryFileData[]) => void;