feat: Custom actions and shortcuts
This commit is contained in:
parent
0f11f7da15
commit
8e5d376b49
25
src/actions/guards.ts
Normal file
25
src/actions/guards.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Action, ActionName, DisableFn, EnableFn } from "./types";
|
||||||
|
|
||||||
|
const disablers = {} as Record<ActionName, DisableFn[]>;
|
||||||
|
const enablers = {} as Record<Action["name"], EnableFn[]>;
|
||||||
|
|
||||||
|
export const getActionDisablers = () => disablers;
|
||||||
|
export const getActionEnablers = () => enablers;
|
||||||
|
|
||||||
|
export const registerDisableFn = (name: ActionName, disabler: DisableFn) => {
|
||||||
|
if (!(name in disablers)) {
|
||||||
|
disablers[name] = [] as DisableFn[];
|
||||||
|
}
|
||||||
|
if (!disablers[name].includes(disabler)) {
|
||||||
|
disablers[name].push(disabler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerEnableFn = (name: Action["name"], enabler: EnableFn) => {
|
||||||
|
if (!(name in enablers)) {
|
||||||
|
enablers[name] = [] as EnableFn[];
|
||||||
|
}
|
||||||
|
if (!enablers[name].includes(enabler)) {
|
||||||
|
enablers[name].push(enabler);
|
||||||
|
}
|
||||||
|
};
|
@ -6,7 +6,11 @@ import {
|
|||||||
ActionResult,
|
ActionResult,
|
||||||
PanelComponentProps,
|
PanelComponentProps,
|
||||||
ActionSource,
|
ActionSource,
|
||||||
|
DisableFn,
|
||||||
|
EnableFn,
|
||||||
|
isActionName,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { getActionDisablers, getActionEnablers } from "./guards";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
@ -40,7 +44,10 @@ const trackAction = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ActionManager {
|
export class ActionManager {
|
||||||
actions = {} as Record<ActionName, Action>;
|
actions = {} as Record<ActionName | Action["name"], Action>;
|
||||||
|
|
||||||
|
disablers = {} as Record<ActionName, DisableFn[]>;
|
||||||
|
enablers = {} as Record<Action["name"], EnableFn[]>;
|
||||||
|
|
||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
@ -68,6 +75,73 @@ export class ActionManager {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public registerActionGuards() {
|
||||||
|
const disablers = getActionDisablers();
|
||||||
|
for (const d in disablers) {
|
||||||
|
const dName = d as ActionName;
|
||||||
|
disablers[dName].forEach((disabler) =>
|
||||||
|
this.registerDisableFn(dName, disabler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const enablers = getActionEnablers();
|
||||||
|
for (const e in enablers) {
|
||||||
|
const eName = e as Action["name"];
|
||||||
|
enablers[e].forEach((enabler) => this.registerEnableFn(eName, enabler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerDisableFn(name: ActionName, disabler: DisableFn) {
|
||||||
|
if (!(name in this.disablers)) {
|
||||||
|
this.disablers[name] = [] as DisableFn[];
|
||||||
|
}
|
||||||
|
if (!this.disablers[name].includes(disabler)) {
|
||||||
|
this.disablers[name].push(disabler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerEnableFn(name: Action["name"], enabler: EnableFn) {
|
||||||
|
if (!(name in this.enablers)) {
|
||||||
|
this.enablers[name] = [] as EnableFn[];
|
||||||
|
}
|
||||||
|
if (!this.enablers[name].includes(enabler)) {
|
||||||
|
this.enablers[name].push(enabler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCustomActions(): Action[] {
|
||||||
|
// For testing
|
||||||
|
if (this === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const customActions: Action[] = [];
|
||||||
|
for (const key in this.actions) {
|
||||||
|
const action = this.actions[key];
|
||||||
|
if (!isActionName(action.name)) {
|
||||||
|
customActions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return customActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isActionEnabled(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
actionName: Action["name"],
|
||||||
|
): boolean {
|
||||||
|
if (isActionName(actionName)) {
|
||||||
|
return !(
|
||||||
|
actionName in this.disablers &&
|
||||||
|
this.disablers[actionName].some((fn) =>
|
||||||
|
fn(elements, appState, actionName),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
actionName in this.enablers &&
|
||||||
|
this.enablers[actionName].some((fn) => fn(elements, appState, actionName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
registerAction(action: Action) {
|
registerAction(action: Action) {
|
||||||
this.actions[action.name] = action;
|
this.actions[action.name] = action;
|
||||||
}
|
}
|
||||||
@ -84,7 +158,11 @@ 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(
|
||||||
|
this.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
|
action.name,
|
||||||
|
)) &&
|
||||||
action.keyTest &&
|
action.keyTest &&
|
||||||
action.keyTest(
|
action.keyTest(
|
||||||
event,
|
event,
|
||||||
@ -132,7 +210,7 @@ export class ActionManager {
|
|||||||
* @param data additional data sent to the PanelComponent
|
* @param data additional data sent to the PanelComponent
|
||||||
*/
|
*/
|
||||||
renderAction = (
|
renderAction = (
|
||||||
name: ActionName,
|
name: ActionName | Action["name"],
|
||||||
data?: PanelComponentProps["data"],
|
data?: PanelComponentProps["data"],
|
||||||
isInHamburgerMenu = false,
|
isInHamburgerMenu = false,
|
||||||
) => {
|
) => {
|
||||||
@ -143,7 +221,11 @@ 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.getElementsIncludingDeleted(),
|
||||||
|
this.getAppState(),
|
||||||
|
name,
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
const action = this.actions[name];
|
const action = this.actions[name];
|
||||||
const PanelComponent = action.PanelComponent!;
|
const PanelComponent = action.PanelComponent!;
|
||||||
@ -165,6 +247,7 @@ export class ActionManager {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelComponent
|
<PanelComponent
|
||||||
|
key={name}
|
||||||
elements={this.getElementsIncludingDeleted()}
|
elements={this.getElementsIncludingDeleted()}
|
||||||
appState={this.getAppState()}
|
appState={this.getAppState()}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
import { Action } from "./types";
|
import { Action, isActionName } from "./types";
|
||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
let actions: readonly Action[] = [];
|
||||||
|
let customActions: readonly Action[] = [];
|
||||||
|
export const getCustomActions = () => customActions;
|
||||||
|
export const getActions = () => actions;
|
||||||
|
|
||||||
export const register = <T extends Action>(action: T) => {
|
export const register = <T extends Action>(action: T) => {
|
||||||
|
if (!isActionName(action.name)) {
|
||||||
|
customActions = customActions.concat(action);
|
||||||
|
}
|
||||||
actions = actions.concat(action);
|
actions = actions.concat(action);
|
||||||
return action as T & {
|
return action as T & {
|
||||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||||
|
@ -80,8 +80,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export type CustomShortcutName = string;
|
||||||
const shortcuts = shortcutMap[name];
|
|
||||||
|
let customShortcutMap: Record<CustomShortcutName, string[]> = {};
|
||||||
|
|
||||||
|
export const registerCustomShortcuts = (
|
||||||
|
shortcuts: Record<CustomShortcutName, string[]>,
|
||||||
|
) => {
|
||||||
|
customShortcutMap = { ...customShortcutMap, ...shortcuts };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShortcutFromShortcutName = (
|
||||||
|
name: ShortcutName | CustomShortcutName,
|
||||||
|
) => {
|
||||||
|
const shortcuts =
|
||||||
|
name in customShortcutMap
|
||||||
|
? customShortcutMap[name as CustomShortcutName]
|
||||||
|
: shortcutMap[name as ShortcutName];
|
||||||
// if multiple shortcuts available, take the first one
|
// if multiple shortcuts available, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||||
};
|
};
|
||||||
|
@ -31,88 +31,110 @@ type ActionFn = (
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
|
// Return `true` to indicate the standard Action with name `actionName`
|
||||||
|
// should be disabled given `elements` and `appState`.
|
||||||
|
export type DisableFn = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
actionName: ActionName,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
// Return `true` to indicate the custom Action with name `actionName`
|
||||||
|
// should be enabled given `elements` and `appState`.
|
||||||
|
export type EnableFn = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
actionName: Action["name"],
|
||||||
|
) => 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;
|
||||||
|
|
||||||
export type ActionName =
|
const actionNames = [
|
||||||
| "copy"
|
"copy",
|
||||||
| "cut"
|
"cut",
|
||||||
| "paste"
|
"paste",
|
||||||
| "copyAsPng"
|
"copyAsPng",
|
||||||
| "copyAsSvg"
|
"copyAsSvg",
|
||||||
| "copyText"
|
"copyText",
|
||||||
| "sendBackward"
|
"sendBackward",
|
||||||
| "bringForward"
|
"bringForward",
|
||||||
| "sendToBack"
|
"sendToBack",
|
||||||
| "bringToFront"
|
"bringToFront",
|
||||||
| "copyStyles"
|
"copyStyles",
|
||||||
| "selectAll"
|
"selectAll",
|
||||||
| "pasteStyles"
|
"pasteStyles",
|
||||||
| "gridMode"
|
"gridMode",
|
||||||
| "zenMode"
|
"zenMode",
|
||||||
| "stats"
|
"stats",
|
||||||
| "changeStrokeColor"
|
"changeStrokeColor",
|
||||||
| "changeBackgroundColor"
|
"changeBackgroundColor",
|
||||||
| "changeFillStyle"
|
"changeFillStyle",
|
||||||
| "changeStrokeWidth"
|
"changeStrokeWidth",
|
||||||
| "changeStrokeShape"
|
"changeStrokeShape",
|
||||||
| "changeSloppiness"
|
"changeSloppiness",
|
||||||
| "changeStrokeStyle"
|
"changeStrokeStyle",
|
||||||
| "changeArrowhead"
|
"changeArrowhead",
|
||||||
| "changeOpacity"
|
"changeOpacity",
|
||||||
| "changeFontSize"
|
"changeFontSize",
|
||||||
| "toggleCanvasMenu"
|
"toggleCanvasMenu",
|
||||||
| "toggleEditMenu"
|
"toggleEditMenu",
|
||||||
| "undo"
|
"undo",
|
||||||
| "redo"
|
"redo",
|
||||||
| "finalize"
|
"finalize",
|
||||||
| "changeProjectName"
|
"changeProjectName",
|
||||||
| "changeExportBackground"
|
"changeExportBackground",
|
||||||
| "changeExportEmbedScene"
|
"changeExportEmbedScene",
|
||||||
| "changeExportScale"
|
"changeExportScale",
|
||||||
| "saveToActiveFile"
|
"saveToActiveFile",
|
||||||
| "saveFileToDisk"
|
"saveFileToDisk",
|
||||||
| "loadScene"
|
"loadScene",
|
||||||
| "duplicateSelection"
|
"duplicateSelection",
|
||||||
| "deleteSelectedElements"
|
"deleteSelectedElements",
|
||||||
| "changeViewBackgroundColor"
|
"changeViewBackgroundColor",
|
||||||
| "clearCanvas"
|
"clearCanvas",
|
||||||
| "zoomIn"
|
"zoomIn",
|
||||||
| "zoomOut"
|
"zoomOut",
|
||||||
| "resetZoom"
|
"resetZoom",
|
||||||
| "zoomToFit"
|
"zoomToFit",
|
||||||
| "zoomToSelection"
|
"zoomToSelection",
|
||||||
| "changeFontFamily"
|
"changeFontFamily",
|
||||||
| "changeTextAlign"
|
"changeTextAlign",
|
||||||
| "changeVerticalAlign"
|
"changeVerticalAlign",
|
||||||
| "toggleFullScreen"
|
"toggleFullScreen",
|
||||||
| "toggleShortcuts"
|
"toggleShortcuts",
|
||||||
| "group"
|
"group",
|
||||||
| "ungroup"
|
"ungroup",
|
||||||
| "goToCollaborator"
|
"goToCollaborator",
|
||||||
| "addToLibrary"
|
"addToLibrary",
|
||||||
| "changeRoundness"
|
"changeRoundness",
|
||||||
| "alignTop"
|
"alignTop",
|
||||||
| "alignBottom"
|
"alignBottom",
|
||||||
| "alignLeft"
|
"alignLeft",
|
||||||
| "alignRight"
|
"alignRight",
|
||||||
| "alignVerticallyCentered"
|
"alignVerticallyCentered",
|
||||||
| "alignHorizontallyCentered"
|
"alignHorizontallyCentered",
|
||||||
| "distributeHorizontally"
|
"distributeHorizontally",
|
||||||
| "distributeVertically"
|
"distributeVertically",
|
||||||
| "flipHorizontal"
|
"flipHorizontal",
|
||||||
| "flipVertical"
|
"flipVertical",
|
||||||
| "viewMode"
|
"viewMode",
|
||||||
| "exportWithDarkMode"
|
"exportWithDarkMode",
|
||||||
| "toggleTheme"
|
"toggleTheme",
|
||||||
| "increaseFontSize"
|
"increaseFontSize",
|
||||||
| "decreaseFontSize"
|
"decreaseFontSize",
|
||||||
| "unbindText"
|
"unbindText",
|
||||||
| "hyperlink"
|
"hyperlink",
|
||||||
| "eraser"
|
"eraser",
|
||||||
| "bindText"
|
"bindText",
|
||||||
| "toggleLock"
|
"toggleLock",
|
||||||
| "toggleLinearEditor";
|
"toggleLinearEditor",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// So we can have the `isActionName` type guard
|
||||||
|
export type ActionName = typeof actionNames[number];
|
||||||
|
export const isActionName = (n: any): n is ActionName =>
|
||||||
|
actionNames.includes(n);
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -123,10 +145,14 @@ export type PanelComponentProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: string;
|
||||||
PanelComponent?: React.FC<
|
PanelComponent?: React.FC<
|
||||||
PanelComponentProps & { isInHamburgerMenu: boolean }
|
PanelComponentProps & { isInHamburgerMenu: boolean }
|
||||||
>;
|
>;
|
||||||
|
panelComponentPredicate?: (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => boolean;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
keyTest?: (
|
keyTest?: (
|
||||||
@ -134,6 +160,13 @@ export interface Action {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
customPredicate?: (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
appProps: ExcalidrawProps,
|
||||||
|
app: AppClassProperties,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
) => boolean;
|
||||||
contextItemLabel?:
|
contextItemLabel?:
|
||||||
| string
|
| string
|
||||||
| ((
|
| ((
|
||||||
@ -145,6 +178,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:
|
||||||
|
@ -36,10 +36,12 @@ export const SelectedShapeActions = ({
|
|||||||
appState,
|
appState,
|
||||||
elements,
|
elements,
|
||||||
renderAction,
|
renderAction,
|
||||||
|
getCustomActions,
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
|
getCustomActions: ActionManager["getCustomActions"];
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@ -92,6 +94,15 @@ export const SelectedShapeActions = ({
|
|||||||
{showChangeBackgroundIcons && (
|
{showChangeBackgroundIcons && (
|
||||||
<div>{renderAction("changeBackgroundColor")}</div>
|
<div>{renderAction("changeBackgroundColor")}</div>
|
||||||
)}
|
)}
|
||||||
|
{getCustomActions().map((action) => {
|
||||||
|
if (
|
||||||
|
action.panelComponentPredicate &&
|
||||||
|
action.panelComponentPredicate(targetElements, appState)
|
||||||
|
) {
|
||||||
|
return renderAction(action.name);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
|
||||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||||
@ -209,12 +220,14 @@ export const ShapesSwitcher = ({
|
|||||||
setAppState,
|
setAppState,
|
||||||
onImageAction,
|
onImageAction,
|
||||||
appState,
|
appState,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
activeTool: AppState["activeTool"];
|
activeTool: AppState["activeTool"];
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
onContextMenu?: (event: React.MouseEvent, source: string) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
@ -264,6 +277,9 @@ export const ShapesSwitcher = ({
|
|||||||
onImageAction({ pointerType });
|
onImageAction({ pointerType });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event, source) => {
|
||||||
|
onContextMenu && onContextMenu(event, source);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -38,7 +38,7 @@ import {
|
|||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { actions } from "../actions/register";
|
import { getActions } from "../actions/register";
|
||||||
import { ActionResult } from "../actions/types";
|
import { ActionResult } from "../actions/types";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||||
@ -418,6 +418,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) ||
|
||||||
@ -438,6 +444,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,
|
||||||
@ -465,13 +472,8 @@ 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.actionManager.registerAll(getActions());
|
||||||
this.syncActionResult,
|
this.actionManager.registerActionGuards();
|
||||||
() => this.state,
|
|
||||||
() => this.scene.getElementsIncludingDeleted(),
|
|
||||||
this,
|
|
||||||
);
|
|
||||||
this.actionManager.registerAll(actions);
|
|
||||||
|
|
||||||
this.actionManager.registerAction(createUndoAction(this.history));
|
this.actionManager.registerAction(createUndoAction(this.history));
|
||||||
this.actionManager.registerAction(createRedoAction(this.history));
|
this.actionManager.registerAction(createRedoAction(this.history));
|
||||||
@ -587,6 +589,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
renderTopRightUI={renderTopRightUI}
|
renderTopRightUI={renderTopRightUI}
|
||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
renderCustomSidebar={this.props.renderSidebar}
|
renderCustomSidebar={this.props.renderSidebar}
|
||||||
|
onContextMenu={this.handleCustomContextMenu}
|
||||||
showExitZenModeBtn={
|
showExitZenModeBtn={
|
||||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||||
this.state.zenModeEnabled
|
this.state.zenModeEnabled
|
||||||
@ -5968,6 +5971,28 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleCustomContextMenu = (
|
||||||
|
event: React.MouseEvent,
|
||||||
|
source: string,
|
||||||
|
) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const container = this.excalidrawContainerRef.current!;
|
||||||
|
const { top: offsetTop, left: offsetLeft } =
|
||||||
|
container.getBoundingClientRect();
|
||||||
|
const left = event.clientX - offsetLeft;
|
||||||
|
const top = event.clientY - offsetTop;
|
||||||
|
this.setState({}, () => {
|
||||||
|
this.setState({
|
||||||
|
contextMenu: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
items: this.getContextMenuItems("custom", source),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private handleCanvasContextMenu = (
|
private handleCanvasContextMenu = (
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
) => {
|
) => {
|
||||||
@ -6139,9 +6164,39 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private getContextMenuItems = (
|
private getContextMenuItems = (
|
||||||
type: "canvas" | "element",
|
type: "canvas" | "element" | "custom",
|
||||||
|
source?: string,
|
||||||
): ContextMenuItems => {
|
): ContextMenuItems => {
|
||||||
const options: ContextMenuItems = [];
|
const options: ContextMenuItems = [];
|
||||||
|
const allElements = this.actionManager.getElementsIncludingDeleted();
|
||||||
|
const appState = this.actionManager.getAppState();
|
||||||
|
let addedCustom = false;
|
||||||
|
this.actionManager.getCustomActions().forEach((action) => {
|
||||||
|
const predicate =
|
||||||
|
type === "custom"
|
||||||
|
? action.customPredicate
|
||||||
|
: action.contextItemPredicate;
|
||||||
|
if (
|
||||||
|
predicate &&
|
||||||
|
predicate(
|
||||||
|
allElements,
|
||||||
|
appState,
|
||||||
|
this.actionManager.app.props,
|
||||||
|
this.actionManager.app,
|
||||||
|
type === "custom" ? { source } : undefined,
|
||||||
|
) &&
|
||||||
|
this.actionManager.isActionEnabled(allElements, appState, action.name)
|
||||||
|
) {
|
||||||
|
addedCustom = true;
|
||||||
|
options.push(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (type === "custom") {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (addedCustom) {
|
||||||
|
options.push(CONTEXT_MENU_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
options.push(actionCopyAsPng, actionCopyAsSvg);
|
options.push(actionCopyAsPng, actionCopyAsSvg);
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { t } from "../i18n";
|
|||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
import {
|
import {
|
||||||
getShortcutFromShortcutName,
|
getShortcutFromShortcutName,
|
||||||
|
CustomShortcutName,
|
||||||
ShortcutName,
|
ShortcutName,
|
||||||
} from "../actions/shortcuts";
|
} from "../actions/shortcuts";
|
||||||
import { Action } from "../actions/types";
|
import { Action } from "../actions/types";
|
||||||
@ -110,7 +111,9 @@ export const ContextMenu = React.memo(
|
|||||||
<div className="context-menu-item__label">{label}</div>
|
<div className="context-menu-item__label">{label}</div>
|
||||||
<kbd className="context-menu-item__shortcut">
|
<kbd className="context-menu-item__shortcut">
|
||||||
{actionName
|
{actionName
|
||||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
? getShortcutFromShortcutName(
|
||||||
|
actionName as ShortcutName | CustomShortcutName,
|
||||||
|
)
|
||||||
: ""}
|
: ""}
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
@ -78,6 +78,7 @@ interface LayerUIProps {
|
|||||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||||
renderWelcomeScreen: boolean;
|
renderWelcomeScreen: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
onContextMenu?: (event: React.MouseEvent, source: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerUI = ({
|
const LayerUI = ({
|
||||||
@ -104,6 +105,7 @@ const LayerUI = ({
|
|||||||
onImageAction,
|
onImageAction,
|
||||||
renderWelcomeScreen,
|
renderWelcomeScreen,
|
||||||
children,
|
children,
|
||||||
|
onContextMenu,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
|
|
||||||
@ -240,6 +242,7 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
|
getCustomActions={actionManager.getCustomActions}
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@ -327,6 +330,7 @@ const LayerUI = ({
|
|||||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
{/* {actionManager.renderAction("eraser", {
|
{/* {actionManager.renderAction("eraser", {
|
||||||
// size: "small",
|
// size: "small",
|
||||||
@ -433,6 +437,7 @@ const LayerUI = ({
|
|||||||
renderSidebars={renderSidebars}
|
renderSidebars={renderSidebars}
|
||||||
device={device}
|
device={device}
|
||||||
renderMenu={renderMenu}
|
renderMenu={renderMenu}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ type MobileMenuProps = {
|
|||||||
device: Device;
|
device: Device;
|
||||||
renderWelcomeScreen?: boolean;
|
renderWelcomeScreen?: boolean;
|
||||||
renderMenu: () => React.ReactNode;
|
renderMenu: () => React.ReactNode;
|
||||||
|
onContextMenu?: (event: React.MouseEvent, source: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@ -60,6 +61,7 @@ export const MobileMenu = ({
|
|||||||
device,
|
device,
|
||||||
renderWelcomeScreen,
|
renderWelcomeScreen,
|
||||||
renderMenu,
|
renderMenu,
|
||||||
|
onContextMenu,
|
||||||
}: MobileMenuProps) => {
|
}: MobileMenuProps) => {
|
||||||
const renderToolbar = () => {
|
const renderToolbar = () => {
|
||||||
return (
|
return (
|
||||||
@ -98,6 +100,7 @@ export const MobileMenu = ({
|
|||||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
@ -190,6 +193,7 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
|
getCustomActions={actionManager.getCustomActions}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -26,6 +26,7 @@ type ToolButtonBaseProps = {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
onContextMenu?(event: React.MouseEvent, source: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolButtonProps =
|
type ToolButtonProps =
|
||||||
@ -157,6 +158,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
lastPointerTypeRef.current = null;
|
lastPointerTypeRef.current = null;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
if (props.onContextMenu !== undefined) {
|
||||||
|
props.onContextMenu(event, props.name ?? "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
|
@ -30,6 +30,20 @@ import { NonDeletedExcalidrawElement } from "../../../element/types";
|
|||||||
import { ImportedLibraryData } from "../../../data/types";
|
import { ImportedLibraryData } from "../../../data/types";
|
||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
import MobileFooter from "./MobileFooter";
|
import MobileFooter from "./MobileFooter";
|
||||||
|
import { Action, EnableFn } from "../../../actions/types";
|
||||||
|
|
||||||
|
const exampleAction: Action = {
|
||||||
|
name: "example",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
return { elements, appState, commitToHistory: false };
|
||||||
|
},
|
||||||
|
customPredicate: (elements, appState, appProps, app, data) =>
|
||||||
|
data !== undefined && data.source === "editor-current-shape",
|
||||||
|
contextItemLabel: "labels.untitled",
|
||||||
|
};
|
||||||
|
const exampleEnableFn: EnableFn = (elements, appState, actionName) =>
|
||||||
|
actionName === "example";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -123,6 +137,8 @@ export default function App() {
|
|||||||
if (!excalidrawAPI) {
|
if (!excalidrawAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
excalidrawAPI.actionManager.registerAction(exampleAction);
|
||||||
|
excalidrawAPI.actionManager.registerEnableFn("example", exampleEnableFn);
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const res = await fetch("/rocket.jpeg");
|
const res = await fetch("/rocket.jpeg");
|
||||||
const imageData = await res.blob();
|
const imageData = await res.blob();
|
||||||
|
85
src/tests/customActions.test.tsx
Normal file
85
src/tests/customActions.test.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import {
|
||||||
|
CustomShortcutName,
|
||||||
|
getShortcutFromShortcutName,
|
||||||
|
registerCustomShortcuts,
|
||||||
|
} from "../actions/shortcuts";
|
||||||
|
import { Action, ActionName, DisableFn, EnableFn } from "../actions/types";
|
||||||
|
import {
|
||||||
|
getActionDisablers,
|
||||||
|
getActionEnablers,
|
||||||
|
registerDisableFn,
|
||||||
|
registerEnableFn,
|
||||||
|
} from "../actions/guards";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("regression tests", () => {
|
||||||
|
it("should retrieve custom shortcuts", () => {
|
||||||
|
const shortcuts: Record<CustomShortcutName, string[]> = {
|
||||||
|
test: [getShortcutKey("CtrlOrCmd+1"), getShortcutKey("CtrlOrCmd+2")],
|
||||||
|
};
|
||||||
|
registerCustomShortcuts(shortcuts);
|
||||||
|
expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should follow action guards", () => {
|
||||||
|
// 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];
|
||||||
|
// Set up the custom Action enablers
|
||||||
|
const enableName = "custom" as Action["name"];
|
||||||
|
const enabler: EnableFn = function (elements) {
|
||||||
|
if (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;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
@ -497,6 +497,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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user