Skip to content

Commit 7263e76

Browse files
authored
Merge pull request facebook#116 from bvaughn/issues/105
Add collapse/toggle UI to Tree
2 parents 56be358 + f89f2b2 commit 7263e76

File tree

10 files changed

+200
-30
lines changed

10 files changed

+200
-30
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
"react-dom": "^16.8.4",
105105
"react-is": "^16.8.4",
106106
"react-virtualized-auto-sizer": "^1.0.2",
107-
"react-window": "^1.5.1",
107+
"react-window": "^1.7.2",
108108
"scheduler": "^0.13",
109109
"semver": "^5.5.1",
110110
"style-loader": "^0.23.1",

src/devtools/store.js

+51-11
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,6 @@ export default class Store extends EventEmitter {
7171
// When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
7272
_isProfiling: boolean = false;
7373

74-
// Total number of visible elements (within all roots).
75-
// Used for windowing purposes.
76-
_numElements: number = 0;
77-
7874
// Suspense cache for reading profilign data.
7975
_profilingCache: ProfilingCache;
8076

@@ -112,6 +108,10 @@ export default class Store extends EventEmitter {
112108
_supportsProfiling: boolean = false;
113109
_supportsReloadAndProfile: boolean = false;
114110

111+
// Total number of visible elements (within all roots).
112+
// Used for windowing purposes.
113+
_weightAcrossRoots: number = 0;
114+
115115
constructor(bridge: Bridge, config?: Config) {
116116
super();
117117

@@ -198,7 +198,7 @@ export default class Store extends EventEmitter {
198198
}
199199

200200
get numElements(): number {
201-
return this._numElements;
201+
return this._weightAcrossRoots;
202202
}
203203

204204
get profilingCache(): ProfilingCache {
@@ -287,16 +287,18 @@ export default class Store extends EventEmitter {
287287
let currentElement = ((this._idToElement.get(firstChildID): any): Element);
288288
let currentWeight = rootWeight;
289289
while (index !== currentWeight) {
290-
for (let i = 0; i < currentElement.children.length; i++) {
290+
const numChildren = currentElement.children.length;
291+
for (let i = 0; i < numChildren; i++) {
291292
const childID = currentElement.children[i];
292293
const child = ((this._idToElement.get(childID): any): Element);
293-
const { weight } = child;
294-
if (index <= currentWeight + weight) {
294+
const childWeight = child.isCollapsed ? 1 : child.weight;
295+
296+
if (index <= currentWeight + childWeight) {
295297
currentWeight++;
296298
currentElement = child;
297299
break;
298300
} else {
299-
currentWeight += weight;
301+
currentWeight += childWeight;
300302
}
301303
}
302304
}
@@ -351,7 +353,7 @@ export default class Store extends EventEmitter {
351353
break;
352354
}
353355
const child = ((this._idToElement.get(childID): any): Element);
354-
index += child.weight;
356+
index += child.isCollapsed ? 1 : child.weight;
355357
}
356358

357359
previousID = current.id;
@@ -420,6 +422,29 @@ export default class Store extends EventEmitter {
420422
this.emit('isProfiling');
421423
}
422424

425+
toggleIsCollapsed(id: number, isCollapsed: boolean): void {
426+
const element = this.getElementByID(id);
427+
if (element !== null) {
428+
const oldWeight = element.isCollapsed ? 1 : element.weight;
429+
element.isCollapsed = isCollapsed;
430+
const newWeight = element.isCollapsed ? 1 : element.weight;
431+
const weightDelta = newWeight - oldWeight;
432+
433+
this._weightAcrossRoots += weightDelta;
434+
435+
let parentElement = this._idToElement.get(element.parentID);
436+
while (parentElement != null) {
437+
parentElement.weight += weightDelta;
438+
parentElement = this._idToElement.get(parentElement.parentID);
439+
}
440+
441+
// The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed.
442+
// In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden).
443+
// Updating the selected search index later may require auto-expanding a collapsed subtree though.
444+
this.emit('mutated', [[], []]);
445+
}
446+
}
447+
423448
_captureScreenshot = throttle(
424449
memoize((commitIndex: number) => {
425450
this._bridge.send('captureScreenshot', { commitIndex });
@@ -518,6 +543,7 @@ export default class Store extends EventEmitter {
518543
depth: -1,
519544
displayName: null,
520545
id,
546+
isCollapsed: false,
521547
key: null,
522548
ownerID: 0,
523549
parentID: 0,
@@ -566,6 +592,7 @@ export default class Store extends EventEmitter {
566592
depth: parentElement.depth + 1,
567593
displayName,
568594
id,
595+
isCollapsed: false,
569596
key,
570597
ownerID,
571598
parentID: parentElement.id,
@@ -715,14 +742,27 @@ export default class Store extends EventEmitter {
715742
throw Error(`Unsupported Bridge operation ${operation}`);
716743
}
717744

718-
this._numElements += weightDelta;
745+
let isInsideCollapsedSubTree = false;
719746

720747
while (parentElement != null) {
721748
parentElement.weight += weightDelta;
749+
750+
// Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent.
751+
// Their weight will bubble up when the parent is expanded.
752+
if (parentElement.isCollapsed) {
753+
isInsideCollapsedSubTree = true;
754+
break;
755+
}
756+
722757
parentElement = ((this._idToElement.get(
723758
parentElement.parentID
724759
): any): Element);
725760
}
761+
762+
// Additions and deletions within a collapsed subtree should not affect the overall number of elements.
763+
if (!isInsideCollapsedSubTree) {
764+
this._weightAcrossRoots += weightDelta;
765+
}
726766
}
727767

728768
this._revision++;

src/devtools/views/ButtonIcon.js

+12
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export type IconType =
77
| 'back'
88
| 'cancel'
99
| 'close'
10+
| 'collapsed'
1011
| 'copy'
1112
| 'down'
13+
| 'expanded'
1214
| 'export'
1315
| 'filter'
1416
| 'import'
@@ -40,12 +42,18 @@ export default function ButtonIcon({ type }: Props) {
4042
case 'close':
4143
pathData = PATH_CLOSE;
4244
break;
45+
case 'collapsed':
46+
pathData = PATH_COLLAPSED;
47+
break;
4348
case 'copy':
4449
pathData = PATH_COPY;
4550
break;
4651
case 'down':
4752
pathData = PATH_DOWN;
4853
break;
54+
case 'expanded':
55+
pathData = PATH_EXPANDED;
56+
break;
4957
case 'export':
5058
pathData = PATH_EXPORT;
5159
break;
@@ -121,13 +129,17 @@ const PATH_CANCEL = `
121129
const PATH_CLOSE =
122130
'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';
123131

132+
const PATH_COLLAPSED = 'M10 17l5-5-5-5v10z';
133+
124134
const PATH_COPY = `
125135
M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2
126136
2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z
127137
`;
128138

129139
const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z';
130140

141+
const PATH_EXPANDED = 'M7 10l5 5 5-5z';
142+
131143
const PATH_EXPORT = 'M15.82,2.14v7H21l-9,9L3,9.18H8.18v-7ZM3,20.13H21v1.73H3Z';
132144

133145
const PATH_FILTER = 'M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z';

src/devtools/views/Components/Element.css

+10
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
align-items: center;
88
cursor: default;
99
user-select: none;
10+
11+
--color-expand-collapse-toggle: var(--color-dim);
1012
}
1113
.Element:hover {
1214
background-color: var(--color-hover-background);
@@ -21,6 +23,7 @@
2123
--color-jsx-arrow-brackets: var(--color-jsx-arrow-brackets-inverted);
2224
--color-attribute-name: var(--color-hover-background);
2325
--color-attribute-value: var(--color-component-name-inverted);
26+
--color-expand-collapse-toggle: var(--color-component-name-inverted);
2427
}
2528

2629
.DollarR {
@@ -55,3 +58,10 @@
5558
.CurrentHighlight {
5659
background-color: var(--color-search-match-current);
5760
}
61+
62+
.ExpandCollapseToggle {
63+
display: inline-flex;
64+
width: 1rem;
65+
height: 1rem;
66+
color: var(--color-expand-collapse-toggle);
67+
}

src/devtools/views/Components/Element.js

+45-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import React, {
99
useRef,
1010
} from 'react';
1111
import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types';
12+
import Store from 'src/devtools/store';
13+
import ButtonIcon from '../ButtonIcon';
1214
import { createRegExp } from '../utils';
1315
import { TreeContext } from './TreeContext';
1416
import { BridgeContext, StoreContext } from '../context';
@@ -28,6 +30,7 @@ export default function ElementView({ data, index, style }: Props) {
2830
const {
2931
baseDepth,
3032
getElementAtIndex,
33+
ownerStack,
3134
selectOwner,
3235
selectedElementID,
3336
selectElementByID,
@@ -75,8 +78,6 @@ export default function ElementView({ data, index, style }: Props) {
7578
}
7679
}, [id, isSelected, lastScrolledIDRef]);
7780

78-
// TODO Add click and key handlers for toggling element open/close state.
79-
8081
const handleMouseDown = useCallback(
8182
({ metaKey }) => {
8283
if (id !== null) {
@@ -114,8 +115,6 @@ export default function ElementView({ data, index, style }: Props) {
114115
const showDollarR =
115116
isSelected && (type === ElementTypeClass || type === ElementTypeFunction);
116117

117-
// TODO styles.SelectedElement is 100% width but it doesn't take horizontal overflow into account.
118-
119118
return (
120119
<div
121120
className={isSelected ? styles.SelectedElement : styles.Element}
@@ -138,6 +137,9 @@ export default function ElementView({ data, index, style }: Props) {
138137
marginBottom: `-${style.height}px`,
139138
}}
140139
>
140+
{ownerStack.length === 0 ? (
141+
<ExpandCollapseToggle element={element} store={store} />
142+
) : null}
141143
<span className={styles.Component} ref={ref}>
142144
<DisplayName displayName={displayName} id={((id: any): number)} />
143145
{key && (
@@ -152,6 +154,45 @@ export default function ElementView({ data, index, style }: Props) {
152154
);
153155
}
154156

157+
// Prevent double clicks on toggle from drilling into the owner list.
158+
const swallowDoubleClick = event => {
159+
event.preventDefault();
160+
event.stopPropagation();
161+
};
162+
163+
type ExpandCollapseToggleProps = {|
164+
element: Element,
165+
store: Store,
166+
|};
167+
168+
function ExpandCollapseToggle({ element, store }: ExpandCollapseToggleProps) {
169+
const { children, id, isCollapsed } = element;
170+
171+
const toggleCollapsed = useCallback(
172+
event => {
173+
event.preventDefault();
174+
event.stopPropagation();
175+
176+
store.toggleIsCollapsed(id, !isCollapsed);
177+
},
178+
[id, isCollapsed, store]
179+
);
180+
181+
if (children.length === 0) {
182+
return <div className={styles.ExpandCollapseToggle} />;
183+
}
184+
185+
return (
186+
<div
187+
className={styles.ExpandCollapseToggle}
188+
onClick={toggleCollapsed}
189+
onDoubleClick={swallowDoubleClick}
190+
>
191+
<ButtonIcon type={isCollapsed ? 'collapsed' : 'expanded'} />
192+
</div>
193+
);
194+
}
195+
155196
type DisplayNameProps = {|
156197
displayName: string | null,
157198
id: number,

src/devtools/views/Components/Tree.js

+33-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
1212
import { FixedSizeList } from 'react-window';
1313
import { TreeContext } from './TreeContext';
1414
import { SettingsContext } from '../Settings/SettingsContext';
15-
import { BridgeContext } from '../context';
15+
import { BridgeContext, StoreContext } from '../context';
1616
import ElementView from './Element';
1717
import InspectHostNodesToggle from './InspectHostNodesToggle';
1818
import OwnersStack from './OwnersStack';
@@ -37,12 +37,14 @@ export default function Tree(props: Props) {
3737
getElementAtIndex,
3838
numElements,
3939
ownerStack,
40+
selectedElementID,
4041
selectedElementIndex,
4142
selectNextElementInTree,
4243
selectParentElementInTree,
4344
selectPreviousElementInTree,
4445
} = useContext(TreeContext);
4546
const bridge = useContext(BridgeContext);
47+
const store = useContext(StoreContext);
4648
// $FlowFixMe https://github.com/facebook/flow/issues/7341
4749
const listRef = useRef<FixedSizeList<ItemData> | null>(null);
4850
const treeRef = useRef<HTMLDivElement | null>(null);
@@ -77,17 +79,43 @@ export default function Tree(props: Props) {
7779
return;
7880
}
7981

82+
let element;
83+
8084
// eslint-disable-next-line default-case
8185
switch (event.key) {
8286
case 'ArrowDown':
8387
selectNextElementInTree();
8488
event.preventDefault();
8589
break;
8690
case 'ArrowLeft':
87-
selectParentElementInTree();
91+
element =
92+
selectedElementID !== null
93+
? store.getElementByID(selectedElementID)
94+
: null;
95+
if (
96+
element !== null &&
97+
element.children.length > 0 &&
98+
!element.isCollapsed
99+
) {
100+
store.toggleIsCollapsed(element.id, true);
101+
} else {
102+
selectParentElementInTree();
103+
}
88104
break;
89105
case 'ArrowRight':
90-
selectNextElementInTree();
106+
element =
107+
selectedElementID !== null
108+
? store.getElementByID(selectedElementID)
109+
: null;
110+
if (
111+
element !== null &&
112+
element.children.length > 0 &&
113+
element.isCollapsed
114+
) {
115+
store.toggleIsCollapsed(element.id, false);
116+
} else {
117+
selectNextElementInTree();
118+
}
91119
event.preventDefault();
92120
break;
93121
case 'ArrowUp':
@@ -107,9 +135,11 @@ export default function Tree(props: Props) {
107135
ownerDocument.removeEventListener('keydown', handleKeyDown);
108136
};
109137
}, [
138+
selectedElementID,
110139
selectNextElementInTree,
111140
selectParentElementInTree,
112141
selectPreviousElementInTree,
142+
store,
113143
]);
114144

115145
// Let react-window know to re-render any time the underlying tree data changes.

0 commit comments

Comments
 (0)