/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import throttle from 'lodash.throttle'; import { useCallback, useEffect, useLayoutEffect, useReducer, useState, useContext, } from 'react'; import { localStorageGetItem, localStorageSetItem, } from 'react-devtools-shared/src/storage'; import {StoreContext, BridgeContext} from './context'; import {sanitizeForParse, smartParse, smartStringify} from '../utils'; type ACTION_RESET = {| type: 'RESET', externalValue: any, |}; type ACTION_UPDATE = {| type: 'UPDATE', editableValue: any, externalValue: any, |}; type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE; type UseEditableValueDispatch = (action: UseEditableValueAction) => void; type UseEditableValueState = {| editableValue: any, externalValue: any, hasPendingChanges: boolean, isValid: boolean, parsedValue: any, |}; function useEditableValueReducer(state, action) { switch (action.type) { case 'RESET': return { ...state, editableValue: smartStringify(action.externalValue), externalValue: action.externalValue, hasPendingChanges: false, isValid: true, parsedValue: action.externalValue, }; case 'UPDATE': let isNewValueValid = false; let newParsedValue; try { newParsedValue = smartParse(action.editableValue); isNewValueValid = true; } catch (error) {} return { ...state, editableValue: sanitizeForParse(action.editableValue), externalValue: action.externalValue, hasPendingChanges: smartStringify(action.externalValue) !== action.editableValue, isValid: isNewValueValid, parsedValue: isNewValueValid ? newParsedValue : state.parsedValue, }; default: throw new Error(`Invalid action "${action.type}"`); } } // Convenience hook for working with an editable value that is validated via JSON.parse. export function useEditableValue( externalValue: any, ): [UseEditableValueState, UseEditableValueDispatch] { const [state, dispatch] = useReducer< UseEditableValueState, UseEditableValueState, UseEditableValueAction, >(useEditableValueReducer, { editableValue: smartStringify(externalValue), externalValue, hasPendingChanges: false, isValid: true, parsedValue: externalValue, }); if (!Object.is(state.externalValue, externalValue)) { if (!state.hasPendingChanges) { dispatch({ type: 'RESET', externalValue, }); } else { dispatch({ type: 'UPDATE', editableValue: state.editableValue, externalValue, }); } } return [state, dispatch]; } export function useIsOverflowing( containerRef: {current: HTMLDivElement | null, ...}, totalChildWidth: number, ): boolean { const [isOverflowing, setIsOverflowing] = useState(false); // It's important to use a layout effect, so that we avoid showing a flash of overflowed content. useLayoutEffect(() => { if (containerRef.current === null) { return () => {}; } const container = ((containerRef.current: any): HTMLDivElement); const handleResize = throttle( () => setIsOverflowing(container.clientWidth <= totalChildWidth), 100, ); handleResize(); // It's important to listen to the ownerDocument.defaultView to support the browser extension. // Here we use portals to render individual tabs (e.g. Profiler), // and the root document might belong to a different window. const ownerWindow = container.ownerDocument.defaultView; ownerWindow.addEventListener('resize', handleResize); return () => ownerWindow.removeEventListener('resize', handleResize); }, [containerRef, totalChildWidth]); return isOverflowing; } // Forked from https://usehooks.com/useLocalStorage/ export function useLocalStorage( key: string, initialValue: T | (() => T), ): [T, (value: T | (() => T)) => void] { const getValueFromLocalStorage = useCallback(() => { try { const item = localStorageGetItem(key); if (item != null) { return JSON.parse(item); } } catch (error) { console.log(error); } if (typeof initialValue === 'function') { return ((initialValue: any): () => T)(); } else { return initialValue; } }, [initialValue, key]); const [storedValue, setStoredValue] = useState(getValueFromLocalStorage); const setValue = useCallback( value => { try { const valueToStore = value instanceof Function ? (value: any)(storedValue) : value; setStoredValue(valueToStore); localStorageSetItem(key, JSON.stringify(valueToStore)); // Notify listeners that this setting has changed. window.dispatchEvent(new Event(key)); } catch (error) { console.log(error); } }, [key, storedValue], ); // Listen for changes to this local storage value made from other windows. // This enables the e.g. "⚛️ Elements" tab to update in response to changes from "⚛️ Settings". useLayoutEffect(() => { const onStorage = event => { const newValue = getValueFromLocalStorage(); if (key === event.key && storedValue !== newValue) { setValue(newValue); } }; window.addEventListener('storage', onStorage); return () => { window.removeEventListener('storage', onStorage); }; }, [getValueFromLocalStorage, key, storedValue, setValue]); return [storedValue, setValue]; } export function useModalDismissSignal( modalRef: {current: HTMLDivElement | null, ...}, dismissCallback: () => void, dismissOnClickOutside?: boolean = true, ): void { useEffect(() => { if (modalRef.current === null) { return () => {}; } const handleDocumentKeyDown = (event: any) => { if (event.key === 'Escape') { dismissCallback(); } }; const handleDocumentClick = (event: any) => { if ( modalRef.current !== null && !modalRef.current.contains(event.target) ) { event.stopPropagation(); event.preventDefault(); dismissCallback(); } }; let ownerDocument = null; // Delay until after the current call stack is empty, // in case this effect is being run while an event is currently bubbling. // In that case, we don't want to listen to the pre-existing event. let timeoutID = setTimeout(() => { timeoutID = null; // It's important to listen to the ownerDocument to support the browser extension. // Here we use portals to render individual tabs (e.g. Profiler), // and the root document might belong to a different window. ownerDocument = ((modalRef.current: any): HTMLDivElement).ownerDocument; ownerDocument.addEventListener('keydown', handleDocumentKeyDown); if (dismissOnClickOutside) { ownerDocument.addEventListener('click', handleDocumentClick, true); } }, 0); return () => { if (timeoutID !== null) { clearTimeout(timeoutID); } if (ownerDocument !== null) { ownerDocument.removeEventListener('keydown', handleDocumentKeyDown); ownerDocument.removeEventListener('click', handleDocumentClick, true); } }; }, [modalRef, dismissCallback, dismissOnClickOutside]); } // Copied from https://github.com/facebook/react/pull/15022 export function useSubscription({ getCurrentValue, subscribe, }: {| getCurrentValue: () => Value, subscribe: (callback: Function) => () => void, |}): Value { const [state, setState] = useState(() => ({ getCurrentValue, subscribe, value: getCurrentValue(), })); if ( state.getCurrentValue !== getCurrentValue || state.subscribe !== subscribe ) { setState({ getCurrentValue, subscribe, value: getCurrentValue(), }); } useEffect(() => { let didUnsubscribe = false; const checkForUpdates = () => { if (didUnsubscribe) { return; } setState(prevState => { if ( prevState.getCurrentValue !== getCurrentValue || prevState.subscribe !== subscribe ) { return prevState; } const value = getCurrentValue(); if (prevState.value === value) { return prevState; } return {...prevState, value}; }); }; const unsubscribe = subscribe(checkForUpdates); checkForUpdates(); return () => { didUnsubscribe = true; unsubscribe(); }; }, [getCurrentValue, subscribe]); return state.value; } export function useHighlightNativeElement() { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const highlightNativeElement = useCallback( (id: number) => { const element = store.getElementByID(id); const rendererID = store.getRendererIDForElement(id); if (element !== null && rendererID !== null) { bridge.send('highlightNativeElement', { displayName: element.displayName, hideAfterTimeout: false, id, openNativeElementsPanel: false, rendererID, scrollIntoView: false, }); } }, [store, bridge], ); const clearHighlightNativeElement = useCallback(() => { bridge.send('clearNativeElementHighlight'); }, [bridge]); return { highlightNativeElement, clearHighlightNativeElement, }; }