diff --git a/src/devtools/store.js b/src/devtools/store.js index 8982abe9fc..880616e69a 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -265,7 +265,7 @@ export default class Store extends EventEmitter { parentElement = ((this._idToElement.get(parentID): any): Element); parentElement.children = parentElement.children.concat(id); - this._idToElement.set(id, { + const element: Element = { children: [], depth: parentElement.depth + 1, displayName, @@ -274,7 +274,11 @@ export default class Store extends EventEmitter { parentID: parentElement.id, type, weight: 1, - }); + }; + + this._idToElement.set(id, element); + + this.emit('elementAdded', element); weightDelta = 1; } @@ -303,6 +307,8 @@ export default class Store extends EventEmitter { childID => childID !== id ); } + + this.emit('elementRemoved', element); break; case TREE_OPERATION_RESET_CHILDREN: id = ((operations[i + 1]: any): number); diff --git a/src/devtools/views/ButtonIcon.js b/src/devtools/views/ButtonIcon.js index 5174e71e17..6f054b1d0a 100644 --- a/src/devtools/views/ButtonIcon.js +++ b/src/devtools/views/ButtonIcon.js @@ -4,15 +4,24 @@ import React from 'react'; import styles from './ButtonIcon.css'; type Props = {| - type: 'search' | 'view-dom' | 'view-source', + type: 'close' | 'down' | 'search' | 'up' | 'view-dom' | 'view-source', |}; export default function ButtonIcon({ type }: Props) { let pathData = null; switch (type) { + case 'close': + pathData = PATH_CLOSE; + break; + case 'down': + pathData = PATH_DOWN; + break; case 'search': pathData = PATH_SEARCH; break; + case 'up': + pathData = PATH_UP; + break; case 'view-dom': pathData = PATH_VIEW_DOM; break; @@ -38,12 +47,19 @@ export default function ButtonIcon({ type }: Props) { ); } +const PATH_CLOSE = + 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'; + +const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'; + const PATH_SEARCH = ` M20.94 11c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z `; +const PATH_UP = 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'; + const PATH_VIEW_DOM = ` M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 diff --git a/src/devtools/views/Element.css b/src/devtools/views/Element.css index 236c404e43..a72e98de51 100644 --- a/src/devtools/views/Element.css +++ b/src/devtools/views/Element.css @@ -63,3 +63,10 @@ .AttributeValue { color: var(--color-attribute-value); } + +.Highlight { + background-color: var(--color-search-match); +} +.CurrentHighlight { + background-color: var(--color-search-match-current); +} diff --git a/src/devtools/views/Element.js b/src/devtools/views/Element.js index a76d57cec0..3a03048721 100644 --- a/src/devtools/views/Element.js +++ b/src/devtools/views/Element.js @@ -1,8 +1,9 @@ // @flow -import React, { Fragment, useCallback, useContext } from 'react'; +import React, { Fragment, useCallback, useContext, useMemo } from 'react'; import { ElementTypeClassOrFunction } from 'src/devtools/types'; import { TreeContext } from './context'; +import { SearchContext } from './SearchContext'; import { SelectedElementContext } from './SelectedElementContext'; import Icon from './Icon'; @@ -56,7 +57,7 @@ export default function Element({ index, style }: Props) { )} - {displayName} + {key && (  key= @@ -68,3 +69,48 @@ export default function Element({ index, style }: Props) { ); } + +type DisplayNameProps = {| + displayName: string | null, + id: number, +|}; + +function DisplayName({ displayName, id }: DisplayNameProps) { + const { + currentIndex: currentSearchIndex, + ids: searchIDs, + text: searchText, + } = useContext(SearchContext); + const isSearchResult = useMemo(() => { + return searchIDs.includes(id); + }, [id, searchIDs]); + const isCurrentResult = + currentSearchIndex !== null && id === searchIDs[currentSearchIndex]; + + if (!isSearchResult || displayName === null) { + return displayName; + } + + const startIndex = displayName + .toLowerCase() + .indexOf(searchText.toLowerCase()); + const stopIndex = startIndex + searchText.length; + + const children = []; + if (startIndex > 0) { + children.push({displayName.slice(0, startIndex)}); + } + children.push( + + {displayName.slice(startIndex, stopIndex)} + + ); + if (stopIndex < displayName.length) { + children.push({displayName.slice(stopIndex)}); + } + + return children; +} diff --git a/src/devtools/views/Elements.js b/src/devtools/views/Elements.js index a201174e1b..8e6b8a24da 100644 --- a/src/devtools/views/Elements.js +++ b/src/devtools/views/Elements.js @@ -5,6 +5,7 @@ import Store from '../store'; import Tree from './Tree'; import { BridgeContext, StoreContext, TreeContext } from './context'; import SelectedElement from './SelectedElement'; +import { SearchController } from './SearchContext'; import { SelectedElementController } from './SelectedElementContext'; import styles from './Elements.css'; @@ -51,14 +52,16 @@ export default function Elements({ bridge, browserName, themeName }: Props) { -
-
- + +
+
+ +
+
+ +
-
- -
-
+ diff --git a/src/devtools/views/HooksTree.css b/src/devtools/views/HooksTree.css index db582ec48b..c33c4c1d80 100644 --- a/src/devtools/views/HooksTree.css +++ b/src/devtools/views/HooksTree.css @@ -19,6 +19,6 @@ } .None { - color: var(--color-deemphasized); + color: var(--color-dimmer); font-style: italic; } diff --git a/src/devtools/views/InspectedElementTree.css b/src/devtools/views/InspectedElementTree.css index faad8dcc34..9020a0afa4 100644 --- a/src/devtools/views/InspectedElementTree.css +++ b/src/devtools/views/InspectedElementTree.css @@ -18,12 +18,12 @@ } .None { - color: var(--color-deemphasized); + color: var(--color-dimmer); font-style: italic; } .Empty { - color: var(--color-deemphasized); + color: var(--color-dimmer); font-style: italic; padding-left: 1rem; } diff --git a/src/devtools/views/SearchContext.js b/src/devtools/views/SearchContext.js new file mode 100644 index 0000000000..596860b98c --- /dev/null +++ b/src/devtools/views/SearchContext.js @@ -0,0 +1,194 @@ +// @flow + +import React, { + createContext, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react'; +import { StoreContext } from './context'; +import { SelectedElementContext } from './SelectedElementContext'; +import Store from '../store'; + +import type { Element } from 'src/devtools/types'; + +export type SearchContextValue = {| + get currentIndex(): number | null, + get ids(): Array, + get text(): string, + updateCurrentIndex(newCurrentIndex: number): void, + updateText(newText: string): void, +|}; + +const SearchContext = createContext( + ((null: any): SearchContextValue) +); +// $FlowFixMe displayName is a valid attribute of React$Context +SearchContext.displayName = 'SearchContext'; + +type Props = {| + children: React$Node, +|}; + +// TODO Remove this wrapper element once global Context.write API exists. +function SearchController({ children }: Props) { + const store = useContext(StoreContext); + const selectedElement = useContext(SelectedElementContext); + + const [currentIndex, setCurrentIndex] = useState(null); + const [ids, setIDs] = useState>([]); + const [text, setText] = useState(''); + + const updateIndexHelper = ( + newCurrentIndex: number | null, + newOrCurrentIDs: Array + ) => { + setCurrentIndex(newCurrentIndex); + + // Make sure a new search result is also selected. + selectedElement.id = + newCurrentIndex !== null ? newOrCurrentIDs[newCurrentIndex] : null; + }; + + const value = useMemo( + () => ({ + get currentIndex(): number | null { + return currentIndex; + }, + + get ids(): Array { + return ids; + }, + + get text(): string { + return text; + }, + + updateCurrentIndex(newCurrentIndex: number) { + updateIndexHelper(newCurrentIndex, ids); + }, + + updateText(newText: string) { + setText(newText); + + // Find all matching elements. + const newIDs = []; + if (newText !== '') { + const regExp = new RegExp(newText, 'i'); + store.roots.forEach(rootID => { + searchTree(store, rootID, regExp, newIDs); + }); + + setIDs(newIDs); + + // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid). + // If it's a new search, reset the index. + if (newIDs.length === 0) { + updateIndexHelper(null, newIDs); + } else if (currentIndex !== null && newText.startsWith(text)) { + updateIndexHelper( + Math.min(currentIndex, newIDs.length - 1), + newIDs + ); + } else { + updateIndexHelper(0, newIDs); + } + } else { + setIDs([]); + updateIndexHelper(null, newIDs); + } + }, + }), + [currentIndex, ids, text] + ); + + // Listen for changes to the tree and incrementally adjust the search results. + useLayoutEffect(() => { + const handleElementAdded = (element: Element) => { + if (!text) { + return; + } + + const { displayName, id } = element; + + // Add this item to the search results if it matches. + const regExp = new RegExp(text, 'i'); + if (displayName !== null && regExp.test(displayName)) { + const newElementIndex = ((store.getIndexOfElementID(id): any): number); + + let newIDs = null; + for (let index = 0; index < ids.length; index++) { + const id = ids[index]; + if ( + newElementIndex < ((store.getIndexOfElementID(id): any): number) + ) { + newIDs = ids + .slice(0, index) + .concat(id) + .concat(ids.slice(index)); + break; + } + } + if (newIDs === null) { + newIDs = ids.concat(id); + } + + setIDs(newIDs); + updateIndexHelper(currentIndex === null ? 0 : currentIndex, newIDs); + } + }; + const handleElementRemoved = (element: Element) => { + if (!text) { + return; + } + + // Prune this item from the search results. + const index = ids.indexOf(element.id); + if (index >= 0) { + const newIDs = ids.slice(0, index).concat(ids.slice(index + 1)); + setIDs(newIDs); + + // If the results are now empty, also deselect things. + if (newIDs.length === 0) { + updateIndexHelper(null, newIDs); + } else if (((currentIndex: any): number) < newIDs.length) { + updateIndexHelper(currentIndex, newIDs); + } else { + updateIndexHelper(newIDs.length - 1, newIDs); + } + } + }; + + store.addListener('elementAdded', handleElementAdded); + store.addListener('elementRemoved', handleElementRemoved); + + return () => { + store.removeListener('elementAdded', handleElementAdded); + store.removeListener('elementRemoved', handleElementRemoved); + }; + }, [currentIndex, ids, store, text]); + + return ( + {children} + ); +} + +function searchTree( + store: Store, + elementID: number, + regExp: RegExp, + ids: Array +): void { + const { children, displayName } = ((store.getElementByID( + elementID + ): any): Element); + if (displayName !== null) { + if (regExp.test(displayName)) { + ids.push(elementID); + } + } + children.forEach(childID => searchTree(store, childID, regExp, ids)); +} + +export { SearchContext, SearchController }; diff --git a/src/devtools/views/SearchInput.css b/src/devtools/views/SearchInput.css new file mode 100644 index 0000000000..5aed02be78 --- /dev/null +++ b/src/devtools/views/SearchInput.css @@ -0,0 +1,53 @@ +.SearchInput { + flex: 1; + display: flex; + align-items: center; +} + +.Input { + flex: 1; + font-size: 1rem; + outline: none; + border: none; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: left center; + background-size: 16px 16px; + padding-left: 1.5rem; +} + +.IndexLabel { + color: var(--color-dim); + font-size: var(--font-size-normal); +} + +.IconButton { + border: none; + background: none; + display: inline-flex; + align-items: center; + padding: 0.25rem; + cursor: pointer; + color: var(--color-button); + flex: 0 0 auto; +} +.IconButton:hover { + color: var(--color-button-hover); +} +.IconButton:disabled { + color: var(--color-dimmer); + cursor: default; +} + +.LeftVRule, +.RightVRule { + height: 20px; + width: 1px; + background-color: var(--color-border); +} +.LeftVRule { + margin: 0 0.25rem 0 0.5rem; +} +.RightVRule { + margin: 0 0.5rem 0 0.25rem; +} diff --git a/src/devtools/views/SearchInput.js b/src/devtools/views/SearchInput.js new file mode 100644 index 0000000000..3159462178 --- /dev/null +++ b/src/devtools/views/SearchInput.js @@ -0,0 +1,117 @@ +// @flow + +import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import { SearchContext } from './SearchContext'; +import ButtonIcon from './ButtonIcon'; + +import styles from './SearchInput.css'; + +type Props = {||}; + +export default function SearchInput(props: Props) { + const { + currentIndex, + ids, + updateCurrentIndex, + updateText, + text, + } = useContext(SearchContext); + + const inputRef = useRef(); + + const handleTextChange = useCallback(({ currentTarget }) => { + updateText(currentTarget.value); + }); + + const selectNext = useCallback(() => { + if (currentIndex !== null) { + updateCurrentIndex(currentIndex + 1); + } + }, [currentIndex]); + + const selectPrevious = useCallback(() => { + if (currentIndex !== null) { + updateCurrentIndex(currentIndex - 1); + } + }, [currentIndex]); + + const resetSearch = useCallback(() => { + updateText(''); + }, [updateText]); + + const handleInputKeyPress = useCallback( + ({ key }) => { + if (key === 'Enter') { + if (currentIndex !== null) { + if (currentIndex + 1 < ids.length) { + updateCurrentIndex(currentIndex + 1); + } else { + updateCurrentIndex(0); + } + } + } + }, + [currentIndex, ids] + ); + + // Auto-focus search input + useEffect(() => { + const handleWindowKeyDown = event => { + const { key, metaKey } = event; + if (key === 'f' && metaKey) { + if (inputRef.current !== null) { + inputRef.current.focus(); + event.preventDefault(); + } + } + }; + + window.addEventListener('keydown', handleWindowKeyDown); + + return () => window.removeEventListener('keydown', handleWindowKeyDown); + }, [inputRef]); + + return ( +
+ + {!!text && ( + + {Math.min(currentIndex + 1, ids.length)} | {ids.length} + + )} +
+ + + +
+
+ ); +} diff --git a/src/devtools/views/SelectedElement.css b/src/devtools/views/SelectedElement.css index c7f2ef356b..0f96d3e2a3 100644 --- a/src/devtools/views/SelectedElement.css +++ b/src/devtools/views/SelectedElement.css @@ -78,6 +78,6 @@ .Loading { padding: 0.25rem; - color: var(--color-deemphasized); + color: var(--color-dimmer); font-style: italic; } diff --git a/src/devtools/views/SelectedElement.js b/src/devtools/views/SelectedElement.js index c58ba25cd6..572fa9eb6e 100644 --- a/src/devtools/views/SelectedElement.js +++ b/src/devtools/views/SelectedElement.js @@ -135,13 +135,13 @@ function useInspectedElement(id: number | null): InspectedElement | null { idRef.current = id; useLayoutEffect(() => { + // Hide previous/stale insepected element to avoid temporarily showing the wrong values. + setInspectedElement(null); + if (id === null) { return () => {}; } - // Hide previous/stale insepected element to avoid temporarily showing the wrong values. - setInspectedElement(null); - let timeoutID = null; // Update the $r variable. @@ -152,15 +152,17 @@ function useInspectedElement(id: number | null): InspectedElement | null { }; const onInspectedElement = (inspectedElement: InspectedElement) => { - if (idRef.current !== inspectedElement.id) { + if (inspectedElement && inspectedElement.id !== idRef.current) { // Ignore bridge updates about previously selected elements. return; } - inspectedElement.context = hydrateHelper(inspectedElement.context); - inspectedElement.hooks = hydrateHelper(inspectedElement.hooks); - inspectedElement.props = hydrateHelper(inspectedElement.props); - inspectedElement.state = hydrateHelper(inspectedElement.state); + if (inspectedElement !== null) { + inspectedElement.context = hydrateHelper(inspectedElement.context); + inspectedElement.hooks = hydrateHelper(inspectedElement.hooks); + inspectedElement.props = hydrateHelper(inspectedElement.props); + inspectedElement.state = hydrateHelper(inspectedElement.state); + } setInspectedElement(inspectedElement); diff --git a/src/devtools/views/Tree.css b/src/devtools/views/Tree.css index 855f5331af..5373266a5a 100644 --- a/src/devtools/views/Tree.css +++ b/src/devtools/views/Tree.css @@ -6,7 +6,7 @@ border-top: 1px solid var(--color-border); } -.SearchRow { +.SearchInput { flex: 0 0 42px; display: flex; align-items: center; @@ -15,16 +15,17 @@ padding: 0.5rem; } -.SearchInput { - font-size: 1rem; - flex: 1; - outline: none; - border: none; - background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: left center; - background-size: 16px 16px; - padding-left: 1.5rem; +.AutoSizerWrapper { + width: 100%; + overflow: auto; + flex: 1 0 auto; + padding: 0.25rem; +} + +.List { + font-family: var(--font-family-monospace); + font-size: var(--font-size-normal); + line-height: var(--line-height-data); } .IconButton { @@ -40,16 +41,3 @@ .IconButton:hover { color: var(--color-button-hover); } - -.AutoSizerWrapper { - width: 100%; - overflow: auto; - flex: 1 0 auto; - padding: 0.25rem; -} - -.List { - font-family: var(--font-family-monospace); - font-size: var(--font-size-normal); - line-height: var(--line-height-data); -} diff --git a/src/devtools/views/Tree.js b/src/devtools/views/Tree.js index aac2f89aff..fb97b5f0de 100644 --- a/src/devtools/views/Tree.js +++ b/src/devtools/views/Tree.js @@ -4,8 +4,9 @@ import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { SelectedElementContext } from './SelectedElementContext'; -import Element from './Element'; import ButtonIcon from './ButtonIcon'; +import Element from './Element'; +import SearchInput from './SearchInput'; import { TreeContext } from './context'; import styles from './Tree.css'; @@ -15,7 +16,7 @@ type Props = {||}; export default function Tree(props: Props) { const selectedElementContext = useContext(SelectedElementContext); const treeContext = useContext(TreeContext); - const listRef = useRef(); + const listRef = useRef>(); // Make sure a newly selected element is visible in the list. // This is helpful for things like the owners list. @@ -59,11 +60,8 @@ export default function Tree(props: Props) { return (
-
- +
+