diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 71d6ea82..3163f7ce 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -269,6 +269,7 @@ import { isTestEnv, easeOut, updateStable, + addEventListener, } from "../utils"; import { createSrcDoc, @@ -559,6 +560,8 @@ class App extends React.Component { [scrollX: number, scrollY: number, zoom: AppState["zoom"]] >(); + onRemoveEventListenersEmitter = new Emitter<[]>(); + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); @@ -2390,63 +2393,6 @@ class App extends React.Component { 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 */ private onFullscreenChange = () => { if ( @@ -2460,76 +2406,108 @@ class App extends React.Component { } }; + private removeEventListeners() { + this.onRemoveEventListenersEmitter.trigger(); + } + private addEventListeners() { + // remove first as we can add event listeners multiple times 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) { - 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) { 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) { - 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) { diff --git a/packages/excalidraw/emitter.ts b/packages/excalidraw/emitter.ts index 5b1cdd0a..cb86670b 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/excalidraw/emitter.ts @@ -1,3 +1,5 @@ +import { UnsubscribeCallback } from "./types"; + type Subscriber = (...payload: T) => void; export class Emitter { @@ -15,7 +17,7 @@ export class Emitter { * * @returns unsubscribe function */ - on(...handlers: Subscriber[] | Subscriber[][]) { + on(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { const _handlers = handlers .flat() .filter((item) => typeof item === "function"); @@ -25,6 +27,17 @@ export class Emitter { return () => this.off(_handlers); } + once(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { + const _handlers = handlers + .flat() + .filter((item) => typeof item === "function"); + + _handlers.push(() => detach()); + + const detach = this.on(..._handlers); + return detach; + } + off(...handlers: Subscriber[] | Subscriber[][]) { const _handlers = handlers.flat(); this.subscribers = this.subscribers.filter( diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 76730c8d..49e5eac1 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -1,16 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -interface Document { - fonts?: { - ready?: Promise; - check?: (font: string, text?: string) => boolean; - load?: (font: string, text?: string) => Promise; - addEventListener?( - type: "loading" | "loadingdone" | "loadingerror", - listener: (this: Document, ev: Event) => any, - ): void; - }; -} - interface Window { ClipboardItem: any; __EXCALIDRAW_SHA__: string | undefined; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 854a2751..2ba9bd68 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -641,7 +641,7 @@ export type PointerDownState = Readonly<{ }; }>; -type UnsubscribeCallback = () => void; +export type UnsubscribeCallback = () => void; export type ExcalidrawImperativeAPI = { updateScene: InstanceType["updateScene"]; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 4278f36f..8b39ba6b 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -7,7 +7,13 @@ import { WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; 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 { ResolutionType } from "./utility-types"; import React from "react"; @@ -992,3 +998,76 @@ export const updateStable = >( } return nextValue; }; + +// Window +export function addEventListener( + 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( + 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( + target: FontFaceSet, + type: K, + listener: (this: FontFaceSet, ev: FontFaceSetEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// HTMLElement / mix +export function addEventListener( + 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); + }; +}