Initial stab at 'View in DOM' button
Brian Vaughn committed
Feb 8, 2019 at 18:48 UTC
a6126c9e275edb1decdabdb93be7f69b35bc6606
5 files changed
+391
-2
src/backend/agent.js
+37
@@ -2,6 +2,7 @@
2
3
import EventEmitter from 'events';
4
import { __DEBUG__ } from '../constants';
5
+import { hideOverlay, showOverlay } from './views/Highlighter';
6
7
import type { RendererID, RendererInterface } from './types';
8
import type { Bridge } from '../types';
@@ -26,12 +27,48 @@ export default class Agent extends EventEmitter {
27
28
bridge.addListener('shutdown', () => this.emit('shutdown'));
29
30
+ bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
31
bridge.addListener('inspectElement', this.inspectElement);
32
bridge.addListener('selectElement', this.selectElement);
33
// TODO Listen to bridge for things like selection.
34
// bridge.on('...'), this...);
35
}
36
37
+ highlightElementInDOM = ({
38
+ displayName,
39
+ id,
40
+ rendererID,
41
+ }: {
42
+ displayName: string,
43
+ id: number,
44
+ rendererID: number,
45
+ }) => {
46
+ const renderer = this._rendererInterfaces[rendererID];
47
+ if (renderer == null) {
48
+ console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
49
+ }
50
+
51
+ let node: HTMLElement | null = null;
52
+ if (
53
+ renderer !== null &&
54
+ typeof renderer.getNativeFromReactElement === 'function'
55
+ ) {
56
+ node = ((renderer.getNativeFromReactElement(id): any): HTMLElement);
57
+ }
58
+
59
+ if (node != null) {
60
+ if (typeof node.scrollIntoView === 'function') {
61
+ // If the node isn't visible show it before highlighting it.
62
+ // We may want to reconsider this; it might be a little disruptive.
63
+ node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
64
+ }
65
+
66
+ showOverlay(((node: any): HTMLElement), displayName);
67
+ } else {
68
+ hideOverlay();
69
+ }
70
+ };
71
+
72
inspectElement = ({ id, rendererID }: { id: number, rendererID: number }) => {
73
const renderer = this._rendererInterfaces[rendererID];
74
if (renderer == null) {
src/backend/renderer.js
+2
-2
@@ -816,9 +816,9 @@ export function attach(
816
817
// The naming is confusing.
818
// They deal with opaque nodes (fibers), not elements.
819
- function getNativeFromReactElement(fiber) {
819
+ function getNativeFromReactElement(id: number) {
820
try {
821
- const primaryFiber = fiber;
821
+ const primaryFiber = getPrimaryFiber(idToFiberMap.get(id));
822
const hostInstance = renderer.findHostInstanceByFiber(primaryFiber);
823
return hostInstance;
824
} catch (err) {
src/backend/views/Highlighter.js
new
+38
@@ -0,0 +1,38 @@
1
+// @flow
2
+
3
+import Overlay from './Overlay';
4
+
5
+const SHOW_DURATION = 2000;
6
+
7
+let timeoutID: TimeoutID | null = null;
8
+let overlay: Overlay | null = null;
9
+
10
+export function hideOverlay() {
11
+ timeoutID = null;
12
+
13
+ if (overlay !== null) {
14
+ overlay.remove();
15
+ overlay = null;
16
+ }
17
+}
18
+
19
+export function showOverlay(
20
+ element: HTMLElement | null,
21
+ componentName: string = ''
22
+) {
23
+ if (timeoutID !== null) {
24
+ clearTimeout(timeoutID);
25
+ }
26
+
27
+ if (element == null) {
28
+ return;
29
+ }
30
+
31
+ if (overlay === null) {
32
+ overlay = new Overlay();
33
+ }
34
+
35
+ overlay.inspect(element, componentName);
36
+
37
+ timeoutID = setTimeout(hideOverlay, SHOW_DURATION);
38
+}
src/backend/views/Overlay.js
new
+298
@@ -0,0 +1,298 @@
1
+// @flow
2
+
3
+import assign from 'object-assign';
4
+
5
+type Rect = {
6
+ bottom: number,
7
+ height: number,
8
+ left: number,
9
+ right: number,
10
+ top: number,
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).
16
+// The colors below were chosen to roughly match those used by Chrome devtools.
17
+export default class Overlay {
18
+ win: Object;
19
+ container: HTMLElement;
20
+ node: HTMLElement;
21
+ border: HTMLElement;
22
+ padding: HTMLElement;
23
+ content: HTMLElement;
24
+ tip: HTMLElement;
25
+ nameSpan: HTMLElement;
26
+ dimSpan: HTMLElement;
27
+
28
+ constructor() {
29
+ const doc = window.document;
30
+ this.win = window;
31
+ this.container = doc.createElement('div');
32
+ this.node = doc.createElement('div');
33
+ this.border = doc.createElement('div');
34
+ this.padding = doc.createElement('div');
35
+ this.content = doc.createElement('div');
36
+
37
+ this.border.style.borderColor = overlayStyles.border;
38
+ this.padding.style.borderColor = overlayStyles.padding;
39
+ this.content.style.backgroundColor = overlayStyles.background;
40
+
41
+ assign(this.node.style, {
42
+ borderColor: overlayStyles.margin,
43
+ pointerEvents: 'none',
44
+ position: 'fixed',
45
+ });
46
+
47
+ this.tip = doc.createElement('div');
48
+ assign(this.tip.style, {
49
+ backgroundColor: '#333740',
50
+ borderRadius: '2px',
51
+ fontFamily:
52
+ '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
53
+ fontWeight: 'bold',
54
+ padding: '3px 5px',
55
+ position: 'fixed',
56
+ fontSize: '12px',
57
+ });
58
+
59
+ this.nameSpan = doc.createElement('span');
60
+ this.tip.appendChild(this.nameSpan);
61
+ assign(this.nameSpan.style, {
62
+ color: '#ee78e6',
63
+ borderRight: '1px solid #aaaaaa',
64
+ paddingRight: '0.5rem',
65
+ marginRight: '0.5rem',
66
+ });
67
+ this.dimSpan = doc.createElement('span');
68
+ this.tip.appendChild(this.dimSpan);
69
+ assign(this.dimSpan.style, {
70
+ color: '#d7d7d7',
71
+ });
72
+
73
+ this.container.style.zIndex = '10000000';
74
+ this.node.style.zIndex = '10000000';
75
+ this.tip.style.zIndex = '10000000';
76
+ this.container.appendChild(this.node);
77
+ this.container.appendChild(this.tip);
78
+ this.node.appendChild(this.border);
79
+ this.border.appendChild(this.padding);
80
+ this.padding.appendChild(this.content);
81
+ doc.body.appendChild(this.container);
82
+ }
83
+
84
+ remove() {
85
+ if (this.container.parentNode) {
86
+ this.container.parentNode.removeChild(this.container);
87
+ }
88
+ }
89
+
90
+ inspect(node: HTMLElement, name?: ?string) {
91
+ // We can't get the size of text nodes or comment nodes. React as of v15
92
+ // heavily uses comment nodes to delimit text.
93
+ if (node.nodeType !== Node.ELEMENT_NODE) {
94
+ return;
95
+ }
96
+ const box = getNestedBoundingClientRect(node, this.win);
97
+ const dims = getElementDimensions(node);
98
+
99
+ boxWrap(dims, 'margin', this.node);
100
+ boxWrap(dims, 'border', this.border);
101
+ boxWrap(dims, 'padding', this.padding);
102
+
103
+ assign(this.content.style, {
104
+ height:
105
+ box.height -
106
+ dims.borderTop -
107
+ dims.borderBottom -
108
+ dims.paddingTop -
109
+ dims.paddingBottom +
110
+ 'px',
111
+ width:
112
+ box.width -
113
+ dims.borderLeft -
114
+ dims.borderRight -
115
+ dims.paddingLeft -
116
+ dims.paddingRight +
117
+ 'px',
118
+ });
119
+
120
+ assign(this.node.style, {
121
+ top: box.top - dims.marginTop + 'px',
122
+ left: box.left - dims.marginLeft + 'px',
123
+ });
124
+
125
+ this.nameSpan.textContent = name || node.nodeName.toLowerCase();
126
+ this.dimSpan.textContent =
127
+ Math.round(box.width) + 'px × ' + Math.round(box.height) + 'px';
128
+
129
+ const tipPos = findTipPos(
130
+ {
131
+ top: box.top - dims.marginTop,
132
+ left: box.left - dims.marginLeft,
133
+ height: box.height + dims.marginTop + dims.marginBottom,
134
+ width: box.width + dims.marginLeft + dims.marginRight,
135
+ },
136
+ this.win
137
+ );
138
+ assign(this.tip.style, tipPos);
139
+ }
140
+}
141
+
142
+function findTipPos(dims, win) {
143
+ const tipHeight = 20;
144
+ const margin = 5;
145
+ let top;
146
+ if (dims.top + dims.height + tipHeight <= win.innerHeight) {
147
+ if (dims.top + dims.height < 0) {
148
+ top = margin;
149
+ } else {
150
+ top = dims.top + dims.height + margin;
151
+ }
152
+ } else if (dims.top - tipHeight <= win.innerHeight) {
153
+ if (dims.top - tipHeight - margin < margin) {
154
+ top = margin;
155
+ } else {
156
+ top = dims.top - tipHeight - margin;
157
+ }
158
+ } else {
159
+ top = win.innerHeight - tipHeight - margin;
160
+ }
161
+
162
+ top += 'px';
163
+
164
+ if (dims.left < 0) {
165
+ return { top, left: margin };
166
+ }
167
+ if (dims.left + 200 > win.innerWidth) {
168
+ return { top, right: margin };
169
+ }
170
+ return { top, left: dims.left + margin + 'px' };
171
+}
172
+
173
+function getElementDimensions(domElement) {
174
+ const calculatedStyle = window.getComputedStyle(domElement);
175
+ return {
176
+ borderLeft: +calculatedStyle.borderLeftWidth.match(/[0-9]*/)[0],
177
+ borderRight: +calculatedStyle.borderRightWidth.match(/[0-9]*/)[0],
178
+ borderTop: +calculatedStyle.borderTopWidth.match(/[0-9]*/)[0],
179
+ borderBottom: +calculatedStyle.borderBottomWidth.match(/[0-9]*/)[0],
180
+ marginLeft: +calculatedStyle.marginLeft.match(/[0-9]*/)[0],
181
+ marginRight: +calculatedStyle.marginRight.match(/[0-9]*/)[0],
182
+ marginTop: +calculatedStyle.marginTop.match(/[0-9]*/)[0],
183
+ marginBottom: +calculatedStyle.marginBottom.match(/[0-9]*/)[0],
184
+ paddingLeft: +calculatedStyle.paddingLeft.match(/[0-9]*/)[0],
185
+ paddingRight: +calculatedStyle.paddingRight.match(/[0-9]*/)[0],
186
+ paddingTop: +calculatedStyle.paddingTop.match(/[0-9]*/)[0],
187
+ paddingBottom: +calculatedStyle.paddingBottom.match(/[0-9]*/)[0],
188
+ };
189
+}
190
+
191
+// Get the window object for the document that a node belongs to,
192
+// or return null if it cannot be found (node not attached to DOM,
193
+// etc).
194
+function getOwnerWindow(node: HTMLElement): typeof window | null {
195
+ if (!node.ownerDocument) {
196
+ return null;
197
+ }
198
+ return node.ownerDocument.defaultView;
199
+}
200
+
201
+// Get the iframe containing a node, or return null if it cannot
202
+// be found (node not within iframe, etc).
203
+function getOwnerIframe(node: HTMLElement): HTMLElement | null {
204
+ const nodeWindow = getOwnerWindow(node);
205
+ if (nodeWindow) {
206
+ return nodeWindow.frameElement;
207
+ }
208
+ return null;
209
+}
210
+
211
+// Get a bounding client rect for a node, with an
212
+// offset added to compensate for its border.
213
+function getBoundingClientRectWithBorderOffset(node: HTMLElement) {
214
+ const dimensions = getElementDimensions(node);
215
+ return mergeRectOffsets([
216
+ node.getBoundingClientRect(),
217
+ {
218
+ top: dimensions.borderTop,
219
+ left: dimensions.borderLeft,
220
+ bottom: dimensions.borderBottom,
221
+ right: dimensions.borderRight,
222
+ // This width and height won't get used by mergeRectOffsets (since this
223
+ // is not the first rect in the array), but we set them so that this
224
+ // object typechecks as a ClientRect.
225
+ width: 0,
226
+ height: 0,
227
+ },
228
+ ]);
229
+}
230
+
231
+// Add together the top, left, bottom, and right properties of
232
+// each ClientRect, but keep the width and height of the first one.
233
+function mergeRectOffsets(rects: Array<Rect>): Rect {
234
+ return rects.reduce((previousRect, rect) => {
235
+ if (previousRect == null) {
236
+ return rect;
237
+ }
238
+
239
+ return {
240
+ top: previousRect.top + rect.top,
241
+ left: previousRect.left + rect.left,
242
+ width: previousRect.width,
243
+ height: previousRect.height,
244
+ bottom: previousRect.bottom + rect.bottom,
245
+ right: previousRect.right + rect.right,
246
+ };
247
+ });
248
+}
249
+
250
+// Calculate a boundingClientRect for a node relative to boundaryWindow,
251
+// taking into account any offsets caused by intermediate iframes.
252
+function getNestedBoundingClientRect(
253
+ node: HTMLElement,
254
+ boundaryWindow: typeof window
255
+): Rect {
256
+ const ownerIframe = getOwnerIframe(node);
257
+ if (ownerIframe && ownerIframe !== boundaryWindow) {
258
+ const rects = [node.getBoundingClientRect()];
259
+ let currentIframe = ownerIframe;
260
+ let onlyOneMore = false;
261
+ while (currentIframe) {
262
+ const rect = getBoundingClientRectWithBorderOffset(currentIframe);
263
+ rects.push(rect);
264
+ currentIframe = getOwnerIframe(currentIframe);
265
+
266
+ if (onlyOneMore) {
267
+ break;
268
+ }
269
+ // We don't want to calculate iframe offsets upwards beyond
270
+ // the iframe containing the boundaryWindow, but we
271
+ // need to calculate the offset relative to the boundaryWindow.
272
+ if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) {
273
+ onlyOneMore = true;
274
+ }
275
+ }
276
+
277
+ return mergeRectOffsets(rects);
278
+ } else {
279
+ return node.getBoundingClientRect();
280
+ }
281
+}
282
+
283
+function boxWrap(dims, what, node) {
284
+ assign(node.style, {
285
+ borderTopWidth: dims[what + 'Top'] + 'px',
286
+ borderLeftWidth: dims[what + 'Left'] + 'px',
287
+ borderRightWidth: dims[what + 'Right'] + 'px',
288
+ borderBottomWidth: dims[what + 'Bottom'] + 'px',
289
+ borderStyle: 'solid',
290
+ });
291
+}
292
+
293
+const overlayStyles = {
294
+ background: 'rgba(120, 170, 210, 0.7)',
295
+ padding: 'rgba(77, 200, 0, 0.3)',
296
+ margin: 'rgba(255, 155, 0, 0.3)',
297
+ border: 'rgba(255, 200, 50, 0.3)',
298
+};
src/devtools/views/SelectedElement.js
+16
@@ -22,12 +22,27 @@ export type Props = {||};
22
23
export default function SelectedElement(_: Props) {
24
const { selectedElementID } = useContext(TreeContext);
25
+ const bridge = useContext(BridgeContext);
26
const store = useContext(StoreContext);
27
const element =
28
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
29
30
const inspectedElement = useInspectedElement(selectedElementID);
31
32
+ const handleClick = useCallback(() => {
33
+ if (element !== null && selectedElementID !== null) {
34
+ const rendererID =
35
+ store.getRendererIDForElement(selectedElementID) || null;
36
+ if (rendererID !== null) {
37
+ bridge.send('highlightElementInDOM', {
38
+ displayName: element.displayName,
39
+ id: selectedElementID,
40
+ rendererID,
41
+ });
42
+ }
43
+ }
44
+ }, [bridge, selectedElementID, store]);
45
+
46
// TODO Make "view DOM" and "view source" buttons work
47
48
if (element === null) {
@@ -51,6 +66,7 @@ export default function SelectedElement(_: Props) {
66
67
<button
68
className={styles.IconButton}
69
+ onClick={handleClick}
70
title="Highlight this element in the page"
71
>
72
<ButtonIcon type="view-dom" />