aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/hooks/useOutsideClick.ts
blob: da9a54d08e625813b3f5e4f017d0237fe1e4c12b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { useEffect } from "react";
import { EVENT } from "../constants";

export function useOutsideClick<T extends HTMLElement>(
  ref: React.RefObject<T | null>,
  /** if performance is of concern, memoize the callback */
  callback: (event: Event) => void,
  /**
   * Optional callback which is called on every click.
   *
   * Should return `true` if click should be considered as inside the container,
   * and `false` if it falls outside and should call the `callback`.
   *
   * Returning `true` overrides the default behavior and `callback` won't be
   * called.
   *
   * Returning `undefined` will fallback to the default behavior.
   */
  isInside?: (
    event: Event & { target: HTMLElement },
    /** the element of the passed ref */
    container: T,
  ) => boolean | undefined,
) {
  useEffect(() => {
    function onOutsideClick(event: Event) {
      const _event = event as Event & { target: T };

      if (!ref.current) {
        return;
      }

      const isInsideOverride = isInside?.(_event, ref.current);

      if (isInsideOverride === true) {
        return;
      } else if (isInsideOverride === false) {
        return callback(_event);
      }

      // clicked element is in the descenendant of the target container
      if (
        ref.current.contains(_event.target) ||
        // target is detached from DOM (happens when the element is removed
        // on a pointerup event fired *before* this handler's pointerup is
        // dispatched)
        !document.documentElement.contains(_event.target)
      ) {
        return;
      }

      const isClickOnRadixPortal =
        _event.target.closest("[data-radix-portal]") ||
        // when radix popup is in "modal" mode, it disables pointer events on
        // the `body` element, so the target element is going to be the `html`
        // (note: this won't work if we selectively re-enable pointer events on
        // specific elements as we do with navbar or excalidraw UI elements)
        (_event.target === document.documentElement &&
          document.body.style.pointerEvents === "none");

      // if clicking on radix portal, assume it's a popup that
      // should be considered as part of the UI. Obviously this is a terrible
      // hack you can end up click on radix popups that outside the tree,
      // but it works for most cases and the downside is minimal for now
      if (isClickOnRadixPortal) {
        return;
      }

      // clicking on a container that ignores outside clicks
      if (_event.target.closest("[data-prevent-outside-click]")) {
        return;
      }

      callback(_event);
    }

    // note: don't use `click` because it often reports incorrect `event.target`
    document.addEventListener(EVENT.POINTER_DOWN, onOutsideClick);
    document.addEventListener(EVENT.TOUCH_START, onOutsideClick);

    return () => {
      document.removeEventListener(EVENT.POINTER_DOWN, onOutsideClick);
      document.removeEventListener(EVENT.TOUCH_START, onOutsideClick);
    };
  }, [ref, callback, isInside]);
}