@samitouri / QOS-React-2 / commits / a6126c9e27

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" />