Compare commits

...

13 Commits

5 changed files with 126 additions and 18 deletions

View File

@ -2,10 +2,10 @@ import React from "react";
import {
Action,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
ActionPredicateFn,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
@ -40,7 +40,8 @@ const trackAction = (
};
export class ActionManager {
actions = {} as Record<ActionName, Action>;
actions = {} as Record<Action["name"], Action>;
actionPredicates = [] as ActionPredicateFn[];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@ -68,6 +69,12 @@ export class ActionManager {
this.app = app;
}
registerActionPredicate(predicate: ActionPredicateFn) {
if (!this.actionPredicates.includes(predicate)) {
this.actionPredicates.push(predicate);
}
}
registerAction(action: Action) {
this.actions[action.name] = action;
}
@ -84,7 +91,7 @@ export class ActionManager {
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
: this.isActionEnabled(action, { noPredicates: true })) &&
action.keyTest &&
action.keyTest(
event,
@ -134,7 +141,7 @@ export class ActionManager {
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@ -142,7 +149,7 @@ export class ActionManager {
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@ -176,13 +183,31 @@ export class ActionManager {
return null;
};
isActionEnabled = (action: Action) => {
const elements = this.getElementsIncludingDeleted();
isActionEnabled = (
action: Action,
opts?: {
elements?: readonly ExcalidrawElement[];
data?: Record<string, any>;
noPredicates?: boolean;
},
): boolean => {
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
const appState = this.getAppState();
const data = opts?.data;
return (
!action.predicate ||
action.predicate(elements, appState, this.app.props, this.app)
);
if (
!opts?.noPredicates &&
action.predicate &&
!action.predicate(elements, appState, this.app.props, this.app, data)
) {
return false;
}
let enabled = true;
this.actionPredicates.forEach((fn) => {
if (!fn(action, elements, appState, data)) {
enabled = false;
}
});
return enabled;
};
}

View File

@ -32,6 +32,15 @@ type ActionFn = (
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
// Return `true` *unless* `Action` should be disabled
// given `elements`, `appState`, and optionally `data`.
export type ActionPredicateFn = (
action: Action,
elements: readonly ExcalidrawElement[],
appState: AppState,
data?: Record<string, any>,
) => boolean;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
@ -132,7 +141,7 @@ export type PanelComponentProps = {
};
export interface Action {
name: ActionName;
name: string;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
@ -152,6 +161,7 @@ export interface Action {
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:

View File

@ -479,6 +479,12 @@ class App extends React.Component<AppProps, AppState> {
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) ||
@ -499,6 +505,7 @@ class App extends React.Component<AppProps, AppState> {
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
actionManager: this.actionManager,
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
@ -527,12 +534,6 @@ class App extends React.Component<AppProps, AppState> {
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.registerAction(createUndoAction(this.history));

View File

@ -0,0 +1,71 @@
import { ExcalidrawElement } from "../element/types";
import { API } from "./helpers/api";
import { render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import { Action, ActionPredicateFn, ActionResult } from "../actions/types";
import {
actionChangeFontFamily,
actionChangeFontSize,
} from "../actions/actionProperties";
import { isTextElement } from "../element";
const { h } = window;
describe("regression tests", () => {
it("should apply universal action predicates", async () => {
await render(<ExcalidrawApp />);
// Create the test elements
const el1 = API.createElement({ type: "rectangle", id: "A", y: 0 });
const el2 = API.createElement({ type: "rectangle", id: "B", y: 30 });
const el3 = API.createElement({ type: "text", id: "C", y: 60 });
const el12: ExcalidrawElement[] = [el1, el2];
const el13: ExcalidrawElement[] = [el1, el3];
const el23: ExcalidrawElement[] = [el2, el3];
const el123: ExcalidrawElement[] = [el1, el2, el3];
// Set up the custom Action enablers
const enableName = "custom" as Action["name"];
const enableAction: Action = {
name: enableName,
perform: (): ActionResult => {
return {} as ActionResult;
},
trackEvent: false,
};
const enabler: ActionPredicateFn = function (action, elements) {
if (action.name !== enableName || elements.some((el) => el.y === 30)) {
return true;
}
return false;
};
// Set up the standard Action disablers
const disabled1 = actionChangeFontFamily;
const disabled2 = actionChangeFontSize;
const disabler: ActionPredicateFn = function (action, elements) {
if (
action.name === disabled2.name &&
elements.some((el) => el.y === 0 || isTextElement(el))
) {
return false;
}
return true;
};
// Test the custom Action enablers
const am = h.app.actionManager;
am.registerActionPredicate(enabler);
expect(am.isActionEnabled(enableAction, { elements: el12 })).toBe(true);
expect(am.isActionEnabled(enableAction, { elements: el13 })).toBe(false);
expect(am.isActionEnabled(enableAction, { elements: el23 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el12 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el13 })).toBe(true);
expect(am.isActionEnabled(disabled1, { elements: el23 })).toBe(true);
// Test the standard Action disablers
am.registerActionPredicate(disabler);
expect(am.isActionEnabled(disabled1, { elements: el123 })).toBe(true);
expect(am.isActionEnabled(disabled2, { elements: [el1] })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: [el2] })).toBe(true);
expect(am.isActionEnabled(disabled2, { elements: [el3] })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el12 })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el23 })).toBe(false);
expect(am.isActionEnabled(disabled2, { elements: el13 })).toBe(false);
});
});

View File

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