import { useState, useEffect, useCallback, useLayoutEffect, useRef, } from "react"; import { useDispatch, useSelector } from "react-redux"; import * as Utils from "./utils"; import { Action, Thunk, AppState } from "./types"; interface Size { width: number; height: number; } export function useUnmountedRef(): { current: boolean } { const unmountedRef = useRef(false); useEffect(() => { return () => { unmountedRef.current = true; }; }, []); return unmountedRef; } export function usePromiseValueOrNull(propValue: Promise): T | null { const [value, setValue] = useState(null); useEffect(() => { let disposed = false; propValue.then(resolvedValue => { if (disposed) { return; } setValue(resolvedValue); }); return () => { disposed = true; }; }, [propValue]); return value; } export function useScreenSize() { const [size] = useState(Utils.getScreenSize()); // TODO: We could subscribe to screen size changes. return size; } export function useWindowSize() { const [size, setSize] = useState(Utils.getWindowSize()); const handler = useCallback( Utils.throttle(() => { setSize(Utils.getWindowSize()); }, 100) as () => void, [] ); useEffect(() => { window.addEventListener("resize", handler); return () => { window.removeEventListener("resize", handler); }; }, [handler]); return size; } const cursorPositionRef = { current: { pageX: 0, pageY: 0 } }; window.document.addEventListener("mousemove", ({ pageX, pageY }) => { cursorPositionRef.current = { pageX, pageY }; }); // We use a single global event listener because there is no way to get the // mouse position aside from an event. Ideally we could create/clean up the // event listener in the hook, but in the case where we want to check the cursor // position on mount, that we wouldn't have had time to capture an event. function useCursorPositionRef() { return cursorPositionRef; } // CSS hover state is not respected if the cursor is already over the node when // it is added to the DOM. This hook allows your component to know its hover // state on mount without waiting for the mouse to move. // https://stackoverflow.com/a/13259049/1263117 export function useIsHovered() { const cursorRef = useCursorPositionRef(); const [hover, setHover] = useState(false); const [node, setNode] = useState(null); useLayoutEffect(() => { if (node == null) { setHover(false); return; } const domRect = node.getBoundingClientRect(); const { pageX, pageY } = cursorRef.current; setHover( pageX >= domRect.left && pageX <= domRect.right && pageY >= domRect.top && pageY <= domRect.bottom ); const enter = () => setHover(true); const leave = () => setHover(false); node.addEventListener("mouseenter", enter); node.addEventListener("mouseleave", leave); return () => { node.removeEventListener("mouseenter", enter); node.removeEventListener("mouseleave", leave); }; }, [node, cursorRef]); return { ref: setNode, hover }; } export function useOnClickAway( ref: Element | null, callback: null | (() => void) ) { useEffect(() => { if (ref == null || callback == null) { return; } const handleClickOut = (ee: MouseEvent) => { const clickOutTarget = ee.target; if (!(clickOutTarget instanceof Element)) { // TypeScript doesn't realize this will always be true return; } if (ref.contains(clickOutTarget)) { return; } // If the click is _not_ inside the menu. callback(); window.document.removeEventListener("click", handleClickOut, { capture: true, }); }; window.document.addEventListener("click", handleClickOut, { capture: true, }); return () => { window.document.removeEventListener("click", handleClickOut, { capture: true, }); }; }, [ref, callback]); } // TODO: Return useSelector directly and apply the type without wrapping export function useTypedSelector(selector: (state: AppState) => T): T { return useSelector(selector); } export function useActionCreator Action | Thunk>( actionCreator: T ): (...funcArgs: Parameters) => void { const dispatch = useDispatch(); return useCallback((...args) => dispatch(actionCreator(...args)), [ dispatch, actionCreator, ]); } export function useTypedDispatch(): (action: Action | Thunk) => void { return useDispatch(); }