From 8e5d376b497b0416700afa10f8375b61782c8967 Mon Sep 17 00:00:00 2001 From: "Daniel J. Geiger" <1852529+DanielJGeiger@users.noreply.github.com> Date: Fri, 6 Jan 2023 13:34:39 -0600 Subject: [PATCH] feat: Custom actions and shortcuts --- src/actions/guards.ts | 25 +++ src/actions/manager.tsx | 91 ++++++++++- src/actions/register.ts | 10 +- src/actions/shortcuts.ts | 19 ++- src/actions/types.ts | 194 ++++++++++++++---------- src/components/Actions.tsx | 16 ++ src/components/App.tsx | 73 +++++++-- src/components/ContextMenu.tsx | 5 +- src/components/LayerUI.tsx | 5 + src/components/MobileMenu.tsx | 4 + src/components/ToolButton.tsx | 6 + src/packages/excalidraw/example/App.tsx | 16 ++ src/tests/customActions.test.tsx | 85 +++++++++++ src/types.ts | 1 + 14 files changed, 452 insertions(+), 98 deletions(-) create mode 100644 src/actions/guards.ts create mode 100644 src/tests/customActions.test.tsx diff --git a/src/actions/guards.ts b/src/actions/guards.ts new file mode 100644 index 000000000..a5a00ab69 --- /dev/null +++ b/src/actions/guards.ts @@ -0,0 +1,25 @@ +import { Action, ActionName, DisableFn, EnableFn } from "./types"; + +const disablers = {} as Record; +const enablers = {} as Record; + +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); + } +}; diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 6c87aa037..6c464ff4a 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -6,7 +6,11 @@ import { ActionResult, PanelComponentProps, ActionSource, + DisableFn, + EnableFn, + isActionName, } from "./types"; +import { getActionDisablers, getActionEnablers } from "./guards"; import { ExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; @@ -40,7 +44,10 @@ const trackAction = ( }; export class ActionManager { - actions = {} as Record; + actions = {} as Record; + + disablers = {} as Record; + enablers = {} as Record; updater: (actionResult: ActionResult | Promise) => void; @@ -68,6 +75,73 @@ export class ActionManager { 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) { this.actions[action.name] = action; } @@ -84,7 +158,11 @@ export class ActionManager { (action) => (action.name in canvasActions ? canvasActions[action.name as keyof typeof canvasActions] - : true) && + : this.isActionEnabled( + this.getElementsIncludingDeleted(), + this.getAppState(), + action.name, + )) && action.keyTest && action.keyTest( event, @@ -132,7 +210,7 @@ export class ActionManager { * @param data additional data sent to the PanelComponent */ renderAction = ( - name: ActionName, + name: ActionName | Action["name"], data?: PanelComponentProps["data"], isInHamburgerMenu = false, ) => { @@ -143,7 +221,11 @@ export class ActionManager { "PanelComponent" in this.actions[name] && (name in canvasActions ? canvasActions[name as keyof typeof canvasActions] - : true) + : this.isActionEnabled( + this.getElementsIncludingDeleted(), + this.getAppState(), + name, + )) ) { const action = this.actions[name]; const PanelComponent = action.PanelComponent!; @@ -165,6 +247,7 @@ export class ActionManager { return ( customActions; +export const getActions = () => actions; export const register = (action: T) => { + if (!isActionName(action.name)) { + customActions = customActions.concat(action); + } actions = actions.concat(action); return action as T & { keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index 41686e521..0029ee120 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -80,8 +80,23 @@ const shortcutMap: Record = { toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], }; -export const getShortcutFromShortcutName = (name: ShortcutName) => { - const shortcuts = shortcutMap[name]; +export type CustomShortcutName = string; + +let customShortcutMap: Record = {}; + +export const registerCustomShortcuts = ( + shortcuts: Record, +) => { + 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 return shortcuts && shortcuts.length > 0 ? shortcuts[0] : ""; }; diff --git a/src/actions/types.ts b/src/actions/types.ts index 93e29cfc1..203be7ae1 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -31,88 +31,110 @@ type ActionFn = ( app: AppClassProperties, ) => ActionResult | Promise; +// 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 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 = { elements: readonly ExcalidrawElement[]; @@ -123,10 +145,14 @@ export type PanelComponentProps = { }; export interface Action { - name: ActionName; + name: string; PanelComponent?: React.FC< PanelComponentProps & { isInHamburgerMenu: boolean } >; + panelComponentPredicate?: ( + elements: readonly ExcalidrawElement[], + appState: AppState, + ) => boolean; perform: ActionFn; keyPriority?: number; keyTest?: ( @@ -134,6 +160,13 @@ export interface Action { appState: AppState, elements: readonly ExcalidrawElement[], ) => boolean; + customPredicate?: ( + elements: readonly ExcalidrawElement[], + appState: AppState, + appProps: ExcalidrawProps, + app: AppClassProperties, + data?: Record, + ) => boolean; contextItemLabel?: | string | (( @@ -145,6 +178,7 @@ export interface Action { appState: AppState, appProps: ExcalidrawProps, app: AppClassProperties, + data?: Record, ) => boolean; checked?: (appState: Readonly) => boolean; trackEvent: diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index fe017f776..b0e06b6a8 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -36,10 +36,12 @@ export const SelectedShapeActions = ({ appState, elements, renderAction, + getCustomActions, }: { appState: AppState; elements: readonly ExcalidrawElement[]; renderAction: ActionManager["renderAction"]; + getCustomActions: ActionManager["getCustomActions"]; }) => { const targetElements = getTargetElements( getNonDeletedElements(elements), @@ -92,6 +94,15 @@ export const SelectedShapeActions = ({ {showChangeBackgroundIcons && (
{renderAction("changeBackgroundColor")}
)} + {getCustomActions().map((action) => { + if ( + action.panelComponentPredicate && + action.panelComponentPredicate(targetElements, appState) + ) { + return renderAction(action.name); + } + return null; + })} {showFillIcons && renderAction("changeFillStyle")} {(hasStrokeWidth(appState.activeTool.type) || @@ -209,12 +220,14 @@ export const ShapesSwitcher = ({ setAppState, onImageAction, appState, + onContextMenu, }: { canvas: HTMLCanvasElement | null; activeTool: AppState["activeTool"]; setAppState: React.Component["setState"]; onImageAction: (data: { pointerType: PointerType | null }) => void; appState: AppState; + onContextMenu?: (event: React.MouseEvent, source: string) => void; }) => ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { @@ -264,6 +277,9 @@ export const ShapesSwitcher = ({ onImageAction({ pointerType }); } }} + onContextMenu={(event, source) => { + onContextMenu && onContextMenu(event, source); + }} /> ); })} diff --git a/src/components/App.tsx b/src/components/App.tsx index c0275dbf2..5e47bf6d8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -38,7 +38,7 @@ import { } from "../actions"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { ActionManager } from "../actions/manager"; -import { actions } from "../actions/register"; +import { getActions } from "../actions/register"; import { ActionResult } from "../actions/types"; import { trackEvent } from "../analytics"; import { getDefaultAppState, isEraserActive } from "../appState"; @@ -418,6 +418,12 @@ class App extends React.Component { this.id = nanoid(); this.library = new Library(this); + this.actionManager = new ActionManager( + this.syncActionResult, + () => this.state, + () => this.scene.getElementsIncludingDeleted(), + this, + ); if (excalidrawRef) { const readyPromise = ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) || @@ -438,6 +444,7 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, + actionManager: this.actionManager, refresh: this.refresh, setToast: this.setToast, id: this.id, @@ -465,13 +472,8 @@ class App extends React.Component { onSceneUpdated: this.onSceneUpdated, }); 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(createRedoAction(this.history)); @@ -587,6 +589,7 @@ class App extends React.Component { renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} renderCustomSidebar={this.props.renderSidebar} + onContextMenu={this.handleCustomContextMenu} showExitZenModeBtn={ typeof this.props?.zenModeEnabled === "undefined" && this.state.zenModeEnabled @@ -5968,6 +5971,28 @@ class App extends React.Component { } }; + 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 = ( event: React.PointerEvent, ) => { @@ -6139,9 +6164,39 @@ class App extends React.Component { }; private getContextMenuItems = ( - type: "canvas" | "element", + type: "canvas" | "element" | "custom", + source?: string, ): 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); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 2ec72e5ea..7787db930 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -5,6 +5,7 @@ import { t } from "../i18n"; import "./ContextMenu.scss"; import { getShortcutFromShortcutName, + CustomShortcutName, ShortcutName, } from "../actions/shortcuts"; import { Action } from "../actions/types"; @@ -110,7 +111,9 @@ export const ContextMenu = React.memo(
{label}
{actionName - ? getShortcutFromShortcutName(actionName as ShortcutName) + ? getShortcutFromShortcutName( + actionName as ShortcutName | CustomShortcutName, + ) : ""} diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 57047a8d4..d2887451a 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -78,6 +78,7 @@ interface LayerUIProps { onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderWelcomeScreen: boolean; children?: React.ReactNode; + onContextMenu?: (event: React.MouseEvent, source: string) => void; } const LayerUI = ({ @@ -104,6 +105,7 @@ const LayerUI = ({ onImageAction, renderWelcomeScreen, children, + onContextMenu, }: LayerUIProps) => { const device = useDevice(); @@ -240,6 +242,7 @@ const LayerUI = ({ appState={appState} elements={elements} renderAction={actionManager.renderAction} + getCustomActions={actionManager.getCustomActions} /> @@ -327,6 +330,7 @@ const LayerUI = ({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} + onContextMenu={onContextMenu} /> {/* {actionManager.renderAction("eraser", { // size: "small", @@ -433,6 +437,7 @@ const LayerUI = ({ renderSidebars={renderSidebars} device={device} renderMenu={renderMenu} + onContextMenu={onContextMenu} /> )} diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 5d77c3407..a51bf917c 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -42,6 +42,7 @@ type MobileMenuProps = { device: Device; renderWelcomeScreen?: boolean; renderMenu: () => React.ReactNode; + onContextMenu?: (event: React.MouseEvent, source: string) => void; }; export const MobileMenu = ({ @@ -60,6 +61,7 @@ export const MobileMenu = ({ device, renderWelcomeScreen, renderMenu, + onContextMenu, }: MobileMenuProps) => { const renderToolbar = () => { return ( @@ -98,6 +100,7 @@ export const MobileMenu = ({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} + onContextMenu={onContextMenu} /> @@ -190,6 +193,7 @@ export const MobileMenu = ({ appState={appState} elements={elements} renderAction={actionManager.renderAction} + getCustomActions={actionManager.getCustomActions} /> ) : null} diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index 7077898f3..ef124ae40 100644 --- a/src/components/ToolButton.tsx +++ b/src/components/ToolButton.tsx @@ -26,6 +26,7 @@ type ToolButtonBaseProps = { selected?: boolean; className?: string; isLoading?: boolean; + onContextMenu?(event: React.MouseEvent, source: string): void; }; type ToolButtonProps = @@ -157,6 +158,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { lastPointerTypeRef.current = null; }); }} + onContextMenu={(event) => { + if (props.onContextMenu !== undefined) { + props.onContextMenu(event, props.name ?? ""); + } + }} > { + 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 { interface Window { @@ -123,6 +137,8 @@ export default function App() { if (!excalidrawAPI) { return; } + excalidrawAPI.actionManager.registerAction(exampleAction); + excalidrawAPI.actionManager.registerEnableFn("example", exampleEnableFn); const fetchData = async () => { const res = await fetch("/rocket.jpeg"); const imageData = await res.blob(); diff --git a/src/tests/customActions.test.tsx b/src/tests/customActions.test.tsx new file mode 100644 index 000000000..c265e965b --- /dev/null +++ b/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 = { + 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); + }); +}); diff --git a/src/types.ts b/src/types.ts index 8ee85d279..da89c903f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -497,6 +497,7 @@ export type ExcalidrawImperativeAPI = { getSceneElements: InstanceType["getSceneElements"]; getAppState: () => InstanceType["state"]; getFiles: () => InstanceType["files"]; + actionManager: InstanceType["actionManager"]; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"]; addFiles: (data: BinaryFileData[]) => void;