Added search support

This commit is contained in:
Brian Vaughn
2019-02-05 17:14:06 +00:00
parent e723ec82bc
commit 998e69a10d
15 changed files with 493 additions and 57 deletions
+8 -2
View File
@@ -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);
+17 -1
View File
@@ -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
+7
View File
@@ -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);
}
+48 -2
View File
@@ -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) {
)}
<span className={styles.Component}>
{displayName}
<DisplayName displayName={displayName} id={id} />
{key && (
<Fragment>
&nbsp;<span className={styles.AttributeName}>key</span>=
@@ -68,3 +69,48 @@ export default function Element({ index, style }: Props) {
</div>
);
}
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(<span key="begin">{displayName.slice(0, startIndex)}</span>);
}
children.push(
<mark
key="middle"
className={isCurrentResult ? styles.CurrentHighlight : styles.Highlight}
>
{displayName.slice(startIndex, stopIndex)}
</mark>
);
if (stopIndex < displayName.length) {
children.push(<span key="end">{displayName.slice(stopIndex)}</span>);
}
return children;
}
+10 -7
View File
@@ -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) {
<StoreContext.Provider value={store}>
<TreeContext.Provider value={treeContext}>
<SelectedElementController>
<div className={styles.Elements}>
<div className={styles.TreeWrapper}>
<Tree />
<SearchController>
<div className={styles.Elements}>
<div className={styles.TreeWrapper}>
<Tree />
</div>
<div className={styles.SelectedElementWrapper}>
<SelectedElement />
</div>
</div>
<div className={styles.SelectedElementWrapper}>
<SelectedElement />
</div>
</div>
</SearchController>
</SelectedElementController>
</TreeContext.Provider>
</StoreContext.Provider>
+1 -1
View File
@@ -19,6 +19,6 @@
}
.None {
color: var(--color-deemphasized);
color: var(--color-dimmer);
font-style: italic;
}
+2 -2
View File
@@ -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;
}
+194
View File
@@ -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<number>,
get text(): string,
updateCurrentIndex(newCurrentIndex: number): void,
updateText(newText: string): void,
|};
const SearchContext = createContext<SearchContextValue>(
((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<number | null>(null);
const [ids, setIDs] = useState<Array<number>>([]);
const [text, setText] = useState<string>('');
const updateIndexHelper = (
newCurrentIndex: number | null,
newOrCurrentIDs: Array<number>
) => {
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<number> {
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 (
<SearchContext.Provider value={value}>{children}</SearchContext.Provider>
);
}
function searchTree(
store: Store,
elementID: number,
regExp: RegExp,
ids: Array<number>
): 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 };
+53
View File
@@ -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;
}
+117
View File
@@ -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 (
<div className={styles.SearchInput}>
<input
className={styles.Input}
onKeyPress={handleInputKeyPress}
onChange={handleTextChange}
placeholder="Search (text or /regex/)"
ref={inputRef}
value={text}
/>
{!!text && (
<span className={styles.IndexLabel}>
{Math.min(currentIndex + 1, ids.length)} | {ids.length}
</span>
)}
<div className={styles.LeftVRule} />
<button
className={styles.IconButton}
disabled={currentIndex === null || currentIndex === 0}
onClick={selectPrevious}
title="Scroll to previous search result"
>
<ButtonIcon type="up" />
</button>
<button
className={styles.IconButton}
disabled={currentIndex === null || currentIndex === ids.length - 1}
onClick={selectNext}
title="Scroll to next search result"
>
<ButtonIcon type="down" />
</button>
<button
className={styles.IconButton}
disabled={!text}
onClick={resetSearch}
title="Reset search"
>
<ButtonIcon type="close" />
</button>
<div className={styles.RightVRule} />
</div>
);
}
+1 -1
View File
@@ -78,6 +78,6 @@
.Loading {
padding: 0.25rem;
color: var(--color-deemphasized);
color: var(--color-dimmer);
font-style: italic;
}
+10 -8
View File
@@ -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);
+12 -24
View File
@@ -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);
}
+5 -7
View File
@@ -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<FixedSizeList<any>>();
// 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 (
<div className={styles.Tree}>
<div className={styles.SearchRow}>
<input
className={styles.SearchInput}
placeholder="Search (text or /regex/)"
/>
<div className={styles.SearchInput}>
<SearchInput />
<button
className={styles.IconButton}
title="Select an element in the page to inspect it"
+8 -2
View File
@@ -10,9 +10,12 @@
--theme-light-color-border: #eeeeee;
--theme-light-component-name: #8155cb;
--theme-light-component-name-inverted: #ffffff;
--theme-light-deemphasized: #cfd1d5;
--theme-light-dim: #777d88;
--theme-light-dimmer: #cfd1d5;
--theme-light-jsx-arrow-brackets: #333333;
--theme-light-jsx-arrow-brackets-inverted: rgba(255, 255, 255, 0.7);
--theme-light-search-match: yellow;
--theme-light-search-match-current: orange;
--theme-light-tree-node-selected: #0088fa;
--theme-light-tree-node-hover: #ebf1fb;
@@ -29,7 +32,10 @@
--color-button-hover: var(--theme-light-button-hover);
--color-component-name: var(--theme-light-component-name);
--color-component-name-inverted: var(--theme-light-component-name-inverted);
--color-deemphasized: var(--theme-light-deemphasized);
--color-dim: var(--theme-light-dim);
--color-dimmer: var(--theme-light-dimmer);
--color-search-match: var(--theme-light-search-match);
--color-search-match-current: var(--theme-light-search-match-current);
--color-tree-jsx-arrow-brackets: var(--theme-light-jsx-arrow-brackets);
--color-tree-jsx-arrow-brackets-inverted: var(
--theme-light-jsx-arrow-brackets-inverted