Compare commits

...

13 Commits

Author SHA1 Message Date
Daniel J. Geiger
5e828da559 Align. 2023-06-16 11:25:08 -05:00
Daniel J. Geiger
8336edb4a0 Merge remote-tracking branch 'origin/release' into feat-custom-actions 2023-06-16 11:11:58 -05:00
Daniel J. Geiger
27e2888347 Address remaining comments. 2023-01-30 09:05:08 -06:00
Daniel J. Geiger
e192538267 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-28 18:49:03 -06:00
Daniel J. Geiger
1d3652a96c Simplify: universal Action predicates instead of action-specific guards. 2023-01-27 14:21:35 -06:00
Daniel J. Geiger
bb96f322c6 Simplify per review comments. 2023-01-26 19:31:09 -06:00
Daniel J. Geiger
e6fb7e3016 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-26 17:54:32 -06:00
Daniel J. Geiger
e385066b4b Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-19 18:32:16 -06:00
Daniel J. Geiger
a5bd54b86d Filter context menu items through isActionEnabled. 2023-01-08 20:00:46 -06:00
Daniel J. Geiger
01432813a6 Fix lint. 2023-01-06 17:10:53 -06:00
Daniel J. Geiger
6e3b575fa5 Fix/cleanup. 2023-01-06 16:55:31 -06:00
Daniel J. Geiger
333cc53797 Merge remote-tracking branch 'origin/master' into feat-custom-actions 2023-01-06 16:32:37 -06:00
Daniel J. Geiger
8e5d376b49 feat: Custom actions and shortcuts 2023-01-06 13:34:39 -06:00
5 changed files with 126 additions and 18 deletions

View File

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

@@ -479,6 +479,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) ||
@@ -499,6 +505,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,
@@ -527,12 +534,6 @@ 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(actions);
this.actionManager.registerAction(createUndoAction(this.history)); 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"]; 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;