Support highlighting of all DOM elements of Fragments, not just first
Fixes https://github.com/bvaughn/react-devtools-experimental/issues/131 Uses the new function `findAllCurrentHostFibers`. Removes dependency on React's `renderer.findHostInstanceByFiber` function which used to highlight only the first DOM element of a Fragment. Reworked `src/backend/views/Overlay` to support highlighting more than one element rectangle annotated with one tooltip. Fixed minor issues with the tooltip position calculation.
Ivan Babak committed
Apr 21, 2019 at 01:06 UTC
281616f462866f382c0fead366c7b2b5172a1ce5
5 files changed
+250
-100
src/backend/agent.js
+6
-5
@@ -276,18 +276,19 @@ export default class Agent extends EventEmitter {
276
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
277
}
278
279
- let node: HTMLElement | null = null;
279
+ let nodes: ?Array<HTMLElement> = null;
280
if (renderer !== null) {
281
- node = ((renderer.findNativeByFiberID(id): any): HTMLElement);
281
+ nodes = ((renderer.findNativeByFiberID(id): any): ?Array<HTMLElement>);
282
}
283
284
- if (node != null) {
284
+ if (nodes != null && nodes[0] != null) {
285
+ const node = nodes[0];
286
if (scrollIntoView && typeof node.scrollIntoView === 'function') {
287
// If the node isn't visible show it before highlighting it.
288
// We may want to reconsider this; it might be a little disruptive.
289
node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
290
}
290
- showOverlay(((node: any): HTMLElement), displayName, hideAfterTimeout);
291
+ showOverlay(nodes, displayName, hideAfterTimeout);
292
if (openNativeElementsPanel) {
293
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
294
this._bridge.send('syncSelectionToNativeElementsPanel');
@@ -585,7 +586,7 @@ export default class Agent extends EventEmitter {
586
587
// Don't pass the name explicitly.
588
// It will be inferred from DOM tag and Fiber owner.
588
- showOverlay(target, null, false);
589
+ showOverlay([target], null, false);
590
591
this._selectFiberForNode(target);
592
};
src/backend/renderer.js
+43
-9
@@ -1258,24 +1258,58 @@ export function attach(
1258
currentRootID = -1;
1259
}
1260
1261
+ function findAllCurrentHostFibers(parent: Fiber): $ReadOnlyArray<Fiber> {
1262
+ const fibers = [];
1263
+ const currentParent = findCurrentFiberUsingSlowPath(parent);
1264
+ if (!currentParent) {
1265
+ return fibers;
1266
+ }
1267
+
1268
+ // Next we'll drill down this component to find all HostComponent/Text.
1269
+ let node: Fiber = currentParent;
1270
+ while (true) {
1271
+ if (node.tag === HostComponent || node.tag === HostText) {
1272
+ fibers.push(node);
1273
+ } else if (node.child) {
1274
+ node.child.return = node;
1275
+ node = node.child;
1276
+ continue;
1277
+ }
1278
+ if (node === currentParent) {
1279
+ return fibers;
1280
+ }
1281
+ while (!node.sibling) {
1282
+ if (!node.return || node.return === currentParent) {
1283
+ return fibers;
1284
+ }
1285
+ node = node.return;
1286
+ }
1287
+ node.sibling.return = node.return;
1288
+ node = node.sibling;
1289
+ }
1290
+ // Flow needs the return here, but ESLint complains about it.
1291
+ // eslint-disable-next-line no-unreachable
1292
+ return fibers;
1293
+ }
1294
+
1295
function findNativeByFiberID(id: number) {
1296
try {
1263
- const fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
1297
+ let fiber = findCurrentFiberUsingSlowPath(idToFiberMap.get(id));
1298
if (fiber === null) {
1299
return null;
1300
}
1301
+ // Special case for a timed-out Suspense.
1302
const isTimedOutSuspense =
1303
fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
1269
- if (!isTimedOutSuspense) {
1270
- // Normal case.
1271
- return renderer.findHostInstanceByFiber(fiber);
1272
- } else {
1304
+ if (isTimedOutSuspense) {
1305
// A timed-out Suspense's findDOMNode is useless.
1306
// Try our best to find the fallback directly.
1307
const maybeFallbackFiber =
1308
(fiber.child && fiber.child.sibling) || fiber;
1277
- return renderer.findHostInstanceByFiber(maybeFallbackFiber);
1309
+ fiber = maybeFallbackFiber;
1310
}
1311
+ const hostFibers = findAllCurrentHostFibers(fiber);
1312
+ return hostFibers.map(hostFiber => hostFiber.stateNode).filter(Boolean);
1313
} catch (err) {
1314
// The fiber might have unmounted by now.
1315
return null;
@@ -1715,9 +1749,9 @@ export function attach(
1749
if (result.hooks !== null) {
1750
console.log('Hooks:', result.hooks);
1751
}
1718
- const nativeNode = findNativeByFiberID(id);
1719
- if (nativeNode !== null) {
1720
- console.log('Node:', nativeNode);
1752
+ const nativeNodes = findNativeByFiberID(id);
1753
+ if (nativeNodes !== null) {
1754
+ console.log('Nodes:', nativeNodes);
1755
}
1756
if (window.chrome || /firefox/i.test(navigator.userAgent)) {
1757
console.log(
src/backend/types.js
+1
-2
@@ -25,7 +25,6 @@ export type RendererID = number;
25
type Dispatcher = any;
26
27
export type ReactRenderer = {
28
- findHostInstanceByFiber: (fiber: Object) => ?NativeType,
28
findFiberByHostInstance: (hostInstance: NativeType) => ?Fiber,
29
version: string,
30
bundleType: BundleType,
@@ -103,7 +102,7 @@ export type PathMatch = {|
102
103
export type RendererInterface = {
104
cleanup: () => void,
106
- findNativeByFiberID: (id: number) => ?NativeType,
105
+ findNativeByFiberID: (id: number) => ?Array<NativeType>,
106
flushInitialOperations: () => void,
107
getBestMatchForTrackedPath: () => PathMatch | null,
108
getCommitDetails: (rootID: number, commitIndex: number) => CommitDetails,
src/backend/views/Highlighter.js
+3
-3
@@ -17,7 +17,7 @@ export function hideOverlay() {
17
}
18
19
export function showOverlay(
20
- element: HTMLElement | null,
20
+ elements: Array<HTMLElement> | null,
21
componentName: string | null,
22
hideAfterTimeout: boolean
23
) {
@@ -25,7 +25,7 @@ export function showOverlay(
25
clearTimeout(timeoutID);
26
}
27
28
- if (element == null) {
28
+ if (elements == null) {
29
return;
30
}
31
@@ -33,7 +33,7 @@ export function showOverlay(
33
overlay = new Overlay();
34
}
35
36
- overlay.inspect(element, componentName);
36
+ overlay.inspect(elements, componentName);
37
38
if (hideAfterTimeout) {
39
timeoutID = setTimeout(hideOverlay, SHOW_DURATION);
src/backend/views/Overlay.js
+197
-81
@@ -11,30 +11,17 @@ type Rect = {
11
width: number,
12
};
13
14
-// Note that this component is not affected by the active Theme,
15
-// because it highlights elements in the main Chrome window (outside of devtools).
14
+// Note that the Overlay components are not affected by the active Theme,
15
+// because they highlight elements in the main Chrome window (outside of devtools).
16
// The colors below were chosen to roughly match those used by Chrome devtools.
17
-export default class Overlay {
18
- window: window;
19
- container: HTMLElement;
17
+
18
+class OverlayRect {
19
node: HTMLElement;
20
border: HTMLElement;
21
padding: HTMLElement;
22
content: HTMLElement;
24
- tip: HTMLElement;
25
- nameSpan: HTMLElement;
26
- dimSpan: HTMLElement;
27
-
28
- constructor() {
29
- // Find the root window, because overlays are positioned relative to it.
30
- let currentWindow = window;
31
- while (currentWindow !== currentWindow.parent) {
32
- currentWindow = currentWindow.parent;
33
- }
23
35
- const doc = currentWindow.document;
36
- this.window = currentWindow;
37
- this.container = doc.createElement('div');
24
+ constructor(doc, container) {
25
this.node = doc.createElement('div');
26
this.border = doc.createElement('div');
27
this.padding = doc.createElement('div');
@@ -50,8 +37,59 @@ export default class Overlay {
37
position: 'fixed',
38
});
39
40
+ this.node.style.zIndex = '10000000';
41
+
42
+ this.node.appendChild(this.border);
43
+ this.border.appendChild(this.padding);
44
+ this.padding.appendChild(this.content);
45
+ container.appendChild(this.node);
46
+ }
47
+
48
+ remove() {
49
+ if (this.node.parentNode) {
50
+ this.node.parentNode.removeChild(this.node);
51
+ }
52
+ }
53
+
54
+ update(box, dims) {
55
+ boxWrap(dims, 'margin', this.node);
56
+ boxWrap(dims, 'border', this.border);
57
+ boxWrap(dims, 'padding', this.padding);
58
+
59
+ assign(this.content.style, {
60
+ height:
61
+ box.height -
62
+ dims.borderTop -
63
+ dims.borderBottom -
64
+ dims.paddingTop -
65
+ dims.paddingBottom +
66
+ 'px',
67
+ width:
68
+ box.width -
69
+ dims.borderLeft -
70
+ dims.borderRight -
71
+ dims.paddingLeft -
72
+ dims.paddingRight +
73
+ 'px',
74
+ });
75
+
76
+ assign(this.node.style, {
77
+ top: box.top - dims.marginTop + 'px',
78
+ left: box.left - dims.marginLeft + 'px',
79
+ });
80
+ }
81
+}
82
+
83
+class OverlayTip {
84
+ tip: HTMLElement;
85
+ nameSpan: HTMLElement;
86
+ dimSpan: HTMLElement;
87
+
88
+ constructor(doc, container) {
89
this.tip = doc.createElement('div');
90
assign(this.tip.style, {
91
+ display: 'flex',
92
+ flexFlow: 'row nowrap',
93
backgroundColor: '#333740',
94
borderRadius: '2px',
95
fontFamily:
@@ -61,6 +99,7 @@ export default class Overlay {
99
pointerEvents: 'none',
100
position: 'fixed',
101
fontSize: '12px',
102
+ whiteSpace: 'nowrap',
103
});
104
105
this.nameSpan = doc.createElement('span');
@@ -77,80 +116,151 @@ export default class Overlay {
116
color: '#d7d7d7',
117
});
118
80
- this.container.style.zIndex = '10000000';
81
- this.node.style.zIndex = '10000000';
119
this.tip.style.zIndex = '10000000';
83
- this.container.appendChild(this.node);
84
- this.container.appendChild(this.tip);
85
- this.node.appendChild(this.border);
86
- this.border.appendChild(this.padding);
87
- this.padding.appendChild(this.content);
120
+ container.appendChild(this.tip);
121
+ }
122
+
123
+ remove() {
124
+ if (this.tip.parentNode) {
125
+ this.tip.parentNode.removeChild(this.tip);
126
+ }
127
+ }
128
+
129
+ updateText(name, width, height) {
130
+ this.nameSpan.textContent = name;
131
+ this.dimSpan.textContent =
132
+ Math.round(width) + 'px × ' + Math.round(height) + 'px';
133
+ }
134
+
135
+ updatePosition(dims, bounds) {
136
+ const tipRect = this.tip.getBoundingClientRect();
137
+ const tipPos = findTipPos(dims, bounds, {
138
+ width: tipRect.width,
139
+ height: tipRect.height,
140
+ });
141
+ assign(this.tip.style, tipPos.style);
142
+ }
143
+}
144
+
145
+export default class Overlay {
146
+ window: window;
147
+ tipBoundsWindow: window;
148
+ container: HTMLElement;
149
+ tip: OverlayTip;
150
+ rects: Array<OverlayRect>;
151
+
152
+ constructor() {
153
+ // Find the root window, because overlays are positioned relative to it.
154
+ let currentWindow = window;
155
+ while (currentWindow !== currentWindow.parent) {
156
+ currentWindow = currentWindow.parent;
157
+ }
158
+ this.window = currentWindow;
159
+
160
+ // When opened in shells/dev, the tooltip should be bound by the app iframe, not by the topmost window.
161
+ let tipBoundsWindow = window;
162
+ while (
163
+ tipBoundsWindow !== tipBoundsWindow.parent &&
164
+ !tipBoundsWindow.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')
165
+ ) {
166
+ tipBoundsWindow = tipBoundsWindow.parent;
167
+ }
168
+ this.tipBoundsWindow = tipBoundsWindow;
169
+
170
+ const doc = currentWindow.document;
171
+ this.container = doc.createElement('div');
172
+ this.container.style.zIndex = '10000000';
173
+
174
+ this.tip = new OverlayTip(doc, this.container);
175
+ this.rects = [];
176
+
177
doc.body.appendChild(this.container);
178
}
179
180
remove() {
181
+ this.tip.remove();
182
+ this.rects.forEach(rect => {
183
+ rect.remove();
184
+ });
185
+ this.rects.length = 0;
186
if (this.container.parentNode) {
187
this.container.parentNode.removeChild(this.container);
188
}
189
}
190
97
- inspect(node: HTMLElement, name?: ?string) {
191
+ inspect(nodes: Array<HTMLElement>, name?: ?string) {
192
// We can't get the size of text nodes or comment nodes. React as of v15
193
// heavily uses comment nodes to delimit text.
100
- if (node.nodeType !== Node.ELEMENT_NODE) {
194
+ const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
195
+
196
+ while (this.rects.length > elements.length) {
197
+ const rect = this.rects.pop();
198
+ rect.remove();
199
+ }
200
+ if (elements.length === 0) {
201
return;
202
}
103
- const box = getNestedBoundingClientRect(node, this.window);
104
- const dims = getElementDimensions(node);
203
106
- boxWrap(dims, 'margin', this.node);
107
- boxWrap(dims, 'border', this.border);
108
- boxWrap(dims, 'padding', this.padding);
109
-
110
- assign(this.content.style, {
111
- height:
112
- box.height -
113
- dims.borderTop -
114
- dims.borderBottom -
115
- dims.paddingTop -
116
- dims.paddingBottom +
117
- 'px',
118
- width:
119
- box.width -
120
- dims.borderLeft -
121
- dims.borderRight -
122
- dims.paddingLeft -
123
- dims.paddingRight +
124
- 'px',
125
- });
204
+ while (this.rects.length < elements.length) {
205
+ this.rects.push(new OverlayRect(this.window.document, this.container));
206
+ }
207
127
- assign(this.node.style, {
128
- top: box.top - dims.marginTop + 'px',
129
- left: box.left - dims.marginLeft + 'px',
208
+ const outerBox = {
209
+ top: Number.POSITIVE_INFINITY,
210
+ right: Number.NEGATIVE_INFINITY,
211
+ bottom: Number.NEGATIVE_INFINITY,
212
+ left: Number.POSITIVE_INFINITY,
213
+ };
214
+ elements.forEach((element, index) => {
215
+ const box = getNestedBoundingClientRect(element, this.window);
216
+ const dims = getElementDimensions(element);
217
+
218
+ outerBox.top = Math.min(outerBox.top, box.top - dims.marginTop);
219
+ outerBox.right = Math.max(
220
+ outerBox.right,
221
+ box.left + box.width + dims.marginRight
222
+ );
223
+ outerBox.bottom = Math.max(
224
+ outerBox.bottom,
225
+ box.top + box.height + dims.marginBottom
226
+ );
227
+ outerBox.left = Math.min(outerBox.left, box.left - dims.marginLeft);
228
+
229
+ const rect = this.rects[index];
230
+ rect.update(box, dims);
231
});
232
233
if (!name) {
133
- name = node.nodeName.toLowerCase();
134
- const ownerName = getOwnerDisplayName(node);
234
+ name = elements[0].nodeName.toLowerCase();
235
+ const ownerName = getOwnerDisplayName(elements[0]);
236
if (ownerName) {
237
name += ' (in ' + ownerName + ')';
238
}
239
}
240
140
- this.nameSpan.textContent = name;
141
- this.dimSpan.textContent =
142
- Math.round(box.width) + 'px × ' + Math.round(box.height) + 'px';
143
-
144
- const tipPos = findTipPos(
241
+ this.tip.updateText(
242
+ name,
243
+ outerBox.right - outerBox.left,
244
+ outerBox.bottom - outerBox.top
245
+ );
246
+ const tipBounds = getNestedBoundingClientRect(
247
+ this.tipBoundsWindow.document.documentElement,
248
+ this.window
249
+ );
250
+ this.tip.updatePosition(
251
{
146
- top: box.top - dims.marginTop,
147
- left: box.left - dims.marginLeft,
148
- height: box.height + dims.marginTop + dims.marginBottom,
149
- width: box.width + dims.marginLeft + dims.marginRight,
252
+ top: outerBox.top,
253
+ left: outerBox.left,
254
+ height: outerBox.bottom - outerBox.top,
255
+ width: outerBox.right - outerBox.left,
256
},
151
- this.window
257
+ {
258
+ top: tipBounds.top + this.tipBoundsWindow.scrollY,
259
+ left: tipBounds.left + this.tipBoundsWindow.scrollX,
260
+ height: this.tipBoundsWindow.innerHeight,
261
+ width: this.tipBoundsWindow.innerWidth,
262
+ }
263
);
153
- assign(this.tip.style, tipPos);
264
}
265
}
266
@@ -185,35 +295,41 @@ function getFiber(node) {
295
return null;
296
}
297
188
-function findTipPos(dims, win) {
189
- const tipHeight = 20;
298
+function findTipPos(dims, bounds, tipSize) {
299
+ const tipHeight = Math.max(tipSize.height, 20);
300
+ const tipWidth = Math.max(tipSize.width, 60);
301
const margin = 5;
302
+
303
let top;
192
- if (dims.top + dims.height + tipHeight <= win.innerHeight) {
193
- if (dims.top + dims.height < 0) {
194
- top = margin;
304
+ if (dims.top + dims.height + tipHeight <= bounds.top + bounds.height) {
305
+ if (dims.top + dims.height < bounds.top + 0) {
306
+ top = bounds.top + margin;
307
} else {
308
top = dims.top + dims.height + margin;
309
}
198
- } else if (dims.top - tipHeight <= win.innerHeight) {
199
- if (dims.top - tipHeight - margin < margin) {
200
- top = margin;
310
+ } else if (dims.top - tipHeight <= bounds.top + bounds.height) {
311
+ if (dims.top - tipHeight - margin < bounds.top + margin) {
312
+ top = bounds.top + margin;
313
} else {
314
top = dims.top - tipHeight - margin;
315
}
316
} else {
205
- top = win.innerHeight - tipHeight - margin;
317
+ top = bounds.top + bounds.height - tipHeight - margin;
318
}
319
208
- top += 'px';
209
-
210
- if (dims.left < 0) {
211
- return { top, left: margin };
320
+ let left = dims.left + margin;
321
+ if (dims.left < bounds.left) {
322
+ left = bounds.left + margin;
323
}
213
- if (dims.left + 200 > win.innerWidth) {
214
- return { top, right: margin };
324
+ if (dims.left + tipWidth > bounds.left + bounds.width) {
325
+ left = bounds.left + bounds.width - tipWidth - margin;
326
}
216
- return { top, left: dims.left + margin + 'px' };
327
+
328
+ top += 'px';
329
+ left += 'px';
330
+ return {
331
+ style: { top, left },
332
+ };
333
}
334
335
export function getElementDimensions(domElement: Element) {