Combined search and selection contexts to avoid complicated dependencies
Brian Vaughn committed
Feb 7, 2019 at 09:29 UTC
fc924d136ac23ab4ac3d681675ee8d93a4047b25
8 files changed
+444
-402
src/devtools/views/Element.js
+12
-15
@@ -4,8 +4,7 @@ import React, { Fragment, useCallback, useContext, useMemo } from 'react';
4
import { ElementTypeClassOrFunction } from 'src/devtools/types';
5
import { TreeContext } from './context';
6
import { createRegExp } from './utils';
7
-import { SearchContext } from './SearchContext';
8
-import { SelectedElementContext } from './SelectedElementContext';
7
+import { SearchAndSelectionContext } from './SearchAndSelectionContext';
8
import Icon from './Icon';
9
10
import styles from './Element.css';
@@ -31,15 +30,15 @@ export default function Element({ index, style }: Props) {
30
31
const { children, depth, displayName, id, key, type } = element;
32
34
- const selectedElement = useContext(SelectedElementContext);
33
+ const { selectedElementID, selectElementWithID } = useContext(
34
+ SearchAndSelectionContext
35
+ );
36
const handleClick = useCallback(
36
- ({ metaKey }) => {
37
- selectedElement.id = metaKey ? null : id;
38
- },
37
+ ({ metaKey }) => selectElementWithID(metaKey ? null : id),
38
[id]
39
);
40
42
- const isSelected = selectedElement.id === id;
41
+ const isSelected = selectedElementID === id;
42
const showDollarR = isSelected && type === ElementTypeClassOrFunction;
43
44
return (
@@ -77,16 +76,14 @@ type DisplayNameProps = {|
76
|};
77
78
function DisplayName({ displayName, id }: DisplayNameProps) {
80
- const {
81
- currentIndex: currentSearchIndex,
82
- ids: searchIDs,
83
- text: searchText,
84
- } = useContext(SearchContext);
79
+ const { searchIndex, searchResults, searchText } = useContext(
80
+ SearchAndSelectionContext
81
+ );
82
const isSearchResult = useMemo(() => {
86
- return searchIDs.includes(id);
87
- }, [id, searchIDs]);
83
+ return searchResults.includes(id);
84
+ }, [id, searchResults]);
85
const isCurrentResult =
89
- currentSearchIndex !== null && id === searchIDs[currentSearchIndex];
86
+ searchIndex !== null && id === searchResults[searchIndex];
87
88
if (!isSearchResult || displayName === null) {
89
return displayName;
src/devtools/views/Elements.js
+10
-13
@@ -5,8 +5,7 @@ import Store from '../store';
5
import Tree from './Tree';
6
import { BridgeContext, StoreContext, TreeContext } from './context';
7
import SelectedElement from './SelectedElement';
8
-import { SearchController } from './SearchContext';
9
-import { SelectedElementController } from './SelectedElementContext';
8
+import { SearchAndSelectionController } from './SearchAndSelectionContext';
9
import styles from './Elements.css';
10
11
import './root.css';
@@ -51,18 +50,16 @@ export default function Elements({ bridge, browserName, themeName }: Props) {
50
<BridgeContext.Provider value={bridge}>
51
<StoreContext.Provider value={store}>
52
<TreeContext.Provider value={treeContext}>
54
- <SelectedElementController>
55
- <SearchController>
56
- <div className={styles.Elements}>
57
- <div className={styles.TreeWrapper}>
58
- <Tree />
59
- </div>
60
- <div className={styles.SelectedElementWrapper}>
61
- <SelectedElement />
62
- </div>
53
+ <SearchAndSelectionController>
54
+ <div className={styles.Elements}>
55
+ <div className={styles.TreeWrapper}>
56
+ <Tree />
57
</div>
64
- </SearchController>
65
- </SelectedElementController>
58
+ <div className={styles.SelectedElementWrapper}>
59
+ <SelectedElement />
60
+ </div>
61
+ </div>
62
+ </SearchAndSelectionController>
63
</TreeContext.Provider>
64
</StoreContext.Provider>
65
</BridgeContext.Provider>
src/devtools/views/SearchAndSelectionContext.js
new
+373
@@ -0,0 +1,373 @@
1
+// @flow
2
+
3
+// This context contains both the current search state and the selected element.
4
+// These values are combined into a single context because changes in one often impact the other.
5
+// Combining them enables us to avoid cascading renders.
6
+
7
+import React, {
8
+ createContext,
9
+ useCallback,
10
+ useContext,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useReducer,
14
+} from 'react';
15
+import { createRegExp } from './utils';
16
+import { StoreContext } from './context';
17
+import Store from '../store';
18
+
19
+import type { Element } from 'src/devtools/types';
20
+
21
+type Context = {|
22
+ // Read current context values:
23
+ searchIndex: number | null,
24
+ searchResults: Array<number>,
25
+ searchText: string,
26
+ selectedElementID: number | null,
27
+ selectedElementIndex: number | null,
28
+
29
+ // Update current context values:
30
+ decrementSearchIndex(): void,
31
+ incrementSearchIndex(): void,
32
+ selectElementWithID(id: number | null): void,
33
+ selectElementAtIndex(index: number | null): void,
34
+ updateSearchText(searchText: string): void,
35
+|};
36
+
37
+const SearchAndSelectionContext = createContext<Context>(
38
+ ((null: any): Context)
39
+);
40
+// $FlowFixMe displayName is a valid attribute of React$ConsearchText
41
+SearchAndSelectionContext.displayName = 'SearchAndSelectionContext';
42
+
43
+type State = {|
44
+ searchIndex: number | null,
45
+ searchResults: Array<number>,
46
+ searchText: string,
47
+ selectedElementID: number | null,
48
+ selectedElementIndex: number | null,
49
+|};
50
+
51
+type Action = {|
52
+ type:
53
+ | 'DECREMENT_SEARCH_INDEX'
54
+ | 'INCREMENT_SEARCH_INDEX'
55
+ | 'REFINE_SEARCH_RESULTS'
56
+ | 'SELECT_ELEMENT_WITH_ID'
57
+ | 'SELECT_ELEMENT_AT_INDEX'
58
+ | 'UPDATE_SEARCH_TEXT',
59
+ payload?: any,
60
+|};
61
+
62
+type Props = {|
63
+ children: React$Node,
64
+|};
65
+
66
+// TODO Remove this wrapper element once global ConsearchText.write API exists.
67
+function SearchAndSelectionController({ children }: Props) {
68
+ const store = useContext(StoreContext);
69
+
70
+ // This reducer is created inline because it needs access to the Store.
71
+ // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
72
+ // so it's okay for the reducer to have an empty dependencies array.
73
+ const reducer = useMemo(
74
+ () =>
75
+ function reducer(state: State, action: Action): State {
76
+ const { searchIndex, searchResults, searchText } = state;
77
+ const { payload, type } = action;
78
+
79
+ let newSearchIndex,
80
+ newSearchResults,
81
+ newSearchText,
82
+ newSelectedElementID;
83
+
84
+ switch (type) {
85
+ case 'DECREMENT_SEARCH_INDEX':
86
+ if (searchResults.length === 0) return state;
87
+
88
+ newSearchIndex =
89
+ ((searchIndex: any): number) > 0
90
+ ? ((searchIndex: any): number) - 1
91
+ : searchResults.length - 1;
92
+ newSelectedElementID = searchResults[newSearchIndex];
93
+
94
+ return {
95
+ ...state,
96
+ searchIndex: newSearchIndex,
97
+ selectedElementID: newSelectedElementID,
98
+ selectedElementIndex: store.getIndexOfElementID(
99
+ newSelectedElementID
100
+ ),
101
+ };
102
+
103
+ case 'INCREMENT_SEARCH_INDEX':
104
+ if (searchResults.length === 0) return state;
105
+
106
+ newSearchIndex =
107
+ ((searchIndex: any): number) + 1 < searchResults.length
108
+ ? ((searchIndex: any): number) + 1
109
+ : 0;
110
+ newSelectedElementID = searchResults[newSearchIndex];
111
+
112
+ return {
113
+ ...state,
114
+ searchIndex: newSearchIndex,
115
+ selectedElementID: newSelectedElementID,
116
+ selectedElementIndex: store.getIndexOfElementID(
117
+ newSelectedElementID
118
+ ),
119
+ };
120
+
121
+ case 'REFINE_SEARCH_RESULTS':
122
+ const [
123
+ addedElementIDs,
124
+ removedElementIDs,
125
+ ] = ((payload: any): Array<Uint32Array>);
126
+
127
+ newSearchResults = searchResults;
128
+ newSearchIndex = searchIndex;
129
+
130
+ removedElementIDs.forEach(id => {
131
+ // Prune this item from the search results.
132
+ const index = newSearchResults.indexOf(id);
133
+ if (index >= 0) {
134
+ newSearchResults = newSearchResults
135
+ .slice(0, index)
136
+ .concat(newSearchResults.slice(index + 1));
137
+
138
+ // If the results are now empty, also deselect things.
139
+ if (newSearchResults.length === 0) {
140
+ newSearchIndex = null;
141
+ } else if (
142
+ ((newSearchIndex: any): number) >= newSearchResults.length
143
+ ) {
144
+ newSearchIndex = newSearchResults.length - 1;
145
+ }
146
+ }
147
+ });
148
+
149
+ addedElementIDs.forEach(id => {
150
+ const { displayName } = ((store.getElementByID(
151
+ id
152
+ ): any): Element);
153
+
154
+ // Add this item to the search results if it matches.
155
+ const regExp = createRegExp(searchText);
156
+ if (displayName !== null && regExp.test(displayName)) {
157
+ const newElementIndex = ((store.getIndexOfElementID(
158
+ id
159
+ ): any): number);
160
+
161
+ let foundMatch = false;
162
+ for (let index = 0; index < newSearchResults.length; index++) {
163
+ const id = newSearchResults[index];
164
+ if (
165
+ newElementIndex <
166
+ ((store.getIndexOfElementID(id): any): number)
167
+ ) {
168
+ foundMatch = true;
169
+ newSearchResults = newSearchResults
170
+ .slice(0, index)
171
+ .concat(id)
172
+ .concat(newSearchResults.slice(index));
173
+ break;
174
+ }
175
+ }
176
+ if (!foundMatch) {
177
+ newSearchResults = newSearchResults.concat(id);
178
+ }
179
+
180
+ newSearchIndex = newSearchIndex === null ? 0 : newSearchIndex;
181
+ }
182
+ });
183
+
184
+ newSelectedElementID =
185
+ newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
186
+
187
+ return {
188
+ ...state,
189
+ searchIndex: newSearchIndex,
190
+ searchResults: newSearchResults,
191
+ selectedElementID: newSelectedElementID,
192
+ selectedElementIndex:
193
+ newSelectedElementID !== null
194
+ ? store.getIndexOfElementID(newSelectedElementID)
195
+ : null,
196
+ };
197
+
198
+ case 'SELECT_ELEMENT_WITH_ID':
199
+ newSelectedElementID = ((payload: any): number | null);
200
+
201
+ return {
202
+ ...state,
203
+ selectedElementID: newSelectedElementID,
204
+ selectedElementIndex:
205
+ newSelectedElementID !== null
206
+ ? store.getIndexOfElementID(newSelectedElementID)
207
+ : null,
208
+ };
209
+
210
+ case 'SELECT_ELEMENT_AT_INDEX':
211
+ newSearchIndex = ((payload: any): number | null);
212
+
213
+ const element =
214
+ newSearchIndex !== null
215
+ ? store.getElementAtIndex(newSearchIndex)
216
+ : null;
217
+
218
+ return {
219
+ ...state,
220
+ selectedElementID: element !== null ? element.id : null,
221
+ selectedElementIndex: newSearchIndex,
222
+ };
223
+
224
+ case 'UPDATE_SEARCH_TEXT':
225
+ newSearchIndex = searchIndex;
226
+ newSearchResults = [];
227
+ newSearchText = ((payload: any): string);
228
+
229
+ // Find all matching elements.
230
+ if (newSearchText !== '') {
231
+ const regExp = createRegExp(newSearchText);
232
+ store.roots.forEach(rootID => {
233
+ recursivelySearchTree(store, rootID, regExp, newSearchResults);
234
+ });
235
+
236
+ // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid).
237
+ // If it's a new search, reset the index.
238
+ if (newSearchResults.length === 0) {
239
+ newSearchIndex = null;
240
+ } else if (
241
+ searchIndex !== null &&
242
+ newSearchText.startsWith(searchText)
243
+ ) {
244
+ newSearchIndex = Math.min(
245
+ searchIndex,
246
+ newSearchResults.length - 1
247
+ );
248
+ } else {
249
+ newSearchIndex = 0;
250
+ }
251
+ } else {
252
+ newSearchIndex = null;
253
+ }
254
+
255
+ newSelectedElementID =
256
+ newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
257
+
258
+ return {
259
+ ...state,
260
+ searchIndex: newSearchIndex,
261
+ searchResults: newSearchResults,
262
+ searchText: newSearchText,
263
+ selectedElementID: newSelectedElementID,
264
+ selectedElementIndex:
265
+ newSelectedElementID !== null
266
+ ? store.getIndexOfElementID(newSelectedElementID)
267
+ : null,
268
+ };
269
+
270
+ default:
271
+ throw new Error(`Unrecognized action "${type}"`);
272
+ }
273
+ },
274
+ []
275
+ );
276
+
277
+ const [state, dispatch] = useReducer(reducer, {
278
+ searchIndex: null,
279
+ searchResults: [],
280
+ searchText: '',
281
+ selectedElementID: null,
282
+ selectedElementIndex: null,
283
+ });
284
+
285
+ const decrementSearchIndex = useCallback(
286
+ () => dispatch({ type: 'DECREMENT_SEARCH_INDEX' }),
287
+ [dispatch]
288
+ );
289
+ const incrementSearchIndex = useCallback(
290
+ () => dispatch({ type: 'INCREMENT_SEARCH_INDEX' }),
291
+ [dispatch]
292
+ );
293
+ const selectElementWithID = useCallback(
294
+ (id: number | null) =>
295
+ dispatch({ type: 'SELECT_ELEMENT_WITH_ID', payload: id }),
296
+ [dispatch]
297
+ );
298
+ const selectElementAtIndex = useCallback(
299
+ (index: number | null) =>
300
+ dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: index }),
301
+ [dispatch]
302
+ );
303
+ const updateSearchText = useCallback(
304
+ (searchText: string) =>
305
+ dispatch({ type: 'UPDATE_SEARCH_TEXT', payload: searchText }),
306
+ [dispatch]
307
+ );
308
+
309
+ const value = useMemo(
310
+ () => ({
311
+ searchIndex: state.searchIndex,
312
+ searchResults: state.searchResults,
313
+ searchText: state.searchText,
314
+ selectedElementID: state.selectedElementID,
315
+ selectedElementIndex: state.selectedElementIndex,
316
+
317
+ decrementSearchIndex,
318
+ incrementSearchIndex,
319
+ selectElementWithID,
320
+ selectElementAtIndex,
321
+ updateSearchText,
322
+ }),
323
+ [state]
324
+ );
325
+
326
+ // Listen for changes to the tree and incrementally adjust the search results.
327
+ useLayoutEffect(() => {
328
+ const handleStoreMutated = ([
329
+ addedElementIDs,
330
+ removedElementIDs,
331
+ ]: Array<Uint32Array>) => {
332
+ if (!state.searchText) {
333
+ return;
334
+ }
335
+
336
+ dispatch({
337
+ type: 'REFINE_SEARCH_RESULTS',
338
+ payload: [addedElementIDs, removedElementIDs],
339
+ });
340
+ };
341
+
342
+ store.addListener('mutated', handleStoreMutated);
343
+
344
+ return () => store.removeListener('mutated', handleStoreMutated);
345
+ }, [state, store]);
346
+
347
+ return (
348
+ <SearchAndSelectionContext.Provider value={value}>
349
+ {children}
350
+ </SearchAndSelectionContext.Provider>
351
+ );
352
+}
353
+
354
+function recursivelySearchTree(
355
+ store: Store,
356
+ elementID: number,
357
+ regExp: RegExp,
358
+ searchResults: Array<number>
359
+): void {
360
+ const { children, displayName } = ((store.getElementByID(
361
+ elementID
362
+ ): any): Element);
363
+ if (displayName !== null) {
364
+ if (regExp.test(displayName)) {
365
+ searchResults.push(elementID);
366
+ }
367
+ }
368
+ children.forEach(childID =>
369
+ recursivelySearchTree(store, childID, regExp, searchResults)
370
+ );
371
+}
372
+
373
+export { SearchAndSelectionContext, SearchAndSelectionController };
src/devtools/views/SearchContext.js
deleted
-241
@@ -1,241 +0,0 @@
1
-// @flow
2
-
3
-import React, {
4
- createContext,
5
- useContext,
6
- useLayoutEffect,
7
- useMemo,
8
- useReducer,
9
-} from 'react';
10
-import { createRegExp } from './utils';
11
-import { StoreContext } from './context';
12
-import { SelectedElementContext } from './SelectedElementContext';
13
-import Store from '../store';
14
-
15
-import type { Element } from 'src/devtools/types';
16
-
17
-export type SearchContextValue = {|
18
- get currentIndex(): number | null,
19
- get ids(): Array<number>,
20
- get text(): string,
21
- updateCurrentIndex(newCurrentIndex: number): void,
22
- updateText(newText: string): void,
23
-|};
24
-
25
-const SearchContext = createContext<SearchContextValue>(
26
- ((null: any): SearchContextValue)
27
-);
28
-// $FlowFixMe displayName is a valid attribute of React$Context
29
-SearchContext.displayName = 'SearchContext';
30
-
31
-type State = {|
32
- currentIndex: number | null,
33
- ids: Array<number>,
34
- text: string,
35
-|};
36
-
37
-type Action = {|
38
- type: 'UPDATE',
39
- payload: $Shape<State>,
40
-|};
41
-
42
-function reducer(state: State, action: Action) {
43
- switch (action.type) {
44
- case 'UPDATE':
45
- return {
46
- ...state,
47
- ...action.payload,
48
- };
49
- default:
50
- throw new Error();
51
- }
52
-}
53
-
54
-type Props = {|
55
- children: React$Node,
56
-|};
57
-
58
-// TODO Remove this wrapper element once global Context.write API exists.
59
-function SearchController({ children }: Props) {
60
- const store = useContext(StoreContext);
61
- const selectedElement = useContext(SelectedElementContext);
62
-
63
- const [state, dispatch] = useReducer(reducer, {
64
- currentIndex: null,
65
- ids: [],
66
- text: '',
67
- });
68
-
69
- const value = useMemo(
70
- () => ({
71
- get currentIndex(): number | null {
72
- return state.currentIndex;
73
- },
74
-
75
- get ids(): Array<number> {
76
- return state.ids;
77
- },
78
-
79
- get text(): string {
80
- return state.text;
81
- },
82
-
83
- updateCurrentIndex(newCurrentIndex: number) {
84
- dispatch({
85
- type: 'UPDATE',
86
- payload: {
87
- currentIndex: newCurrentIndex,
88
- },
89
- });
90
-
91
- // Make sure a new search result is also selected.
92
- selectedElement.id =
93
- newCurrentIndex !== null ? state.ids[newCurrentIndex] : null;
94
- },
95
-
96
- updateText(newText: string) {
97
- const { currentIndex: oldCurrentIndex, text: oldText } = state;
98
-
99
- let newCurrentIndex = oldCurrentIndex;
100
- let newIDs = [];
101
-
102
- // Find all matching elements.
103
- if (newText !== '') {
104
- let regExp = createRegExp(newText);
105
- store.roots.forEach(rootID => {
106
- searchTree(store, rootID, regExp, newIDs);
107
- });
108
-
109
- // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid).
110
- // If it's a new search, reset the index.
111
- if (newIDs.length === 0) {
112
- newCurrentIndex = null;
113
- } else if (oldCurrentIndex !== null && newText.startsWith(oldText)) {
114
- newCurrentIndex = Math.min(oldCurrentIndex, newIDs.length - 1);
115
- } else {
116
- newCurrentIndex = 0;
117
- }
118
- } else {
119
- newCurrentIndex = null;
120
- }
121
-
122
- dispatch({
123
- type: 'UPDATE',
124
- payload: {
125
- currentIndex: newCurrentIndex,
126
- ids: newIDs,
127
- text: newText,
128
- },
129
- });
130
-
131
- // Make sure a new search result is also selected.
132
- selectedElement.id =
133
- newCurrentIndex !== null ? newIDs[newCurrentIndex] : null;
134
- },
135
- }),
136
- [state]
137
- );
138
-
139
- // Listen for changes to the tree and incrementally adjust the search results.
140
- useLayoutEffect(() => {
141
- const handleStoreMutated = ([
142
- addedElementIDs,
143
- removedElementIDs,
144
- ]: Array<Uint32Array>) => {
145
- const { currentIndex, ids, text } = state;
146
-
147
- if (!text) {
148
- return;
149
- }
150
-
151
- let newIDs = ids;
152
- let newCurrentIndex = currentIndex;
153
-
154
- removedElementIDs.forEach(id => {
155
- // Prune this item from the search results.
156
- const index = newIDs.indexOf(id);
157
- if (index >= 0) {
158
- newIDs = newIDs.slice(0, index).concat(newIDs.slice(index + 1));
159
-
160
- // If the results are now empty, also deselect things.
161
- if (newIDs.length === 0) {
162
- newCurrentIndex = null;
163
- } else if (((newCurrentIndex: any): number) >= newIDs.length) {
164
- newCurrentIndex = newIDs.length - 1;
165
- }
166
- }
167
- });
168
-
169
- addedElementIDs.forEach(id => {
170
- const { displayName } = ((store.getElementByID(id): any): Element);
171
-
172
- // Add this item to the search results if it matches.
173
- const regExp = createRegExp(text);
174
- if (displayName !== null && regExp.test(displayName)) {
175
- const newElementIndex = ((store.getIndexOfElementID(
176
- id
177
- ): any): number);
178
-
179
- let foundMatch = false;
180
- for (let index = 0; index < newIDs.length; index++) {
181
- const id = newIDs[index];
182
- if (
183
- newElementIndex < ((store.getIndexOfElementID(id): any): number)
184
- ) {
185
- foundMatch = true;
186
- newIDs = newIDs
187
- .slice(0, index)
188
- .concat(id)
189
- .concat(newIDs.slice(index));
190
- break;
191
- }
192
- }
193
- if (!foundMatch) {
194
- newIDs = newIDs.concat(id);
195
- }
196
-
197
- newCurrentIndex = newCurrentIndex === null ? 0 : newCurrentIndex;
198
- }
199
- });
200
-
201
- dispatch({
202
- type: 'UPDATE',
203
- payload: {
204
- currentIndex: newCurrentIndex,
205
- ids: newIDs,
206
- },
207
- });
208
-
209
- // Make sure a new search result is also selected.
210
- selectedElement.id =
211
- newCurrentIndex !== null ? newIDs[newCurrentIndex] : null;
212
- };
213
-
214
- store.addListener('mutated', handleStoreMutated);
215
-
216
- return () => store.removeListener('mutated', handleStoreMutated);
217
- }, [state, store]);
218
-
219
- return (
220
- <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
221
- );
222
-}
223
-
224
-function searchTree(
225
- store: Store,
226
- elementID: number,
227
- regExp: RegExp,
228
- ids: Array<number>
229
-): void {
230
- const { children, displayName } = ((store.getElementByID(
231
- elementID
232
- ): any): Element);
233
- if (displayName !== null) {
234
- if (regExp.test(displayName)) {
235
- ids.push(elementID);
236
- }
237
- }
238
- children.forEach(childID => searchTree(store, childID, regExp, ids));
239
-}
240
-
241
-export { SearchContext, SearchController };
src/devtools/views/SearchInput.js
+24
-44
@@ -1,7 +1,7 @@
1
// @flow
2
3
import React, { useCallback, useContext, useEffect, useRef } from 'react';
4
-import { SearchContext } from './SearchContext';
4
+import { SearchAndSelectionContext } from './SearchAndSelectionContext';
5
import ButtonIcon from './ButtonIcon';
6
7
import styles from './SearchInput.css';
@@ -9,50 +9,33 @@ import styles from './SearchInput.css';
9
type Props = {||};
10
11
export default function SearchInput(props: Props) {
12
- // We use the wrapper searchContext object rather than its individual parts–
13
- // (specifically for the useCallback input arrays below)–
14
- // because a change in any one value (e.g. ids array) impacts other values (e.g. currentIndex).
15
- // It's easier to avoid stale scope issues if we depend on the entire search state.
16
- const searchContext = useContext(SearchContext);
12
+ const {
13
+ decrementSearchIndex,
14
+ incrementSearchIndex,
15
+ searchIndex,
16
+ searchResults,
17
+ searchText,
18
+ updateSearchText,
19
+ } = useContext(SearchAndSelectionContext);
20
21
const inputRef = useRef();
22
20
- const handleTextChange = useCallback(({ currentTarget }) => {
21
- searchContext.updateText(currentTarget.value);
22
- });
23
-
24
- const selectNext = useCallback(() => {
25
- const { currentIndex } = searchContext;
26
- if (currentIndex !== null) {
27
- searchContext.updateCurrentIndex(currentIndex + 1);
28
- }
29
- }, [searchContext]);
30
-
31
- const selectPrevious = useCallback(() => {
32
- const { currentIndex } = searchContext;
33
- if (currentIndex !== null) {
34
- searchContext.updateCurrentIndex(currentIndex - 1);
35
- }
36
- }, [searchContext]);
23
+ const handleTextChange = useCallback(
24
+ ({ currentTarget }) => updateSearchText(currentTarget.value),
25
+ [updateSearchText]
26
+ );
27
28
const resetSearch = useCallback(() => {
39
- searchContext.updateText('');
40
- }, [searchContext]);
29
+ updateSearchText('');
30
+ }, [updateSearchText]);
31
32
const handleInputKeyPress = useCallback(
33
({ key }) => {
34
if (key === 'Enter') {
45
- const { currentIndex, ids } = searchContext;
46
- if (currentIndex !== null) {
47
- if (currentIndex + 1 < ids.length) {
48
- searchContext.updateCurrentIndex(currentIndex + 1);
49
- } else {
50
- searchContext.updateCurrentIndex(0);
51
- }
52
- }
35
+ incrementSearchIndex();
36
}
37
},
55
- [searchContext]
38
+ [incrementSearchIndex]
39
);
40
41
// Auto-focus search input
@@ -72,8 +55,6 @@ export default function SearchInput(props: Props) {
55
return () => window.removeEventListener('keydown', handleWindowKeyDown);
56
}, [inputRef]);
57
75
- const { currentIndex, ids, text } = searchContext;
76
-
58
return (
59
<div className={styles.SearchInput}>
60
<input
@@ -82,33 +63,32 @@ export default function SearchInput(props: Props) {
63
onChange={handleTextChange}
64
placeholder="Search (text or /regex/)"
65
ref={inputRef}
85
- value={text}
66
+ value={searchText}
67
/>
87
- {!!text && (
68
+ {!!searchText && (
69
<span className={styles.IndexLabel}>
89
- {Math.min(currentIndex + 1, ids.length)} | {ids.length}
70
+ {Math.min(searchIndex + 1, searchResults.length)} |{' '}
71
+ {searchResults.length}
72
</span>
73
)}
74
<div className={styles.LeftVRule} />
75
<button
76
className={styles.IconButton}
95
- disabled={currentIndex === null || currentIndex === 0}
96
- onClick={selectPrevious}
77
+ onClick={decrementSearchIndex}
78
title="Scroll to previous search result"
79
>
80
<ButtonIcon type="up" />
81
</button>
82
<button
83
className={styles.IconButton}
103
- disabled={currentIndex === null || currentIndex === ids.length - 1}
104
- onClick={selectNext}
84
+ onClick={incrementSearchIndex}
85
title="Scroll to next search result"
86
>
87
<ButtonIcon type="down" />
88
</button>
89
<button
90
className={styles.IconButton}
111
- disabled={!text}
91
+ disabled={!searchText}
92
onClick={resetSearch}
93
title="Reset search"
94
>
src/devtools/views/SelectedElement.js
+10
-18
@@ -1,7 +1,7 @@
1
// @flow
2
3
import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
4
-import { SelectedElementContext } from './SelectedElementContext';
4
+import { SearchAndSelectionContext } from './SearchAndSelectionContext';
5
import { BridgeContext, StoreContext } from './context';
6
import ButtonIcon from './ButtonIcon';
7
import HooksTree from './HooksTree';
@@ -11,17 +11,16 @@ import styles from './SelectedElement.css';
11
12
import type { InspectedElement } from '../types';
13
import type { DehydratedData } from 'src/devtools/types';
14
-import type { SelectedElementContextValue } from './SelectedElementContext';
14
15
export type Props = {||};
16
17
export default function SelectedElement(_: Props) {
19
- const selectedElement = useContext(SelectedElementContext);
20
- const { id } = selectedElement;
18
+ const { selectedElementID } = useContext(SearchAndSelectionContext);
19
const store = useContext(StoreContext);
22
- const element = id !== null ? store.getElementByID(id) : null;
20
+ const element =
21
+ selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
22
24
- const inspectedElement = useInspectedElement(id);
23
+ const inspectedElement = useInspectedElement(selectedElementID);
24
25
// TODO Make "view DOM" and "view source" buttons work
26
@@ -65,10 +64,7 @@ export default function SelectedElement(_: Props) {
64
)}
65
66
{inspectedElement !== null && (
68
- <InspectedElementView
69
- inspectedElement={inspectedElement}
70
- selectedElement={selectedElement}
71
- />
67
+ <InspectedElementView inspectedElement={inspectedElement} />
68
)}
69
</div>
70
);
@@ -76,13 +72,11 @@ export default function SelectedElement(_: Props) {
72
73
type InspectedElementViewProps = {|
74
inspectedElement: InspectedElement,
79
- selectedElement: SelectedElementContextValue,
75
|};
76
82
-function InspectedElementView({
83
- inspectedElement,
84
- selectedElement,
85
-}: InspectedElementViewProps) {
77
+function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
78
+ const { selectElementWithID } = useContext(SearchAndSelectionContext);
79
+
80
let { context, hooks, owners, props, state } = inspectedElement;
81
82
return (
@@ -99,9 +93,7 @@ function InspectedElementView({
93
<div
94
key={owner.id}
95
className={styles.Owner}
102
- onClick={() => {
103
- selectedElement.id = owner.id;
104
- }}
96
+ onClick={() => selectElementWithID(owner.id)}
97
title={owner.displayName}
98
>
99
{owner.displayName}
src/devtools/views/SelectedElementContext.js
deleted
-56
@@ -1,56 +0,0 @@
1
-// @flow
2
-
3
-import React, { createContext, useContext, useMemo, useState } from 'react';
4
-import { StoreContext } from './context';
5
-
6
-export type SelectedElementContextValue = {|
7
- id: number | null,
8
- index: number | null,
9
-|};
10
-
11
-const SelectedElementContext = createContext<SelectedElementContextValue>(
12
- ((null: any): SelectedElementContextValue)
13
-);
14
-// $FlowFixMe displayName is a valid attribute of React$Context
15
-SelectedElementContext.displayName = 'SelectedElementContext';
16
-
17
-type Props = {|
18
- children: React$Node,
19
-|};
20
-
21
-// TODO Remove this wrapper element once global Context.write API exists.
22
-function SelectedElementController({ children }: Props) {
23
- const store = useContext(StoreContext);
24
-
25
- const [id, setID] = useState<number | null>(null);
26
- const [index, setIndex] = useState<number | null>(null);
27
-
28
- const value = useMemo(
29
- () => ({
30
- get id() {
31
- return id;
32
- },
33
- set id(id: number | null) {
34
- setID(id);
35
- setIndex(id !== null ? store.getIndexOfElementID(id) : null);
36
- },
37
- get index() {
38
- return index;
39
- },
40
- set index(index: number | null) {
41
- const element = index !== null ? store.getElementAtIndex(index) : null;
42
- setID(element !== null ? element.id : null);
43
- setIndex(index);
44
- },
45
- }),
46
- [id]
47
- );
48
-
49
- return (
50
- <SelectedElementContext.Provider value={value}>
51
- {children}
52
- </SelectedElementContext.Provider>
53
- );
54
-}
55
-
56
-export { SelectedElementContext, SelectedElementController };
src/devtools/views/Tree.js
+15
-15
@@ -3,7 +3,7 @@
3
import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
4
import AutoSizer from 'react-virtualized-auto-sizer';
5
import { FixedSizeList } from 'react-window';
6
-import { SelectedElementContext } from './SelectedElementContext';
6
+import { SearchAndSelectionContext } from './SearchAndSelectionContext';
7
import ButtonIcon from './ButtonIcon';
8
import Element from './Element';
9
import SearchInput from './SearchInput';
@@ -14,37 +14,37 @@ import styles from './Tree.css';
14
type Props = {||};
15
16
export default function Tree(props: Props) {
17
- const selectedElementContext = useContext(SelectedElementContext);
17
+ const { selectedElementIndex, selectElementAtIndex } = useContext(
18
+ SearchAndSelectionContext
19
+ );
20
const treeContext = useContext(TreeContext);
21
const listRef = useRef<FixedSizeList<any>>();
22
23
// Make sure a newly selected element is visible in the list.
24
// This is helpful for things like the owners list.
25
useLayoutEffect(() => {
24
- const { index } = selectedElementContext;
25
- if (index !== null && listRef.current != null) {
26
- listRef.current.scrollToItem(index);
26
+ if (selectedElementIndex !== null && listRef.current != null) {
27
+ listRef.current.scrollToItem(selectedElementIndex);
28
}
28
- }, [listRef, selectedElementContext]);
29
+ }, [listRef, selectedElementIndex]);
30
31
// Navigate the tree with up/down arrow keys.
32
useEffect(() => {
33
const handleKeyDown = event => {
33
- let index;
34
-
34
// eslint-disable-next-line default-case
35
switch (event.key) {
36
case 'ArrowDown':
38
- index = selectedElementContext.index;
39
- if (index !== null && index + 1 < treeContext.size) {
40
- selectedElementContext.index = ((index: any): number) + 1;
37
+ if (
38
+ selectedElementIndex !== null &&
39
+ selectedElementIndex + 1 < treeContext.size
40
+ ) {
41
+ selectElementAtIndex(((selectedElementIndex: any): number) + 1);
42
}
43
event.preventDefault();
44
break;
45
case 'ArrowUp':
45
- index = selectedElementContext.index;
46
- if (index !== null && index > 0) {
47
- selectedElementContext.index = ((index: any): number) - 1;
46
+ if (selectedElementIndex !== null && selectedElementIndex > 0) {
47
+ selectElementAtIndex(((selectedElementIndex: any): number) - 1);
48
}
49
event.preventDefault();
50
break;
@@ -56,7 +56,7 @@ export default function Tree(props: Props) {
56
return () => {
57
window.removeEventListener('keydown', handleKeyDown);
58
};
59
- }, [selectedElementContext, treeContext]);
59
+ }, [selectedElementIndex, selectElementAtIndex, treeContext]);
60
61
return (
62
<div className={styles.Tree}>