feat: add `onChange`, `onPointerDown`, `onPointerUp` api subs (#7154)

This commit is contained in:
David Luzar 2023-10-20 13:08:22 +02:00 committed by GitHub
parent 9eb89f9960
commit e7cc2337ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 173 additions and 0 deletions

View File

@ -374,6 +374,7 @@ import {
resetCursor,
setCursorForShape,
} from "../cursor";
import { Emitter } from "../emitter";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -505,6 +506,30 @@ class App extends React.Component<AppProps, AppState> {
laserPathManager: LaserPathManager = new LaserPathManager(this);
onChangeEmitter = new Emitter<
[
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
]
>();
onPointerDownEmitter = new Emitter<
[
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: React.PointerEvent<HTMLElement>,
]
>();
onPointerUpEmitter = new Emitter<
[
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: PointerEvent,
]
>();
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
@ -568,6 +593,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor: this.resetCursor,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@ -1750,6 +1778,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.destroy();
this.library.destroy();
this.laserPathManager.destroy();
this.onChangeEmitter.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@ -2034,6 +2063,11 @@ class App extends React.Component<AppProps, AppState> {
this.state,
this.files,
);
this.onChangeEmitter.trigger(
this.scene.getElementsIncludingDeleted(),
this.state,
this.files,
);
}
}
@ -4699,6 +4733,11 @@ class App extends React.Component<AppProps, AppState> {
}
this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
this.onPointerDownEmitter.trigger(
this.state.activeTool,
pointerDownState,
event,
);
const onPointerMove =
this.onPointerMoveFromPointerDownHandler(pointerDownState);
@ -6551,6 +6590,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ pendingImageElementId: null });
}
this.onPointerUpEmitter.trigger(
this.state.activeTool,
pointerDownState,
childEvent,
);
if (draggingElement?.type === "freedraw") {
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,

47
src/emitter.ts Normal file
View File

@ -0,0 +1,47 @@
type Subscriber<T extends any[]> = (...payload: T) => void;
export class Emitter<T extends any[] = []> {
public subscribers: Subscriber<T>[] = [];
public value: T | undefined;
private updateOnChangeOnly: boolean;
constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) {
this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false;
this.value = opts?.initialState;
}
/**
* Attaches subscriber
*
* @returns unsubscribe function
*/
on(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
const _handlers = handlers
.flat()
.filter((item) => typeof item === "function");
this.subscribers.push(..._handlers);
return () => this.off(_handlers);
}
off(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
const _handlers = handlers.flat();
this.subscribers = this.subscribers.filter(
(handler) => !_handlers.includes(handler),
);
}
trigger(...payload: T): any[] {
if (this.updateOnChangeOnly && this.value === payload) {
return [];
}
this.value = payload;
return this.subscribers.map((handler) => handler(...payload));
}
destroy() {
this.subscribers = [];
this.value = undefined;
}
}

View File

@ -0,0 +1,58 @@
import { vi } from "vitest";
import { Excalidraw } from "../../packages/excalidraw/index";
import { ExcalidrawImperativeAPI } from "../../types";
import { resolvablePromise } from "../../utils";
import { render } from "../test-utils";
import { Pointer } from "../helpers/ui";
describe("event callbacks", () => {
const h = window.h;
let excalidrawAPI: ExcalidrawImperativeAPI;
const mouse = new Pointer("mouse");
beforeEach(async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw ref={(api) => excalidrawAPIPromise.resolve(api as any)} />,
);
excalidrawAPI = await excalidrawAPIPromise;
});
it("should trigger onChange on render", async () => {
const onChange = vi.fn();
const origBackgroundColor = h.state.viewBackgroundColor;
excalidrawAPI.onChange(onChange);
excalidrawAPI.updateScene({ appState: { viewBackgroundColor: "red" } });
expect(onChange).toHaveBeenCalledWith(
// elements
[],
// appState
expect.objectContaining({
viewBackgroundColor: "red",
}),
// files
{},
);
expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe(
origBackgroundColor,
);
});
it("should trigger onPointerDown/onPointerUp on canvas pointerDown/pointerUp", async () => {
const onPointerDown = vi.fn();
const onPointerUp = vi.fn();
excalidrawAPI.onPointerDown(onPointerDown);
excalidrawAPI.onPointerUp(onPointerUp);
mouse.downAt(100);
expect(onPointerDown).toHaveBeenCalledTimes(1);
expect(onPointerUp).not.toHaveBeenCalled();
mouse.up();
expect(onPointerDown).toHaveBeenCalledTimes(1);
expect(onPointerUp).toHaveBeenCalledTimes(1);
});
});

View File

@ -607,6 +607,8 @@ export type PointerDownState = Readonly<{
};
}>;
type UnsubscribeCallback = () => void;
export type ExcalidrawImperativeAPI = {
updateScene: InstanceType<typeof App>["updateScene"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
@ -637,6 +639,27 @@ export type ExcalidrawImperativeAPI = {
* used in conjunction with view mode (props.viewModeEnabled).
*/
updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
onChange: (
callback: (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => void,
) => UnsubscribeCallback;
onPointerDown: (
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: React.PointerEvent<HTMLElement>,
) => void,
) => UnsubscribeCallback;
onPointerUp: (
callback: (
activeTool: AppState["activeTool"],
pointerDownState: PointerDownState,
event: PointerEvent,
) => void,
) => UnsubscribeCallback;
};
export type Device = Readonly<{