Browse Source

refactor: editor events sub/unsub refactor (#7483)

David Luzar 1 year ago
parent
commit
c72e853c85

+ 91 - 113
packages/excalidraw/components/App.tsx

@@ -269,6 +269,7 @@ import {
   isTestEnv,
   isTestEnv,
   easeOut,
   easeOut,
   updateStable,
   updateStable,
+  addEventListener,
 } from "../utils";
 } from "../utils";
 import {
 import {
   createSrcDoc,
   createSrcDoc,
@@ -559,6 +560,8 @@ class App extends React.Component<AppProps, AppState> {
     [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
     [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
   >();
   >();
 
 
+  onRemoveEventListenersEmitter = new Emitter<[]>();
+
   constructor(props: AppProps) {
   constructor(props: AppProps) {
     super(props);
     super(props);
     const defaultAppState = getDefaultAppState();
     const defaultAppState = getDefaultAppState();
@@ -2390,63 +2393,6 @@ class App extends React.Component<AppProps, AppState> {
     this.setState({});
     this.setState({});
   });
   });
 
 
-  private removeEventListeners() {
-    document.removeEventListener(EVENT.POINTER_UP, this.removePointer);
-    document.removeEventListener(EVENT.COPY, this.onCopy);
-    document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
-    document.removeEventListener(EVENT.CUT, this.onCut);
-    this.excalidrawContainerRef.current?.removeEventListener(
-      EVENT.WHEEL,
-      this.onWheel,
-    );
-    this.nearestScrollableContainer?.removeEventListener(
-      EVENT.SCROLL,
-      this.onScroll,
-    );
-    document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
-    document.removeEventListener(
-      EVENT.MOUSE_MOVE,
-      this.updateCurrentCursorPosition,
-      false,
-    );
-    document.removeEventListener(EVENT.KEYUP, this.onKeyUp);
-    window.removeEventListener(EVENT.RESIZE, this.onResize, false);
-    window.removeEventListener(EVENT.UNLOAD, this.onUnload, false);
-    window.removeEventListener(EVENT.BLUR, this.onBlur, false);
-    this.excalidrawContainerRef.current?.removeEventListener(
-      EVENT.DRAG_OVER,
-      this.disableEvent,
-      false,
-    );
-    this.excalidrawContainerRef.current?.removeEventListener(
-      EVENT.DROP,
-      this.disableEvent,
-      false,
-    );
-
-    document.removeEventListener(
-      EVENT.GESTURE_START,
-      this.onGestureStart as any,
-      false,
-    );
-    document.removeEventListener(
-      EVENT.GESTURE_CHANGE,
-      this.onGestureChange as any,
-      false,
-    );
-    document.removeEventListener(
-      EVENT.GESTURE_END,
-      this.onGestureEnd as any,
-      false,
-    );
-    document.removeEventListener(
-      EVENT.FULLSCREENCHANGE,
-      this.onFullscreenChange,
-    );
-
-    window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
-  }
-
   /** generally invoked only if fullscreen was invoked programmatically */
   /** generally invoked only if fullscreen was invoked programmatically */
   private onFullscreenChange = () => {
   private onFullscreenChange = () => {
     if (
     if (
@@ -2460,76 +2406,108 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
   };
   };
 
 
+  private removeEventListeners() {
+    this.onRemoveEventListenersEmitter.trigger();
+  }
+
   private addEventListeners() {
   private addEventListeners() {
+    // remove first as we can add event listeners multiple times
     this.removeEventListeners();
     this.removeEventListeners();
-    window.addEventListener(EVENT.MESSAGE, this.onWindowMessage, false);
-    document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553
-    document.addEventListener(EVENT.COPY, this.onCopy);
-    this.excalidrawContainerRef.current?.addEventListener(
-      EVENT.WHEEL,
-      this.onWheel,
-      { passive: false },
-    );
+
+    // -------------------------------------------------------------------------
+    //                        view+edit mode listeners
+    // -------------------------------------------------------------------------
 
 
     if (this.props.handleKeyboardGlobally) {
     if (this.props.handleKeyboardGlobally) {
-      document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
+      this.onRemoveEventListenersEmitter.once(
+        addEventListener(document, EVENT.KEYDOWN, this.onKeyDown, false),
+      );
     }
     }
-    document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
-    document.addEventListener(
-      EVENT.MOUSE_MOVE,
-      this.updateCurrentCursorPosition,
-    );
-    // rerender text elements on font load to fix #637 && #1553
-    document.fonts?.addEventListener?.("loadingdone", (event) => {
-      const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
-      this.fonts.onFontsLoaded(loadedFontFaces);
-    });
 
 
-    // Safari-only desktop pinch zoom
-    document.addEventListener(
-      EVENT.GESTURE_START,
-      this.onGestureStart as any,
-      false,
-    );
-    document.addEventListener(
-      EVENT.GESTURE_CHANGE,
-      this.onGestureChange as any,
-      false,
-    );
-    document.addEventListener(
-      EVENT.GESTURE_END,
-      this.onGestureEnd as any,
-      false,
+    this.onRemoveEventListenersEmitter.once(
+      addEventListener(
+        this.excalidrawContainerRef.current,
+        EVENT.WHEEL,
+        this.onWheel,
+        { passive: false },
+      ),
+      addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
+      addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553
+      addEventListener(document, EVENT.COPY, this.onCopy),
+      addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
+      addEventListener(
+        document,
+        EVENT.MOUSE_MOVE,
+        this.updateCurrentCursorPosition,
+      ),
+      // rerender text elements on font load to fix #637 && #1553
+      addEventListener(document.fonts, "loadingdone", (event) => {
+        const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces;
+        this.fonts.onFontsLoaded(loadedFontFaces);
+      }),
+      // Safari-only desktop pinch zoom
+      addEventListener(
+        document,
+        EVENT.GESTURE_START,
+        this.onGestureStart as any,
+        false,
+      ),
+      addEventListener(
+        document,
+        EVENT.GESTURE_CHANGE,
+        this.onGestureChange as any,
+        false,
+      ),
+      addEventListener(
+        document,
+        EVENT.GESTURE_END,
+        this.onGestureEnd as any,
+        false,
+      ),
     );
     );
+
     if (this.state.viewModeEnabled) {
     if (this.state.viewModeEnabled) {
       return;
       return;
     }
     }
 
 
-    document.addEventListener(EVENT.FULLSCREENCHANGE, this.onFullscreenChange);
-    document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
-    document.addEventListener(EVENT.CUT, this.onCut);
+    // -------------------------------------------------------------------------
+    //                        edit-mode listeners only
+    // -------------------------------------------------------------------------
+
+    this.onRemoveEventListenersEmitter.once(
+      addEventListener(
+        document,
+        EVENT.FULLSCREENCHANGE,
+        this.onFullscreenChange,
+      ),
+      addEventListener(document, EVENT.PASTE, this.pasteFromClipboard),
+      addEventListener(document, EVENT.CUT, this.onCut),
+      addEventListener(window, EVENT.RESIZE, this.onResize, false),
+      addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
+      addEventListener(window, EVENT.BLUR, this.onBlur, false),
+      addEventListener(
+        this.excalidrawContainerRef.current,
+        EVENT.DRAG_OVER,
+        this.disableEvent,
+        false,
+      ),
+      addEventListener(
+        this.excalidrawContainerRef.current,
+        EVENT.DROP,
+        this.disableEvent,
+        false,
+      ),
+    );
+
     if (this.props.detectScroll) {
     if (this.props.detectScroll) {
-      this.nearestScrollableContainer = getNearestScrollableContainer(
-        this.excalidrawContainerRef.current!,
-      );
-      this.nearestScrollableContainer.addEventListener(
-        EVENT.SCROLL,
-        this.onScroll,
+      this.onRemoveEventListenersEmitter.once(
+        addEventListener(
+          getNearestScrollableContainer(this.excalidrawContainerRef.current!),
+          EVENT.SCROLL,
+          this.onScroll,
+        ),
       );
       );
     }
     }
-    window.addEventListener(EVENT.RESIZE, this.onResize, false);
-    window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
-    window.addEventListener(EVENT.BLUR, this.onBlur, false);
-    this.excalidrawContainerRef.current?.addEventListener(
-      EVENT.DRAG_OVER,
-      this.disableEvent,
-      false,
-    );
-    this.excalidrawContainerRef.current?.addEventListener(
-      EVENT.DROP,
-      this.disableEvent,
-      false,
-    );
   }
   }
 
 
   componentDidUpdate(prevProps: AppProps, prevState: AppState) {
   componentDidUpdate(prevProps: AppProps, prevState: AppState) {

+ 14 - 1
packages/excalidraw/emitter.ts

@@ -1,3 +1,5 @@
+import { UnsubscribeCallback } from "./types";
+
 type Subscriber<T extends any[]> = (...payload: T) => void;
 type Subscriber<T extends any[]> = (...payload: T) => void;
 
 
 export class Emitter<T extends any[] = []> {
 export class Emitter<T extends any[] = []> {
@@ -15,7 +17,7 @@ export class Emitter<T extends any[] = []> {
    *
    *
    * @returns unsubscribe function
    * @returns unsubscribe function
    */
    */
-  on(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
+  on(...handlers: Subscriber<T>[] | Subscriber<T>[][]): UnsubscribeCallback {
     const _handlers = handlers
     const _handlers = handlers
       .flat()
       .flat()
       .filter((item) => typeof item === "function");
       .filter((item) => typeof item === "function");
@@ -25,6 +27,17 @@ export class Emitter<T extends any[] = []> {
     return () => this.off(_handlers);
     return () => this.off(_handlers);
   }
   }
 
 
+  once(...handlers: Subscriber<T>[] | Subscriber<T>[][]): UnsubscribeCallback {
+    const _handlers = handlers
+      .flat()
+      .filter((item) => typeof item === "function");
+
+    _handlers.push(() => detach());
+
+    const detach = this.on(..._handlers);
+    return detach;
+  }
+
   off(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
   off(...handlers: Subscriber<T>[] | Subscriber<T>[][]) {
     const _handlers = handlers.flat();
     const _handlers = handlers.flat();
     this.subscribers = this.subscribers.filter(
     this.subscribers = this.subscribers.filter(

+ 0 - 13
packages/excalidraw/global.d.ts

@@ -1,16 +1,3 @@
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-interface Document {
-  fonts?: {
-    ready?: Promise<void>;
-    check?: (font: string, text?: string) => boolean;
-    load?: (font: string, text?: string) => Promise<FontFace[]>;
-    addEventListener?(
-      type: "loading" | "loadingdone" | "loadingerror",
-      listener: (this: Document, ev: Event) => any,
-    ): void;
-  };
-}
-
 interface Window {
 interface Window {
   ClipboardItem: any;
   ClipboardItem: any;
   __EXCALIDRAW_SHA__: string | undefined;
   __EXCALIDRAW_SHA__: string | undefined;

+ 1 - 1
packages/excalidraw/types.ts

@@ -641,7 +641,7 @@ export type PointerDownState = Readonly<{
   };
   };
 }>;
 }>;
 
 
-type UnsubscribeCallback = () => void;
+export type UnsubscribeCallback = () => void;
 
 
 export type ExcalidrawImperativeAPI = {
 export type ExcalidrawImperativeAPI = {
   updateScene: InstanceType<typeof App>["updateScene"];
   updateScene: InstanceType<typeof App>["updateScene"];

+ 80 - 1
packages/excalidraw/utils.ts

@@ -7,7 +7,13 @@ import {
   WINDOWS_EMOJI_FALLBACK_FONT,
   WINDOWS_EMOJI_FALLBACK_FONT,
 } from "./constants";
 } from "./constants";
 import { FontFamilyValues, FontString } from "./element/types";
 import { FontFamilyValues, FontString } from "./element/types";
-import { ActiveTool, AppState, ToolType, Zoom } from "./types";
+import {
+  ActiveTool,
+  AppState,
+  ToolType,
+  UnsubscribeCallback,
+  Zoom,
+} from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { unstable_batchedUpdates } from "react-dom";
 import { ResolutionType } from "./utility-types";
 import { ResolutionType } from "./utility-types";
 import React from "react";
 import React from "react";
@@ -992,3 +998,76 @@ export const updateStable = <T extends any[] | Record<string, any>>(
   }
   }
   return nextValue;
   return nextValue;
 };
 };
+
+// Window
+export function addEventListener<K extends keyof WindowEventMap>(
+  target: Window & typeof globalThis,
+  type: K,
+  listener: (this: Window, ev: WindowEventMap[K]) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+export function addEventListener(
+  target: Window & typeof globalThis,
+  type: string,
+  listener: (this: Window, ev: Event) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+// Document
+export function addEventListener<K extends keyof DocumentEventMap>(
+  target: Document,
+  type: K,
+  listener: (this: Document, ev: DocumentEventMap[K]) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+export function addEventListener(
+  target: Document,
+  type: string,
+  listener: (this: Document, ev: Event) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+// FontFaceSet (document.fonts)
+export function addEventListener<K extends keyof FontFaceSetEventMap>(
+  target: FontFaceSet,
+  type: K,
+  listener: (this: FontFaceSet, ev: FontFaceSetEventMap[K]) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+// HTMLElement / mix
+export function addEventListener<K extends keyof HTMLElementEventMap>(
+  target:
+    | Document
+    | (Window & typeof globalThis)
+    | HTMLElement
+    | undefined
+    | null
+    | false,
+  type: K,
+  listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback;
+// implem
+export function addEventListener(
+  /**
+   * allows for falsy values so you don't have to type check when adding
+   * event listeners to optional elements
+   */
+  target:
+    | Document
+    | (Window & typeof globalThis)
+    | FontFaceSet
+    | HTMLElement
+    | undefined
+    | null
+    | false,
+  type: keyof WindowEventMap | keyof DocumentEventMap | string,
+  listener: (ev: Event) => any,
+  options?: boolean | AddEventListenerOptions,
+): UnsubscribeCallback {
+  if (!target) {
+    return () => {};
+  }
+  target?.addEventListener?.(type, listener, options);
+  return () => {
+    target?.removeEventListener?.(type, listener, options);
+  };
+}