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
11 changed files with 131 additions and 63 deletions

View File

@@ -2,11 +2,6 @@
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you.
For new contributors we would recommend to start with *Easy* tasks.
In case you want to pick up something from the roadmap, comment on that issue and one of the project maintainers will assign it to you, post which you can discuss in the issue and start working on it.
## Setup
### Option 1 - Manual

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

@@ -12,7 +12,7 @@ const Header = () => (
<div className="HelpDialog__header">
<a
className="HelpDialog__btn"
href="https://docs.excalidraw.com"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -43,7 +43,3 @@ Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/
## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api)
## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing)

View File

@@ -251,7 +251,6 @@ const drawImagePlaceholder = (
size,
);
};
const drawElementOnCanvas = (
element: NonDeletedExcalidrawElement,
rc: RoughCanvas,
@@ -301,35 +300,17 @@ const drawElementOnCanvas = (
const img = isInitializedImageElement(element)
? renderConfig.imageCache.get(element.fileId)?.image
: undefined;
context.save();
const { width, height } = element;
if (element.roundness) {
const r = getCornerRadius(Math.min(width, height), element) / 4;
context.beginPath();
context.moveTo(r, 0);
context.lineTo(width - r, 0);
context.quadraticCurveTo(width, 0, width, r);
context.lineTo(width, height - r);
context.quadraticCurveTo(width, height, width - r, height);
context.lineTo(r, height);
context.quadraticCurveTo(0, height, 0, height - r);
context.lineTo(0, r);
context.quadraticCurveTo(0, 0, r, 0);
context.closePath();
context.clip();
}
if (img != null && !(img instanceof Promise)) {
context.drawImage(
img,
0 /* hardcoded for the selection box*/,
0,
width,
height,
element.width,
element.height,
);
} else {
drawImagePlaceholder(element, context, renderConfig.zoom.value);
}
context.restore();
break;
}
default: {
@@ -1409,14 +1390,7 @@ export const renderElementToSvg = (
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
if (element.roundness) {
const r =
getCornerRadius(
Math.min(element.width, element.height),
element,
) / 4;
image.setAttribute("clip-path", `inset(0% round ${r}px)`);
}
symbol.appendChild(image);
root.prepend(symbol);

View File

@@ -29,8 +29,7 @@ export const canChangeRoundness = (type: string) =>
type === "rectangle" ||
type === "arrow" ||
type === "line" ||
type === "diamond" ||
type === "image";
type === "diamond";
export const hasText = (type: string) => type === "text";

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

@@ -118,7 +118,6 @@ describe("export", () => {
scale: [1, 1],
width: 100,
height: 100,
roundness: null,
angle: normalizeAngle(315),
}),
API.createElement({
@@ -129,7 +128,6 @@ describe("export", () => {
scale: [-1, 1],
width: 50,
height: 50,
roundness: null,
angle: normalizeAngle(45),
}),
API.createElement({
@@ -140,7 +138,6 @@ describe("export", () => {
scale: [1, -1],
width: 100,
height: 100,
roundness: null,
angle: normalizeAngle(45),
}),
API.createElement({
@@ -151,7 +148,6 @@ describe("export", () => {
scale: [-1, -1],
width: 50,
height: 50,
roundness: null,
angle: normalizeAngle(315),
}),
];

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;