From e7cc2337eac70278ebc4aee830cf3cac91a75731 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 20 Oct 2023 13:08:22 +0200 Subject: [PATCH] feat: add `onChange`, `onPointerDown`, `onPointerUp` api subs (#7154) --- src/components/App.tsx | 45 +++++++++++++++++++++++ src/emitter.ts | 47 ++++++++++++++++++++++++ src/tests/packages/events.test.tsx | 58 ++++++++++++++++++++++++++++++ src/types.ts | 23 ++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 src/emitter.ts create mode 100644 src/tests/packages/events.test.tsx diff --git a/src/components/App.tsx b/src/components/App.tsx index 9aa23d62..44ecdb4d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -374,6 +374,7 @@ import { resetCursor, setCursorForShape, } from "../cursor"; +import { Emitter } from "../emitter"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -505,6 +506,30 @@ class App extends React.Component { 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, + ] + >(); + + 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 { 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 { 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 { this.state, this.files, ); + this.onChangeEmitter.trigger( + this.scene.getElementsIncludingDeleted(), + this.state, + this.files, + ); } } @@ -4699,6 +4733,11 @@ class App extends React.Component { } 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 { this.setState({ pendingImageElementId: null }); } + this.onPointerUpEmitter.trigger( + this.state.activeTool, + pointerDownState, + childEvent, + ); + if (draggingElement?.type === "freedraw") { const pointerCoords = viewportCoordsToSceneCoords( childEvent, diff --git a/src/emitter.ts b/src/emitter.ts new file mode 100644 index 00000000..5b1cdd0a --- /dev/null +++ b/src/emitter.ts @@ -0,0 +1,47 @@ +type Subscriber = (...payload: T) => void; + +export class Emitter { + public subscribers: Subscriber[] = []; + 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[] | Subscriber[][]) { + const _handlers = handlers + .flat() + .filter((item) => typeof item === "function"); + + this.subscribers.push(..._handlers); + + return () => this.off(_handlers); + } + + off(...handlers: Subscriber[] | Subscriber[][]) { + 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; + } +} diff --git a/src/tests/packages/events.test.tsx b/src/tests/packages/events.test.tsx new file mode 100644 index 00000000..231af579 --- /dev/null +++ b/src/tests/packages/events.test.tsx @@ -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(); + await render( + 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); + }); +}); diff --git a/src/types.ts b/src/types.ts index 8b05ba40..c6fe765c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -607,6 +607,8 @@ export type PointerDownState = Readonly<{ }; }>; +type UnsubscribeCallback = () => void; + export type ExcalidrawImperativeAPI = { updateScene: InstanceType["updateScene"]; updateLibrary: InstanceType["updateLibrary"]; @@ -637,6 +639,27 @@ export type ExcalidrawImperativeAPI = { * used in conjunction with view mode (props.viewModeEnabled). */ updateFrameRendering: InstanceType["updateFrameRendering"]; + onChange: ( + callback: ( + elements: readonly ExcalidrawElement[], + appState: AppState, + files: BinaryFiles, + ) => void, + ) => UnsubscribeCallback; + onPointerDown: ( + callback: ( + activeTool: AppState["activeTool"], + pointerDownState: PointerDownState, + event: React.PointerEvent, + ) => void, + ) => UnsubscribeCallback; + onPointerUp: ( + callback: ( + activeTool: AppState["activeTool"], + pointerDownState: PointerDownState, + event: PointerEvent, + ) => void, + ) => UnsubscribeCallback; }; export type Device = Readonly<{