Simplify custom Actions: universal Action predicates instead of

action-specific guards.
This commit is contained in:
Daniel J. Geiger 2023-01-27 13:23:40 -06:00
parent 512e506798
commit c8d4e8c421
9 changed files with 105 additions and 193 deletions

View file

@ -1,18 +1,19 @@
import { ExcalidrawElement } from "../element/types";
import { getShortcutKey } from "../utils";
import { API } from "./helpers/api";
import { render } from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import {
CustomShortcutName,
getShortcutFromShortcutName,
registerCustomShortcuts,
} from "../actions/shortcuts";
import { Action, ActionName, DisableFn, EnableFn } from "../actions/types";
import { Action, ActionPredicateFn, ActionResult } from "../actions/types";
import {
getActionDisablers,
getActionEnablers,
registerDisableFn,
registerEnableFn,
} from "../actions/guards";
actionChangeFontFamily,
actionChangeFontSize,
} from "../actions/actionProperties";
import { isTextElement } from "../element";
const { h } = window;
@ -25,61 +26,60 @@ describe("regression tests", () => {
expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
});
it("should follow action guards", () => {
it("should apply universal action predicates", async () => {
await render(<ExcalidrawApp />);
// 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];
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 enabler: EnableFn = function (elements) {
if (elements.some((el) => el.y === 30)) {
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;
};
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;
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 false;
return true;
};
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);
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
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);
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

@ -20,6 +20,7 @@ import {
SubtypeRecord,
prepareSubtype,
selectSubtype,
subtypeActionPredicate,
} from "../../subtypes";
import {
maybeGetSubtypeProps,
@ -36,6 +37,7 @@ const { h } = window;
export class API {
constructor() {
h.app.actionManager.registerActionPredicate(subtypeActionPredicate);
if (true) {
// Call `prepareSubtype()` here for `@excalidraw/excalidraw`-specific subtypes
}
@ -45,7 +47,6 @@ export class API {
const prep = prepareSubtype(record, subtypePrepFn);
if (prep.actions) {
h.app.actionManager.registerAll(prep.actions);
h.app.actionManager.registerActionGuards();
}
return prep;
};

View file

@ -24,9 +24,11 @@ import { getFontString, getShortcutKey } from "../utils";
import * as textElementUtils from "../element/textElement";
import { isTextElement } from "../element";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { Action } from "../actions/types";
import { Action, ActionName } from "../actions/types";
import { AppState } from "../types";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { actionChangeSloppiness } from "../actions";
import { actionChangeRoundness } from "../actions/actionProperties";
const MW = 200;
const TWIDTH = 200;
@ -60,13 +62,13 @@ const testSubtypeIcon = ({ theme }: { theme: Theme }) =>
);
const TEST_ACTION = "testAction";
const TEST_DISABLE1 = "changeSloppiness";
const TEST_DISABLE3 = "changeRoundness";
const TEST_DISABLE1 = actionChangeSloppiness;
const TEST_DISABLE3 = actionChangeRoundness;
const test1: SubtypeRecord = {
subtype: "test",
parents: ["line", "arrow", "rectangle", "diamond", "ellipse"],
disabledNames: [TEST_DISABLE1],
disabledNames: [TEST_DISABLE1.name as ActionName],
actionNames: [TEST_ACTION],
};
@ -106,7 +108,7 @@ const test3: SubtypeRecord = {
testShortcut: [getShortcutKey("Shift+T")],
},
alwaysEnabledNames: ["test3Always"],
disabledNames: [TEST_DISABLE3],
disabledNames: [TEST_DISABLE3.name as ActionName],
};
const test3Button = SubtypeButton(