This reverts #19603. Before: <img width="724" alt="Screenshot 2024-08-28 at 12 07 29 AM" src="https://github.com/user-attachments/assets/0613088f-c013-4f1c-92c3-fbdae8c1f109"> After: <img width="771" alt="Screenshot 2024-08-28 at 12 08 13 AM" src="https://github.com/user-attachments/assets/eef21bee-d11f-4f0a-9147-053a163f720f"> Consensus seems to be that while the purple on is a bit clearer and easier to read. The purple is not on brand so it doesn't look like React. It looks ugly. It's distracting (too eye catching). Taking away attention from other tabs in an unfair way. It also gets worse with more tabs added. We plan on both adding another tab and panes inside other tabs (elements/sources) soon. Each needs to be marked somehow as part of React but spelling it out is too long. Putting inside a second tab means two clicks and takes away real-estate from our extension and doesn't solve the problem with extension panes in other tabs. We also plan on adding multiple different tracks to the Performance tab which also needs a name other than just React and spelling out React as a prefix is too long. The Emoji is too distracting. So it seems best to uniformly apply the symbol - albeit it might just look like a dot to many. Dark mode looks close to on brand: <img width="1089" alt="Screenshot 2024-08-28 at 12 32 50 AM" src="https://github.com/user-attachments/assets/7175a540-4241-4c26-9e4d-4d367873af57">
370 lines
10 KiB
JavaScript
370 lines
10 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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 {
|
|
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: UseEditableValueState,
|
|
action: UseEditableValueAction,
|
|
) {
|
|
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<boolean>(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 = () =>
|
|
setIsOverflowing(container.clientWidth <= totalChildWidth);
|
|
|
|
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<T>(
|
|
key: string,
|
|
initialValue: T | (() => T),
|
|
onValueSet?: (any, string) => void,
|
|
): [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<any>(getValueFromLocalStorage);
|
|
|
|
const setValue = useCallback(
|
|
(value: $FlowFixMe) => {
|
|
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));
|
|
|
|
if (onValueSet != null) {
|
|
onValueSet(valueToStore, 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(() => {
|
|
// $FlowFixMe[missing-local-annot]
|
|
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 handleRootNodeKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
dismissCallback();
|
|
}
|
|
};
|
|
|
|
const handleRootNodeClick: MouseEventHandler = event => {
|
|
if (
|
|
modalRef.current !== null &&
|
|
/* $FlowExpectedError[incompatible-call] Instead of dealing with possibly multiple realms
|
|
and multiple Node references to comply with Flow (e.g. checking with `event.target instanceof Node`)
|
|
just delegate it to contains call */
|
|
!modalRef.current.contains(event.target)
|
|
) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
dismissCallback();
|
|
}
|
|
};
|
|
|
|
let modalRootNode = 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: null | 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.
|
|
const modalDOMElement = modalRef.current;
|
|
if (modalDOMElement != null) {
|
|
modalRootNode = modalDOMElement.getRootNode();
|
|
modalRootNode.addEventListener('keydown', handleRootNodeKeyDown);
|
|
if (dismissOnClickOutside) {
|
|
modalRootNode.addEventListener('click', handleRootNodeClick, true);
|
|
}
|
|
}
|
|
}, 0);
|
|
|
|
return () => {
|
|
if (timeoutID !== null) {
|
|
clearTimeout(timeoutID);
|
|
}
|
|
|
|
if (modalRootNode !== null) {
|
|
modalRootNode.removeEventListener('keydown', handleRootNodeKeyDown);
|
|
modalRootNode.removeEventListener('click', handleRootNodeClick, true);
|
|
}
|
|
};
|
|
}, [modalRef, dismissCallback, dismissOnClickOutside]);
|
|
}
|
|
|
|
// Copied from https://github.com/facebook/react/pull/15022
|
|
export function useSubscription<Value>({
|
|
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 useHighlightHostInstance(): {
|
|
clearHighlightHostInstance: () => void,
|
|
highlightHostInstance: (id: number) => void,
|
|
} {
|
|
const bridge = useContext(BridgeContext);
|
|
const store = useContext(StoreContext);
|
|
|
|
const highlightHostInstance = useCallback(
|
|
(id: number) => {
|
|
const element = store.getElementByID(id);
|
|
const rendererID = store.getRendererIDForElement(id);
|
|
if (element !== null && rendererID !== null) {
|
|
bridge.send('highlightHostInstance', {
|
|
displayName: element.displayName,
|
|
hideAfterTimeout: false,
|
|
id,
|
|
openBuiltinElementsPanel: false,
|
|
rendererID,
|
|
scrollIntoView: false,
|
|
});
|
|
}
|
|
},
|
|
[store, bridge],
|
|
);
|
|
|
|
const clearHighlightHostInstance = useCallback(() => {
|
|
bridge.send('clearHostInstanceHighlight');
|
|
}, [bridge]);
|
|
|
|
return {
|
|
highlightHostInstance,
|
|
clearHighlightHostInstance,
|
|
};
|
|
}
|