feat: Custom actions and shortcuts

This commit is contained in:
Daniel J. Geiger 2023-01-06 13:34:39 -06:00
parent 0f11f7da15
commit 8e5d376b49
14 changed files with 452 additions and 98 deletions

25
src/actions/guards.ts Normal file
View File

@ -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);
}
};

View File

@ -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}

View File

@ -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"];

View File

@ -80,8 +80,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export type CustomShortcutName = string;
const shortcuts = shortcutMap[name];
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] : "";
}; };

View File

@ -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 = const actionNames = [
| "copy" "copy",
| "cut" "cut",
| "paste" "paste",
| "copyAsPng" "copyAsPng",
| "copyAsSvg" "copyAsSvg",
| "copyText" "copyText",
| "sendBackward" "sendBackward",
| "bringForward" "bringForward",
| "sendToBack" "sendToBack",
| "bringToFront" "bringToFront",
| "copyStyles" "copyStyles",
| "selectAll" "selectAll",
| "pasteStyles" "pasteStyles",
| "gridMode" "gridMode",
| "zenMode" "zenMode",
| "stats" "stats",
| "changeStrokeColor" "changeStrokeColor",
| "changeBackgroundColor" "changeBackgroundColor",
| "changeFillStyle" "changeFillStyle",
| "changeStrokeWidth" "changeStrokeWidth",
| "changeStrokeShape" "changeStrokeShape",
| "changeSloppiness" "changeSloppiness",
| "changeStrokeStyle" "changeStrokeStyle",
| "changeArrowhead" "changeArrowhead",
| "changeOpacity" "changeOpacity",
| "changeFontSize" "changeFontSize",
| "toggleCanvasMenu" "toggleCanvasMenu",
| "toggleEditMenu" "toggleEditMenu",
| "undo" "undo",
| "redo" "redo",
| "finalize" "finalize",
| "changeProjectName" "changeProjectName",
| "changeExportBackground" "changeExportBackground",
| "changeExportEmbedScene" "changeExportEmbedScene",
| "changeExportScale" "changeExportScale",
| "saveToActiveFile" "saveToActiveFile",
| "saveFileToDisk" "saveFileToDisk",
| "loadScene" "loadScene",
| "duplicateSelection" "duplicateSelection",
| "deleteSelectedElements" "deleteSelectedElements",
| "changeViewBackgroundColor" "changeViewBackgroundColor",
| "clearCanvas" "clearCanvas",
| "zoomIn" "zoomIn",
| "zoomOut" "zoomOut",
| "resetZoom" "resetZoom",
| "zoomToFit" "zoomToFit",
| "zoomToSelection" "zoomToSelection",
| "changeFontFamily" "changeFontFamily",
| "changeTextAlign" "changeTextAlign",
| "changeVerticalAlign" "changeVerticalAlign",
| "toggleFullScreen" "toggleFullScreen",
| "toggleShortcuts" "toggleShortcuts",
| "group" "group",
| "ungroup" "ungroup",
| "goToCollaborator" "goToCollaborator",
| "addToLibrary" "addToLibrary",
| "changeRoundness" "changeRoundness",
| "alignTop" "alignTop",
| "alignBottom" "alignBottom",
| "alignLeft" "alignLeft",
| "alignRight" "alignRight",
| "alignVerticallyCentered" "alignVerticallyCentered",
| "alignHorizontallyCentered" "alignHorizontallyCentered",
| "distributeHorizontally" "distributeHorizontally",
| "distributeVertically" "distributeVertically",
| "flipHorizontal" "flipHorizontal",
| "flipVertical" "flipVertical",
| "viewMode" "viewMode",
| "exportWithDarkMode" "exportWithDarkMode",
| "toggleTheme" "toggleTheme",
| "increaseFontSize" "increaseFontSize",
| "decreaseFontSize" "decreaseFontSize",
| "unbindText" "unbindText",
| "hyperlink" "hyperlink",
| "eraser" "eraser",
| "bindText" "bindText",
| "toggleLock" "toggleLock",
| "toggleLinearEditor"; "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:

View File

@ -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);
}}
/> />
); );
})} })}

View File

@ -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.actionManager.registerAll(getActions());
this.syncActionResult, this.actionManager.registerActionGuards();
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
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);

View File

@ -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>

View File

@ -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}
/> />
)} )}

View File

@ -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}

View File

@ -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}`}

View File

@ -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();

View File

@ -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);
});
});

View File

@ -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;