Browse Source

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

David Luzar 1 year ago
parent
commit
e7cc2337ea
4 changed files with 173 additions and 0 deletions
  1. 45 0
      src/components/App.tsx
  2. 47 0
      src/emitter.ts
  3. 58 0
      src/tests/packages/events.test.tsx
  4. 23 0
      src/types.ts

+ 45 - 0
src/components/App.tsx

@@ -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 - 0
src/emitter.ts

@@ -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 - 0
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<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 - 0
src/types.ts

@@ -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<{