mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-06 00:56:58 +02:00
Compare commits
13 Commits
v0.16.1
...
feat-custo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5e828da559 | ||
![]() |
8336edb4a0 | ||
![]() |
27e2888347 | ||
![]() |
e192538267 | ||
![]() |
1d3652a96c | ||
![]() |
bb96f322c6 | ||
![]() |
e6fb7e3016 | ||
![]() |
e385066b4b | ||
![]() |
a5bd54b86d | ||
![]() |
01432813a6 | ||
![]() |
6e3b575fa5 | ||
![]() |
333cc53797 | ||
![]() |
8e5d376b49 |
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -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:
|
||||||
|
@@ -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));
|
||||||
|
71
src/tests/customActions.test.tsx
Normal file
71
src/tests/customActions.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
||||||
|
Reference in New Issue
Block a user