feat: Custom actions and shortcuts

This commit is contained in:
Daniel J. Geiger 2023-01-06 13:34:39 -06:00
parent 0f11f7da15
commit 8e5d376b49
14 changed files with 452 additions and 98 deletions

25
src/actions/guards.ts Normal file
View 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);
}
};

View File

@ -6,7 +6,11 @@ import {
ActionResult,
PanelComponentProps,
ActionSource,
DisableFn,
EnableFn,
isActionName,
} from "./types";
import { getActionDisablers, getActionEnablers } from "./guards";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
@ -40,7 +44,10 @@ const trackAction = (
};
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;
@ -68,6 +75,73 @@ export class ActionManager {
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) {
this.actions[action.name] = action;
}
@ -84,7 +158,11 @@ export class ActionManager {
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
: this.isActionEnabled(
this.getElementsIncludingDeleted(),
this.getAppState(),
action.name,
)) &&
action.keyTest &&
action.keyTest(
event,
@ -132,7 +210,7 @@ export class ActionManager {
* @param data additional data sent to the PanelComponent
*/
renderAction = (
name: ActionName,
name: ActionName | Action["name"],
data?: PanelComponentProps["data"],
isInHamburgerMenu = false,
) => {
@ -143,7 +221,11 @@ export class ActionManager {
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
: this.isActionEnabled(
this.getElementsIncludingDeleted(),
this.getAppState(),
name,
))
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@ -165,6 +247,7 @@ export class ActionManager {
return (
<PanelComponent
key={name}
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}

View File

@ -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) => {
if (!isActionName(action.name)) {
customActions = customActions.concat(action);
}
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];

View File

@ -80,8 +80,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
export type CustomShortcutName = string;
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
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};

View File

@ -31,88 +31,110 @@ type ActionFn = (
app: AppClassProperties,
) => 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 ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
| "toggleEditMenu"
| "undo"
| "redo"
| "finalize"
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeExportScale"
| "saveToActiveFile"
| "saveFileToDisk"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
| "changeViewBackgroundColor"
| "clearCanvas"
| "zoomIn"
| "zoomOut"
| "resetZoom"
| "zoomToFit"
| "zoomToSelection"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeRoundness"
| "alignTop"
| "alignBottom"
| "alignLeft"
| "alignRight"
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock"
| "toggleLinearEditor";
const actionNames = [
"copy",
"cut",
"paste",
"copyAsPng",
"copyAsSvg",
"copyText",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"copyStyles",
"selectAll",
"pasteStyles",
"gridMode",
"zenMode",
"stats",
"changeStrokeColor",
"changeBackgroundColor",
"changeFillStyle",
"changeStrokeWidth",
"changeStrokeShape",
"changeSloppiness",
"changeStrokeStyle",
"changeArrowhead",
"changeOpacity",
"changeFontSize",
"toggleCanvasMenu",
"toggleEditMenu",
"undo",
"redo",
"finalize",
"changeProjectName",
"changeExportBackground",
"changeExportEmbedScene",
"changeExportScale",
"saveToActiveFile",
"saveFileToDisk",
"loadScene",
"duplicateSelection",
"deleteSelectedElements",
"changeViewBackgroundColor",
"clearCanvas",
"zoomIn",
"zoomOut",
"resetZoom",
"zoomToFit",
"zoomToSelection",
"changeFontFamily",
"changeTextAlign",
"changeVerticalAlign",
"toggleFullScreen",
"toggleShortcuts",
"group",
"ungroup",
"goToCollaborator",
"addToLibrary",
"changeRoundness",
"alignTop",
"alignBottom",
"alignLeft",
"alignRight",
"alignVerticallyCentered",
"alignHorizontallyCentered",
"distributeHorizontally",
"distributeVertically",
"flipHorizontal",
"flipVertical",
"viewMode",
"exportWithDarkMode",
"toggleTheme",
"increaseFontSize",
"decreaseFontSize",
"unbindText",
"hyperlink",
"eraser",
"bindText",
"toggleLock",
"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 = {
elements: readonly ExcalidrawElement[];
@ -123,10 +145,14 @@ export type PanelComponentProps = {
};
export interface Action {
name: ActionName;
name: string;
PanelComponent?: React.FC<
PanelComponentProps & { isInHamburgerMenu: boolean }
>;
panelComponentPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
@ -134,6 +160,13 @@ export interface Action {
appState: AppState,
elements: readonly ExcalidrawElement[],
) => boolean;
customPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
contextItemLabel?:
| string
| ((
@ -145,6 +178,7 @@ export interface Action {
appState: AppState,
appProps: ExcalidrawProps,
app: AppClassProperties,
data?: Record<string, any>,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent:

View File

@ -36,10 +36,12 @@ export const SelectedShapeActions = ({
appState,
elements,
renderAction,
getCustomActions,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
getCustomActions: ActionManager["getCustomActions"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@ -92,6 +94,15 @@ export const SelectedShapeActions = ({
{showChangeBackgroundIcons && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{getCustomActions().map((action) => {
if (
action.panelComponentPredicate &&
action.panelComponentPredicate(targetElements, appState)
) {
return renderAction(action.name);
}
return null;
})}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
@ -209,12 +220,14 @@ export const ShapesSwitcher = ({
setAppState,
onImageAction,
appState,
onContextMenu,
}: {
canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
onContextMenu?: (event: React.MouseEvent, source: string) => void;
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@ -264,6 +277,9 @@ export const ShapesSwitcher = ({
onImageAction({ pointerType });
}
}}
onContextMenu={(event, source) => {
onContextMenu && onContextMenu(event, source);
}}
/>
);
})}

View File

@ -38,7 +38,7 @@ import {
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { getActions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState, isEraserActive } from "../appState";
@ -418,6 +418,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) ||
@ -438,6 +444,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,
@ -465,13 +472,8 @@ 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.registerAll(getActions());
this.actionManager.registerActionGuards();
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
@ -587,6 +589,7 @@ class App extends React.Component<AppProps, AppState> {
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
onContextMenu={this.handleCustomContextMenu}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
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 = (
event: React.PointerEvent<HTMLCanvasElement>,
) => {
@ -6139,9 +6164,39 @@ class App extends React.Component<AppProps, AppState> {
};
private getContextMenuItems = (
type: "canvas" | "element",
type: "canvas" | "element" | "custom",
source?: string,
): 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);

View File

@ -5,6 +5,7 @@ import { t } from "../i18n";
import "./ContextMenu.scss";
import {
getShortcutFromShortcutName,
CustomShortcutName,
ShortcutName,
} from "../actions/shortcuts";
import { Action } from "../actions/types";
@ -110,7 +111,9 @@ export const ContextMenu = React.memo(
<div className="context-menu-item__label">{label}</div>
<kbd className="context-menu-item__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
? getShortcutFromShortcutName(
actionName as ShortcutName | CustomShortcutName,
)
: ""}
</kbd>
</button>

View File

@ -78,6 +78,7 @@ interface LayerUIProps {
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderWelcomeScreen: boolean;
children?: React.ReactNode;
onContextMenu?: (event: React.MouseEvent, source: string) => void;
}
const LayerUI = ({
@ -104,6 +105,7 @@ const LayerUI = ({
onImageAction,
renderWelcomeScreen,
children,
onContextMenu,
}: LayerUIProps) => {
const device = useDevice();
@ -240,6 +242,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
getCustomActions={actionManager.getCustomActions}
/>
</Island>
</Section>
@ -327,6 +330,7 @@ const LayerUI = ({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
onContextMenu={onContextMenu}
/>
{/* {actionManager.renderAction("eraser", {
// size: "small",
@ -433,6 +437,7 @@ const LayerUI = ({
renderSidebars={renderSidebars}
device={device}
renderMenu={renderMenu}
onContextMenu={onContextMenu}
/>
)}

View File

@ -42,6 +42,7 @@ type MobileMenuProps = {
device: Device;
renderWelcomeScreen?: boolean;
renderMenu: () => React.ReactNode;
onContextMenu?: (event: React.MouseEvent, source: string) => void;
};
export const MobileMenu = ({
@ -60,6 +61,7 @@ export const MobileMenu = ({
device,
renderWelcomeScreen,
renderMenu,
onContextMenu,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@ -98,6 +100,7 @@ export const MobileMenu = ({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
onContextMenu={onContextMenu}
/>
</Stack.Row>
</Island>
@ -190,6 +193,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
getCustomActions={actionManager.getCustomActions}
/>
</Section>
) : null}

View File

@ -26,6 +26,7 @@ type ToolButtonBaseProps = {
selected?: boolean;
className?: string;
isLoading?: boolean;
onContextMenu?(event: React.MouseEvent, source: string): void;
};
type ToolButtonProps =
@ -157,6 +158,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
lastPointerTypeRef.current = null;
});
}}
onContextMenu={(event) => {
if (props.onContextMenu !== undefined) {
props.onContextMenu(event, props.name ?? "");
}
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}

View File

@ -30,6 +30,20 @@ import { NonDeletedExcalidrawElement } from "../../../element/types";
import { ImportedLibraryData } from "../../../data/types";
import CustomFooter from "./CustomFooter";
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 {
interface Window {
@ -123,6 +137,8 @@ export default function App() {
if (!excalidrawAPI) {
return;
}
excalidrawAPI.actionManager.registerAction(exampleAction);
excalidrawAPI.actionManager.registerEnableFn("example", exampleEnableFn);
const fetchData = async () => {
const res = await fetch("/rocket.jpeg");
const imageData = await res.blob();

View 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);
});
});

View File

@ -497,6 +497,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;