main
js 329 lines 8.98 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import {getElementDimensions, getNestedBoundingClientRect} from '../utils';
11
12 import type {Rect} from '../utils';
13 import type Agent from 'react-devtools-shared/src/backend/agent';
14
15 type Box = {top: number, left: number, width: number, height: number};
16
17 const assign = Object.assign;
18
19 // Note that the Overlay components are not affected by the active Theme,
20 // because they highlight elements in the main Chrome window (outside of devtools).
21 // The colors below were chosen to roughly match those used by Chrome devtools.
22
23 class OverlayRect {
24 node: HTMLElement;
25 border: HTMLElement;
26 padding: HTMLElement;
27 content: HTMLElement;
28
29 constructor(doc: Document, container: HTMLElement) {
30 this.node = doc.createElement('div');
31 this.border = doc.createElement('div');
32 this.padding = doc.createElement('div');
33 this.content = doc.createElement('div');
34
35 this.border.style.borderColor = overlayStyles.border;
36 this.padding.style.borderColor = overlayStyles.padding;
37 this.content.style.backgroundColor = overlayStyles.background;
38
39 assign(this.node.style, {
40 borderColor: overlayStyles.margin,
41 pointerEvents: 'none',
42 position: 'fixed',
43 });
44
45 this.node.style.zIndex = '10000000';
46
47 this.node.appendChild(this.border);
48 this.border.appendChild(this.padding);
49 this.padding.appendChild(this.content);
50 container.appendChild(this.node);
51 }
52
53 remove() {
54 if (this.node.parentNode) {
55 this.node.parentNode.removeChild(this.node);
56 }
57 }
58
59 update(box: Rect, dims: any) {
60 boxWrap(dims, 'margin', this.node);
61 boxWrap(dims, 'border', this.border);
62 boxWrap(dims, 'padding', this.padding);
63
64 assign(this.content.style, {
65 height:
66 box.height -
67 dims.borderTop -
68 dims.borderBottom -
69 dims.paddingTop -
70 dims.paddingBottom +
71 'px',
72 width:
73 box.width -
74 dims.borderLeft -
75 dims.borderRight -
76 dims.paddingLeft -
77 dims.paddingRight +
78 'px',
79 });
80
81 assign(this.node.style, {
82 top: box.top - dims.marginTop + 'px',
83 left: box.left - dims.marginLeft + 'px',
84 });
85 }
86 }
87
88 class OverlayTip {
89 tip: HTMLElement;
90 nameSpan: HTMLElement;
91 dimSpan: HTMLElement;
92
93 constructor(doc: Document, container: HTMLElement) {
94 this.tip = doc.createElement('div');
95 assign(this.tip.style, {
96 display: 'flex',
97 flexFlow: 'row nowrap',
98 backgroundColor: '#333740',
99 borderRadius: '2px',
100 fontFamily:
101 '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
102 fontWeight: 'bold',
103 padding: '3px 5px',
104 pointerEvents: 'none',
105 position: 'fixed',
106 fontSize: '12px',
107 whiteSpace: 'nowrap',
108 });
109
110 this.nameSpan = doc.createElement('span');
111 this.tip.appendChild(this.nameSpan);
112 assign(this.nameSpan.style, {
113 color: '#ee78e6',
114 borderRight: '1px solid #aaaaaa',
115 paddingRight: '0.5rem',
116 marginRight: '0.5rem',
117 });
118 this.dimSpan = doc.createElement('span');
119 this.tip.appendChild(this.dimSpan);
120 assign(this.dimSpan.style, {
121 color: '#d7d7d7',
122 });
123
124 this.tip.style.zIndex = '10000000';
125 container.appendChild(this.tip);
126 }
127
128 remove() {
129 if (this.tip.parentNode) {
130 this.tip.parentNode.removeChild(this.tip);
131 }
132 }
133
134 updateText(name: string, width: number, height: number) {
135 this.nameSpan.textContent = name;
136 this.dimSpan.textContent =
137 Math.round(width) + 'px × ' + Math.round(height) + 'px';
138 }
139
140 updatePosition(dims: Box, bounds: Box) {
141 const tipRect = this.tip.getBoundingClientRect();
142 const tipPos = findTipPos(dims, bounds, {
143 width: tipRect.width,
144 height: tipRect.height,
145 });
146 assign(this.tip.style, tipPos.style);
147 }
148 }
149
150 export default class Overlay {
151 window: any;
152 tipBoundsWindow: any;
153 container: HTMLElement;
154 tip: OverlayTip;
155 rects: Array<OverlayRect>;
156 agent: Agent;
157
158 constructor(agent: Agent) {
159 // Find the root window, because overlays are positioned relative to it.
160 const currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window;
161 this.window = currentWindow;
162
163 // When opened in shells/dev, the tooltip should be bound by the app iframe, not by the topmost window.
164 const tipBoundsWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window;
165 this.tipBoundsWindow = tipBoundsWindow;
166
167 const doc = currentWindow.document;
168 this.container = doc.createElement('div');
169 this.container.style.zIndex = '10000000';
170
171 this.tip = new OverlayTip(doc, this.container);
172 this.rects = [];
173
174 this.agent = agent;
175
176 doc.body.appendChild(this.container);
177 }
178
179 remove() {
180 this.tip.remove();
181 this.rects.forEach(rect => {
182 rect.remove();
183 });
184 this.rects.length = 0;
185 if (this.container.parentNode) {
186 this.container.parentNode.removeChild(this.container);
187 }
188 }
189
190 inspect(nodes: $ReadOnlyArray<HTMLElement | Text>, name?: ?string) {
191 // We can't get the size of text nodes or comment nodes. React as of v15
192 // heavily uses comment nodes to delimit text.
193 // TODO: We actually can measure text nodes. We should.
194 const elements: $ReadOnlyArray<HTMLElement> = nodes.filter(
195 node => node.nodeType === Node.ELEMENT_NODE,
196 ) as any;
197
198 while (this.rects.length > elements.length) {
199 const rect = this.rects.pop();
200 // $FlowFixMe[incompatible-use]
201 rect.remove();
202 }
203 if (elements.length === 0) {
204 return;
205 }
206
207 while (this.rects.length < elements.length) {
208 this.rects.push(new OverlayRect(this.window.document, this.container));
209 }
210
211 const outerBox = {
212 top: Number.POSITIVE_INFINITY,
213 right: Number.NEGATIVE_INFINITY,
214 bottom: Number.NEGATIVE_INFINITY,
215 left: Number.POSITIVE_INFINITY,
216 };
217 elements.forEach((element, index) => {
218 const box = getNestedBoundingClientRect(element, this.window);
219 const dims = getElementDimensions(element);
220
221 outerBox.top = Math.min(outerBox.top, box.top - dims.marginTop);
222 outerBox.right = Math.max(
223 outerBox.right,
224 box.left + box.width + dims.marginRight,
225 );
226 outerBox.bottom = Math.max(
227 outerBox.bottom,
228 box.top + box.height + dims.marginBottom,
229 );
230 outerBox.left = Math.min(outerBox.left, box.left - dims.marginLeft);
231
232 const rect = this.rects[index];
233 rect.update(box, dims);
234 });
235
236 if (!name) {
237 name = elements[0].nodeName.toLowerCase();
238
239 const node = elements[0];
240 const ownerName = this.agent.getComponentNameForHostInstance(node);
241 if (ownerName) {
242 name += ' (in ' + ownerName + ')';
243 }
244 }
245
246 this.tip.updateText(
247 name,
248 outerBox.right - outerBox.left,
249 outerBox.bottom - outerBox.top,
250 );
251 const tipBounds = getNestedBoundingClientRect(
252 this.tipBoundsWindow.document.documentElement,
253 this.window,
254 );
255
256 this.tip.updatePosition(
257 {
258 top: outerBox.top,
259 left: outerBox.left,
260 height: outerBox.bottom - outerBox.top,
261 width: outerBox.right - outerBox.left,
262 },
263 {
264 top: tipBounds.top + this.tipBoundsWindow.scrollY,
265 left: tipBounds.left + this.tipBoundsWindow.scrollX,
266 height: this.tipBoundsWindow.innerHeight,
267 width: this.tipBoundsWindow.innerWidth,
268 },
269 );
270 }
271 }
272
273 function findTipPos(
274 dims: Box,
275 bounds: Box,
276 tipSize: {height: number, width: number},
277 ) {
278 const tipHeight = Math.max(tipSize.height, 20);
279 const tipWidth = Math.max(tipSize.width, 60);
280 const margin = 5;
281
282 let top: number | string;
283 if (dims.top + dims.height + tipHeight <= bounds.top + bounds.height) {
284 if (dims.top + dims.height < bounds.top + 0) {
285 top = bounds.top + margin;
286 } else {
287 top = dims.top + dims.height + margin;
288 }
289 } else if (dims.top - tipHeight <= bounds.top + bounds.height) {
290 if (dims.top - tipHeight - margin < bounds.top + margin) {
291 top = bounds.top + margin;
292 } else {
293 top = dims.top - tipHeight - margin;
294 }
295 } else {
296 top = bounds.top + bounds.height - tipHeight - margin;
297 }
298
299 let left: number | string = dims.left + margin;
300 if (dims.left < bounds.left) {
301 left = bounds.left + margin;
302 }
303 if (dims.left + tipWidth > bounds.left + bounds.width) {
304 left = bounds.left + bounds.width - tipWidth - margin;
305 }
306
307 top += 'px';
308 left += 'px';
309 return {
310 style: {top, left},
311 };
312 }
313
314 function boxWrap(dims: any, what: string, node: HTMLElement) {
315 assign(node.style, {
316 borderTopWidth: dims[what + 'Top'] + 'px',
317 borderLeftWidth: dims[what + 'Left'] + 'px',
318 borderRightWidth: dims[what + 'Right'] + 'px',
319 borderBottomWidth: dims[what + 'Bottom'] + 'px',
320 borderStyle: 'solid',
321 });
322 }
323
324 const overlayStyles = {
325 background: 'rgba(120, 170, 210, 0.7)',
326 padding: 'rgba(77, 200, 0, 0.3)',
327 margin: 'rgba(255, 155, 0, 0.3)',
328 border: 'rgba(255, 200, 50, 0.3)',
329 };