| 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 | }; |