feat: add onChange
, onPointerDown
, onPointerUp
api subs (#7154)
This commit is contained in:
parent
9eb89f9960
commit
e7cc2337ea
@ -374,6 +374,7 @@ import {
|
|||||||
resetCursor,
|
resetCursor,
|
||||||
setCursorForShape,
|
setCursorForShape,
|
||||||
} from "../cursor";
|
} from "../cursor";
|
||||||
|
import { Emitter } from "../emitter";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@ -505,6 +506,30 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
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) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
@ -568,6 +593,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
resetCursor: this.resetCursor,
|
resetCursor: this.resetCursor,
|
||||||
updateFrameRendering: this.updateFrameRendering,
|
updateFrameRendering: this.updateFrameRendering,
|
||||||
toggleSidebar: this.toggleSidebar,
|
toggleSidebar: this.toggleSidebar,
|
||||||
|
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||||
|
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||||
|
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||||
} as const;
|
} as const;
|
||||||
if (typeof excalidrawRef === "function") {
|
if (typeof excalidrawRef === "function") {
|
||||||
excalidrawRef(api);
|
excalidrawRef(api);
|
||||||
@ -1750,6 +1778,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.destroy();
|
this.scene.destroy();
|
||||||
this.library.destroy();
|
this.library.destroy();
|
||||||
this.laserPathManager.destroy();
|
this.laserPathManager.destroy();
|
||||||
|
this.onChangeEmitter.destroy();
|
||||||
ShapeCache.destroy();
|
ShapeCache.destroy();
|
||||||
SnapCache.destroy();
|
SnapCache.destroy();
|
||||||
clearTimeout(touchTimeout);
|
clearTimeout(touchTimeout);
|
||||||
@ -2034,6 +2063,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
this.files,
|
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.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
|
||||||
|
this.onPointerDownEmitter.trigger(
|
||||||
|
this.state.activeTool,
|
||||||
|
pointerDownState,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
|
||||||
const onPointerMove =
|
const onPointerMove =
|
||||||
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
this.onPointerMoveFromPointerDownHandler(pointerDownState);
|
||||||
@ -6551,6 +6590,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ pendingImageElementId: null });
|
this.setState({ pendingImageElementId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.onPointerUpEmitter.trigger(
|
||||||
|
this.state.activeTool,
|
||||||
|
pointerDownState,
|
||||||
|
childEvent,
|
||||||
|
);
|
||||||
|
|
||||||
if (draggingElement?.type === "freedraw") {
|
if (draggingElement?.type === "freedraw") {
|
||||||
const pointerCoords = viewportCoordsToSceneCoords(
|
const pointerCoords = viewportCoordsToSceneCoords(
|
||||||
childEvent,
|
childEvent,
|
||||||
|
47
src/emitter.ts
Normal file
47
src/emitter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
58
src/tests/packages/events.test.tsx
Normal file
58
src/tests/packages/events.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
23
src/types.ts
23
src/types.ts
@ -607,6 +607,8 @@ export type PointerDownState = Readonly<{
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type UnsubscribeCallback = () => void;
|
||||||
|
|
||||||
export type ExcalidrawImperativeAPI = {
|
export type ExcalidrawImperativeAPI = {
|
||||||
updateScene: InstanceType<typeof App>["updateScene"];
|
updateScene: InstanceType<typeof App>["updateScene"];
|
||||||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||||||
@ -637,6 +639,27 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
* used in conjunction with view mode (props.viewModeEnabled).
|
* used in conjunction with view mode (props.viewModeEnabled).
|
||||||
*/
|
*/
|
||||||
updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
|
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<{
|
export type Device = Readonly<{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user