import * as React from "react"; import debounce from "lodash/debounce.js"; import { deepEqual } from "./support.js"; export function useEventListener( eventName: K, handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, element: HTMLElement | Window | null, passive: boolean, capture = false ) { // Create a ref that stores handler const savedHandler = React.useRef<(this: HTMLElement, ev: HTMLElementEventMap[K]) => any>(); // Update ref.current value if handler changes. // This allows our effect below to always get latest handler ... // ... without us needing to pass it in effect deps array ... // ... and potentially cause effect to re-run every render. savedHandler.current = handler; React.useEffect( () => { // Make sure element supports addEventListener if (element === null || element.addEventListener === undefined) return; const el = element as HTMLElement; // Create event listener that calls handler function stored in ref const eventListener = (event: HTMLElementEventMap[K]) => { savedHandler.current?.call(el, event); }; el.addEventListener(eventName, eventListener, { passive, capture }); // Remove event listener on cleanup return () => { el.removeEventListener(eventName, eventListener, { capture }); }; }, [eventName, element, passive, capture] // Re-run if eventName or element changes ); } export function whenDefined(obj: any, result: T) { return obj === undefined ? undefined : result; } const PI = Math.PI; export function degreesToRadians(degrees: number) { return (degrees * PI) / 180; } export const getSquareBB = (posX: number, posY: number, squareSideLength: number) => ({ x1: posX - squareSideLength / 2, y1: posY - squareSideLength / 2, x2: posX + squareSideLength / 2, y2: posY + squareSideLength / 2, }); export const getSquareXPosFromAlign = ( alignment: "left" | "center" | "right", containerX: number, containerWidth: number, horizontalPadding: number, squareWidth: number ) => { switch (alignment) { case "left": return Math.floor(containerX) + horizontalPadding + squareWidth / 2; case "center": return Math.floor(containerX + containerWidth / 2); case "right": return Math.floor(containerX + containerWidth) - horizontalPadding - squareWidth / 2; } }; export const getSquareWidth = (maxSize: number, containerHeight: number, verticalPadding: number) => Math.min(maxSize, containerHeight - verticalPadding * 2); type BoundingBox = { x1: number; y1: number; x2: number; y2: number }; export const pointIsWithinBB = (x: number, y: number, bb: BoundingBox) => bb.x1 <= x && x <= bb.x2 && bb.y1 <= y && y <= bb.y2; /** * The input provided to a sprite function. * * @category Columns */ export interface SpriteProps { fgColor: string; bgColor: string; } export const EditPencil: React.FunctionComponent> = (props: Partial) => { const fg = props.fgColor ?? "currentColor"; return ( ); }; export const Checkmark: React.FunctionComponent> = (props: Partial) => { const fg = props.fgColor ?? "currentColor"; return ( ); }; export function useDebouncedMemo(factory: () => T, deps: React.DependencyList | undefined, time: number): T { const [state, setState] = React.useState(factory); const mountedRef = React.useRef(true); React.useEffect( () => () => { mountedRef.current = false; }, [] ); const debouncedSetState = React.useRef( debounce(x => { if (mountedRef.current) { setState(x); } }, time) ); React.useLayoutEffect(() => { if (mountedRef.current) { debouncedSetState.current(() => factory()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return state; } // Shamelessly inline direction to avoid conflicts with 1.0 and 2.0. const rtlRange = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC"; const ltrRange = "A-Za-z\u00C0-\u00D6\u00D8-\u00F6" + "\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C" + "\uFE00-\uFE6F\uFEFD-\uFFFF"; /* eslint-disable no-misleading-character-class */ const rtl = new RegExp("^[^" + ltrRange + "]*[" + rtlRange + "]"); /* eslint-enable no-misleading-character-class */ export function direction(value: string): "rtl" | "not-rtl" { return rtl.test(value) ? "rtl" : "not-rtl"; } let scrollbarWidthCache: number | undefined = undefined; export function getScrollBarWidth(): number { if (typeof document === "undefined") return 0; if (scrollbarWidthCache !== undefined) return scrollbarWidthCache; const inner = document.createElement("p"); inner.style.width = "100%"; inner.style.height = "200px"; const outer = document.createElement("div"); outer.id = "testScrollbar"; outer.style.position = "absolute"; outer.style.top = "0px"; outer.style.left = "0px"; outer.style.visibility = "hidden"; outer.style.width = "200px"; outer.style.height = "150px"; outer.style.overflow = "hidden"; outer.append(inner); document.body.append(outer); const w1 = inner.offsetWidth; outer.style.overflow = "scroll"; let w2 = inner.offsetWidth; if (w1 === w2) { w2 = outer.clientWidth; } outer.remove(); scrollbarWidthCache = w1 - w2; return scrollbarWidthCache; } // Dear future reader, // This dumb hook is to make sure if the inputState changes, that effectively behaves like an instant "setState" call. // This is useful in a wide variety of situations. I'm too dumb to know if this is a good idea or a really dumb one. // I can't tell. It's like poes law but for code. // // I'm sorry. const empty = Symbol(); export function useStateWithReactiveInput(inputState: T): [T, React.Dispatch>, () => void] { // When [0] is not empty we will return it, [1] is always the last value we saw const inputStateRef = React.useRef<[T | typeof empty, T]>([empty, inputState]); if (inputStateRef.current[1] !== inputState) { // it changed, we must use thee! inputStateRef.current[0] = inputState; } inputStateRef.current[1] = inputState; const [state, setState] = React.useState(inputState); // crimes against humanity here const [, forceRender] = React.useState<{} | undefined>(); const setStateOuter = React.useCallback(nv => { // this takes care of the case where the inputState was set, then setState gets called again but back to what // the state was before the inputState changed. Since the useState effect wont trigger a render in this case // we need to be very naughty and force it to see the change. Technically this may not be needed some chunk of // the time (in fact most of it) but checking for it is likely to be more expensive than just over-doing it const s = inputStateRef.current[0]; if (s !== empty) { nv = typeof nv === "function" ? (nv as (pv: T) => T)(s) : nv; if (nv === s) return; // they are setting it to what the inputState is anyway so we can just do nothing } if (s !== empty) forceRender({}); setState(pv => { if (typeof nv === "function") { return (nv as (pv: T) => T)(s === empty ? pv : s); } return nv; }); inputStateRef.current[0] = empty; }, []); const onEmpty = React.useCallback(() => { inputStateRef.current[0] = empty; forceRender({}); }, []); return [inputStateRef.current[0] === empty ? state : inputStateRef.current[0], setStateOuter, onEmpty]; } export function makeAccessibilityStringForArray(arr: readonly string[]): string { // this is basically just .join(", ") but checks to make sure it is not going to allocate // a string that is so large it might crash the browser if (arr.length === 0) { return ""; } let index = 0; let count = 0; for (const str of arr) { count += str.length; if (count > 10_000) break; index++; } return arr.slice(0, index).join(", "); } export function useDeepMemo(value: T): T { const ref = React.useRef(value); if (!deepEqual(value, ref.current)) { ref.current = value; } return ref.current; }