| 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 type Store from 'react-devtools-shared/src/devtools/store'; |
| 11 | import type { |
| 12 | Element, |
| 13 | SuspenseNode, |
| 14 | Rect, |
| 15 | } from 'react-devtools-shared/src/frontend/types'; |
| 16 | import typeof { |
| 17 | SyntheticMouseEvent, |
| 18 | SyntheticPointerEvent, |
| 19 | } from 'react-dom-bindings/src/events/SyntheticEvent'; |
| 20 | |
| 21 | import * as React from 'react'; |
| 22 | import {createContext, useContext, useLayoutEffect, useMemo} from 'react'; |
| 23 | import { |
| 24 | TreeDispatcherContext, |
| 25 | TreeStateContext, |
| 26 | } from '../Components/TreeContext'; |
| 27 | import {StoreContext} from '../context'; |
| 28 | import {useHighlightHostInstance} from '../hooks'; |
| 29 | import styles from './SuspenseRects.css'; |
| 30 | import { |
| 31 | SuspenseTreeStateContext, |
| 32 | SuspenseTreeDispatcherContext, |
| 33 | } from './SuspenseTreeContext'; |
| 34 | import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js'; |
| 35 | import type RBush from 'rbush'; |
| 36 | |
| 37 | function ScaledRect({ |
| 38 | className, |
| 39 | rect, |
| 40 | visible, |
| 41 | suspended, |
| 42 | selected, |
| 43 | hovered, |
| 44 | adjust, |
| 45 | ...props |
| 46 | }: { |
| 47 | className: string, |
| 48 | rect: Rect, |
| 49 | visible: boolean, |
| 50 | suspended: boolean, |
| 51 | selected?: boolean, |
| 52 | hovered?: boolean, |
| 53 | adjust?: boolean, |
| 54 | ... |
| 55 | }): React$Node { |
| 56 | const viewBox = useContext(ViewBox); |
| 57 | const width = (rect.width / viewBox.width) * 100 + '%'; |
| 58 | const height = (rect.height / viewBox.height) * 100 + '%'; |
| 59 | const x = ((rect.x - viewBox.x) / viewBox.width) * 100 + '%'; |
| 60 | const y = ((rect.y - viewBox.y) / viewBox.height) * 100 + '%'; |
| 61 | |
| 62 | return ( |
| 63 | <div |
| 64 | {...props} |
| 65 | className={styles.SuspenseRectsScaledRect + ' ' + className} |
| 66 | data-visible={visible} |
| 67 | data-suspended={suspended} |
| 68 | data-selected={selected} |
| 69 | data-hovered={hovered} |
| 70 | style={{ |
| 71 | // Shrink one pixel so that the bottom outline will line up with the top outline of the next one. |
| 72 | width: adjust ? 'calc(' + width + ' - 1px)' : width, |
| 73 | height: adjust ? 'calc(' + height + ' - 1px)' : height, |
| 74 | top: y, |
| 75 | left: x, |
| 76 | }} |
| 77 | /> |
| 78 | ); |
| 79 | } |
| 80 | |
| 81 | function SuspenseRects({ |
| 82 | suspenseID, |
| 83 | parentRects, |
| 84 | }: { |
| 85 | suspenseID: SuspenseNode['id'], |
| 86 | parentRects: null | Array<Rect>, |
| 87 | }): React$Node { |
| 88 | const store = useContext(StoreContext); |
| 89 | const treeDispatch = useContext(TreeDispatcherContext); |
| 90 | const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); |
| 91 | const {uniqueSuspendersOnly, timeline, hoveredTimelineIndex} = useContext( |
| 92 | SuspenseTreeStateContext, |
| 93 | ); |
| 94 | |
| 95 | const {inspectedElementID} = useContext(TreeStateContext); |
| 96 | |
| 97 | const {highlightHostInstance, clearHighlightHostInstance} = |
| 98 | useHighlightHostInstance(); |
| 99 | |
| 100 | const suspense = store.getSuspenseByID(suspenseID); |
| 101 | if (suspense === null) { |
| 102 | // getSuspenseByID will have already warned |
| 103 | return null; |
| 104 | } |
| 105 | const visible = suspense.hasUniqueSuspenders || !uniqueSuspendersOnly; |
| 106 | |
| 107 | function handleClick(event: SyntheticMouseEvent) { |
| 108 | if (event.defaultPrevented) { |
| 109 | // Already clicked on an inner rect |
| 110 | return; |
| 111 | } |
| 112 | event.preventDefault(); |
| 113 | treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); |
| 114 | suspenseTreeDispatch({ |
| 115 | type: 'SET_SUSPENSE_LINEAGE', |
| 116 | payload: suspenseID, |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | function handleDoubleClick(event: SyntheticMouseEvent) { |
| 121 | if (event.defaultPrevented) { |
| 122 | // Already clicked on an inner rect |
| 123 | return; |
| 124 | } |
| 125 | event.preventDefault(); |
| 126 | suspenseTreeDispatch({ |
| 127 | type: 'TOGGLE_TIMELINE_FOR_ID', |
| 128 | payload: suspenseID, |
| 129 | }); |
| 130 | } |
| 131 | |
| 132 | function handlePointerOver(event: SyntheticPointerEvent) { |
| 133 | if (event.defaultPrevented) { |
| 134 | // Already hovered an inner rect |
| 135 | return; |
| 136 | } |
| 137 | event.preventDefault(); |
| 138 | highlightHostInstance(suspenseID); |
| 139 | suspenseTreeDispatch({ |
| 140 | type: 'HOVER_TIMELINE_FOR_ID', |
| 141 | payload: suspenseID, |
| 142 | }); |
| 143 | } |
| 144 | |
| 145 | function handlePointerLeave(event: SyntheticPointerEvent) { |
| 146 | if (event.defaultPrevented) { |
| 147 | // Already hovered an inner rect |
| 148 | return; |
| 149 | } |
| 150 | event.preventDefault(); |
| 151 | clearHighlightHostInstance(); |
| 152 | suspenseTreeDispatch({ |
| 153 | type: 'HOVER_TIMELINE_FOR_ID', |
| 154 | payload: -1, |
| 155 | }); |
| 156 | } |
| 157 | |
| 158 | // TODO: Use the nearest Suspense boundary |
| 159 | const selected = inspectedElementID === suspenseID; |
| 160 | |
| 161 | const hovered = |
| 162 | hoveredTimelineIndex > -1 && |
| 163 | timeline[hoveredTimelineIndex].id === suspenseID; |
| 164 | |
| 165 | let environment: null | string = null; |
| 166 | for (let i = 0; i < timeline.length; i++) { |
| 167 | const timelineStep = timeline[i]; |
| 168 | if (timelineStep.id === suspenseID) { |
| 169 | environment = timelineStep.environment; |
| 170 | break; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | const rects = suspense.rects; |
| 175 | const boundingBox = getBoundingBox(rects); |
| 176 | |
| 177 | // Next we'll try to find a rect within one of our rects that isn't intersecting with |
| 178 | // other rects. |
| 179 | // TODO: This should probably be memoized based on if any changes to the rtree has been made. |
| 180 | const titleBox: null | Rect = |
| 181 | rects === null ? null : findTitleBox(store._rtree, rects, parentRects); |
| 182 | const nextRects = |
| 183 | rects === null || rects.length === 0 |
| 184 | ? parentRects |
| 185 | : parentRects === null || parentRects.length === 0 |
| 186 | ? rects |
| 187 | : parentRects.concat(rects); |
| 188 | |
| 189 | return ( |
| 190 | <ScaledRect |
| 191 | rect={boundingBox} |
| 192 | className={ |
| 193 | styles.SuspenseRectsBoundary + |
| 194 | ' ' + |
| 195 | getClassNameForEnvironment(environment) |
| 196 | } |
| 197 | visible={visible} |
| 198 | selected={selected} |
| 199 | suspended={suspense.isSuspended} |
| 200 | hovered={hovered}> |
| 201 | <ViewBox.Provider value={boundingBox}> |
| 202 | {visible && |
| 203 | suspense.rects !== null && |
| 204 | suspense.rects.map((rect, index) => { |
| 205 | return ( |
| 206 | <ScaledRect |
| 207 | key={index} |
| 208 | className={styles.SuspenseRectsRect} |
| 209 | rect={rect} |
| 210 | adjust={true} |
| 211 | onClick={handleClick} |
| 212 | onDoubleClick={handleDoubleClick} |
| 213 | onPointerOver={handlePointerOver} |
| 214 | onPointerLeave={handlePointerLeave} |
| 215 | // Reach-UI tooltip will go out of bounds of parent scroll container. |
| 216 | title={suspense.name || 'Unknown'} |
| 217 | /> |
| 218 | ); |
| 219 | })} |
| 220 | {suspense.children.length > 0 && ( |
| 221 | <ScaledRect |
| 222 | className={styles.SuspenseRectsBoundaryChildren} |
| 223 | rect={boundingBox}> |
| 224 | {suspense.children.map(childID => { |
| 225 | return ( |
| 226 | <SuspenseRects |
| 227 | key={childID} |
| 228 | suspenseID={childID} |
| 229 | parentRects={nextRects} |
| 230 | /> |
| 231 | ); |
| 232 | })} |
| 233 | </ScaledRect> |
| 234 | )} |
| 235 | {titleBox && suspense.name && visible ? ( |
| 236 | <ScaledRect className={styles.SuspenseRectsTitle} rect={titleBox}> |
| 237 | <span>{suspense.name}</span> |
| 238 | </ScaledRect> |
| 239 | ) : null} |
| 240 | </ViewBox.Provider> |
| 241 | </ScaledRect> |
| 242 | ); |
| 243 | } |
| 244 | |
| 245 | function getBoundingBox(rects: $ReadOnlyArray<Rect> | null): Rect { |
| 246 | if (rects === null || rects.length === 0) { |
| 247 | return {x: 0, y: 0, width: 0, height: 0}; |
| 248 | } |
| 249 | |
| 250 | let minX = Number.POSITIVE_INFINITY; |
| 251 | let minY = Number.POSITIVE_INFINITY; |
| 252 | let maxX = Number.NEGATIVE_INFINITY; |
| 253 | let maxY = Number.NEGATIVE_INFINITY; |
| 254 | |
| 255 | for (let i = 0; i < rects.length; i++) { |
| 256 | const rect = rects[i]; |
| 257 | minX = Math.min(minX, rect.x); |
| 258 | minY = Math.min(minY, rect.y); |
| 259 | maxX = Math.max(maxX, rect.x + rect.width); |
| 260 | maxY = Math.max(maxY, rect.y + rect.height); |
| 261 | } |
| 262 | |
| 263 | return { |
| 264 | x: minX, |
| 265 | y: minY, |
| 266 | width: maxX - minX, |
| 267 | height: maxY - minY, |
| 268 | }; |
| 269 | } |
| 270 | |
| 271 | function computeBoundingRectRecursively( |
| 272 | store: Store, |
| 273 | node: SuspenseNode, |
| 274 | bounds: { |
| 275 | minX: number, |
| 276 | minY: number, |
| 277 | maxX: number, |
| 278 | maxY: number, |
| 279 | }, |
| 280 | ): void { |
| 281 | const rects = node.rects; |
| 282 | if (rects !== null) { |
| 283 | for (let j = 0; j < rects.length; j++) { |
| 284 | const rect = rects[j]; |
| 285 | if (rect.x < bounds.minX) { |
| 286 | bounds.minX = rect.x; |
| 287 | } |
| 288 | if (rect.x + rect.width > bounds.maxX) { |
| 289 | bounds.maxX = rect.x + rect.width; |
| 290 | } |
| 291 | if (rect.y < bounds.minY) { |
| 292 | bounds.minY = rect.y; |
| 293 | } |
| 294 | if (rect.y + rect.height > bounds.maxY) { |
| 295 | bounds.maxY = rect.y + rect.height; |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | for (let i = 0; i < node.children.length; i++) { |
| 300 | const child = store.getSuspenseByID(node.children[i]); |
| 301 | if (child !== null) { |
| 302 | computeBoundingRectRecursively(store, child, bounds); |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | function getDocumentBoundingRect( |
| 308 | store: Store, |
| 309 | roots: $ReadOnlyArray<SuspenseNode['id']>, |
| 310 | ): Rect { |
| 311 | if (roots.length === 0) { |
| 312 | return {x: 0, y: 0, width: 0, height: 0}; |
| 313 | } |
| 314 | |
| 315 | const bounds = { |
| 316 | minX: Number.POSITIVE_INFINITY, |
| 317 | minY: Number.POSITIVE_INFINITY, |
| 318 | maxX: Number.NEGATIVE_INFINITY, |
| 319 | maxY: Number.NEGATIVE_INFINITY, |
| 320 | }; |
| 321 | |
| 322 | for (let i = 0; i < roots.length; i++) { |
| 323 | const rootID = roots[i]; |
| 324 | const root = store.getSuspenseByID(rootID); |
| 325 | if (root === null) { |
| 326 | continue; |
| 327 | } |
| 328 | computeBoundingRectRecursively(store, root, bounds); |
| 329 | } |
| 330 | |
| 331 | if (bounds.minX === Number.POSITIVE_INFINITY) { |
| 332 | // No rects found, return empty rect |
| 333 | return {x: 0, y: 0, width: 0, height: 0}; |
| 334 | } |
| 335 | |
| 336 | return { |
| 337 | x: bounds.minX, |
| 338 | y: bounds.minY, |
| 339 | width: bounds.maxX - bounds.minX, |
| 340 | height: bounds.maxY - bounds.minY, |
| 341 | }; |
| 342 | } |
| 343 | |
| 344 | function findTitleBox( |
| 345 | rtree: RBush<Rect>, |
| 346 | rects: Array<Rect>, |
| 347 | parentRects: null | Array<Rect>, |
| 348 | ): null | Rect { |
| 349 | for (let i = 0; i < rects.length; i++) { |
| 350 | const rect = rects[i]; |
| 351 | if (rect.width < 20 || rect.height < 10) { |
| 352 | // Skip small rects. They're likely not able to be contain anything useful anyway. |
| 353 | continue; |
| 354 | } |
| 355 | // Find all overlapping rects elsewhere in the tree to limit our rect. |
| 356 | const overlappingRects = rtree.search({ |
| 357 | minX: rect.x, |
| 358 | minY: rect.y, |
| 359 | maxX: rect.x + rect.width, |
| 360 | maxY: rect.y + rect.height, |
| 361 | }); |
| 362 | if ( |
| 363 | overlappingRects.length === 0 || |
| 364 | (overlappingRects.length === 1 && overlappingRects[0] === rect) |
| 365 | ) { |
| 366 | // There are no overlapping rects that isn't our own rect, so we can just use |
| 367 | // the full space of the rect. |
| 368 | return rect; |
| 369 | } |
| 370 | // We have some overlapping rects but they might not overlap everything. Let's |
| 371 | // shrink it up toward the top left corner until it has no more overlap. |
| 372 | const minX = rect.x; |
| 373 | const minY = rect.y; |
| 374 | let maxX = rect.x + rect.width; |
| 375 | let maxY = rect.y + rect.height; |
| 376 | for (let j = 0; j < overlappingRects.length; j++) { |
| 377 | const overlappingRect = overlappingRects[j]; |
| 378 | if (overlappingRect === rect) { |
| 379 | continue; |
| 380 | } |
| 381 | const x = overlappingRect.x; |
| 382 | const y = overlappingRect.y; |
| 383 | if (y < maxY && x < maxX) { |
| 384 | if ( |
| 385 | parentRects !== null && |
| 386 | parentRects.indexOf(overlappingRect) !== -1 |
| 387 | ) { |
| 388 | // This rect overlaps but it's part of a parent boundary. We let |
| 389 | // title content render if it's on top and not a sibling. |
| 390 | continue; |
| 391 | } |
| 392 | // This rect cuts into the remaining space. Let's figure out if we're |
| 393 | // better off cutting on the x or y axis to maximize remaining space. |
| 394 | const remainderX = x - minX; |
| 395 | const remainderY = y - minY; |
| 396 | if (remainderX > remainderY) { |
| 397 | maxX = x; |
| 398 | } else { |
| 399 | maxY = y; |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | if (maxX > minX && maxY > minY) { |
| 404 | return { |
| 405 | x: minX, |
| 406 | y: minY, |
| 407 | width: maxX - minX, |
| 408 | height: maxY - minY, |
| 409 | }; |
| 410 | } |
| 411 | } |
| 412 | return null; |
| 413 | } |
| 414 | |
| 415 | function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node { |
| 416 | const store = useContext(StoreContext); |
| 417 | const root = store.getSuspenseByID(rootID); |
| 418 | if (root === null) { |
| 419 | // getSuspenseByID will have already warned |
| 420 | return null; |
| 421 | } |
| 422 | |
| 423 | return root.children.map(childID => { |
| 424 | return ( |
| 425 | <SuspenseRects key={childID} suspenseID={childID} parentRects={null} /> |
| 426 | ); |
| 427 | }); |
| 428 | } |
| 429 | |
| 430 | function SuspenseRectsInitialPaint(): React$Node { |
| 431 | const {roots} = useContext(SuspenseTreeStateContext); |
| 432 | return roots.map(rootID => { |
| 433 | return <SuspenseRectsRoot key={rootID} rootID={rootID} />; |
| 434 | }); |
| 435 | } |
| 436 | |
| 437 | function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node { |
| 438 | const store = useContext(StoreContext); |
| 439 | const children = useMemo(() => { |
| 440 | return store.getSuspenseChildren(id); |
| 441 | }, [id, store]); |
| 442 | |
| 443 | return children.map(suspenseID => { |
| 444 | return ( |
| 445 | <SuspenseRects |
| 446 | key={suspenseID} |
| 447 | suspenseID={suspenseID} |
| 448 | parentRects={null} |
| 449 | /> |
| 450 | ); |
| 451 | }); |
| 452 | } |
| 453 | |
| 454 | const ViewBox = createContext<Rect>(null as any); |
| 455 | |
| 456 | function SuspenseRectsContainer({ |
| 457 | scaleRef, |
| 458 | }: { |
| 459 | scaleRef: {current: number}, |
| 460 | }): React$Node { |
| 461 | const store = useContext(StoreContext); |
| 462 | const {activityID, inspectedElementID} = useContext(TreeStateContext); |
| 463 | const treeDispatch = useContext(TreeDispatcherContext); |
| 464 | const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); |
| 465 | // TODO: This relies on a full re-render of all children when the Suspense tree changes. |
| 466 | const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} = |
| 467 | useContext(SuspenseTreeStateContext); |
| 468 | |
| 469 | const activityChildren: $ReadOnlyArray<SuspenseNode['id']> | null = |
| 470 | useMemo(() => { |
| 471 | if (activityID === null) { |
| 472 | return null; |
| 473 | } |
| 474 | return store.getSuspenseChildren(activityID); |
| 475 | }, [activityID, store]); |
| 476 | const transitionChildren = |
| 477 | activityChildren === null ? roots : activityChildren; |
| 478 | |
| 479 | // We're using the bounding box of the entire document to anchor the Transition |
| 480 | // in the actual document. |
| 481 | const boundingBox = getDocumentBoundingRect(store, roots); |
| 482 | |
| 483 | const boundingBoxWidth = boundingBox.width; |
| 484 | const heightScale = |
| 485 | boundingBoxWidth === 0 ? 1 : boundingBox.height / boundingBoxWidth; |
| 486 | // Scales the inspected document to fit into the available width |
| 487 | const width = '100%'; |
| 488 | const aspectRatio = `1 / ${heightScale}`; |
| 489 | |
| 490 | function handleClick(event: SyntheticMouseEvent) { |
| 491 | if (event.defaultPrevented) { |
| 492 | // Already clicked on an inner rect |
| 493 | return; |
| 494 | } |
| 495 | if (transitionChildren.length === 0) { |
| 496 | // Nothing to select |
| 497 | return; |
| 498 | } |
| 499 | const arbitraryRootID = roots[0]; |
| 500 | const transitionRoot = activityID === null ? arbitraryRootID : activityID; |
| 501 | |
| 502 | event.preventDefault(); |
| 503 | treeDispatch({ |
| 504 | type: 'SELECT_ELEMENT_BY_ID', |
| 505 | payload: transitionRoot, |
| 506 | }); |
| 507 | suspenseTreeDispatch({ |
| 508 | type: 'SET_SUSPENSE_LINEAGE', |
| 509 | payload: arbitraryRootID, |
| 510 | }); |
| 511 | } |
| 512 | |
| 513 | function handleDoubleClick(event: SyntheticMouseEvent) { |
| 514 | if (event.defaultPrevented) { |
| 515 | // Already clicked on an inner rect |
| 516 | return; |
| 517 | } |
| 518 | event.preventDefault(); |
| 519 | suspenseTreeDispatch({ |
| 520 | type: 'SUSPENSE_SET_TIMELINE_INDEX', |
| 521 | payload: 0, |
| 522 | }); |
| 523 | } |
| 524 | |
| 525 | // $FlowFixMe[incompatible-type] |
| 526 | const isRootSelected = roots.includes(inspectedElementID); |
| 527 | // When we're focusing a Transition, the first timeline step will not be a root. |
| 528 | const isRootHovered = activityID === null && hoveredTimelineIndex === 0; |
| 529 | |
| 530 | let hasRootSuspenders = false; |
| 531 | if (!uniqueSuspendersOnly) { |
| 532 | hasRootSuspenders = true; |
| 533 | } else { |
| 534 | for (let i = 0; i < roots.length; i++) { |
| 535 | const rootID = roots[i]; |
| 536 | const root = store.getSuspenseByID(rootID); |
| 537 | if (root !== null && root.hasUniqueSuspenders) { |
| 538 | hasRootSuspenders = true; |
| 539 | break; |
| 540 | } |
| 541 | } |
| 542 | } |
| 543 | |
| 544 | const rootEnvironment = |
| 545 | timeline.length === 0 ? null : timeline[0].environment; |
| 546 | |
| 547 | useLayoutEffect(() => { |
| 548 | // 100% of the width represents this many pixels in the real document. |
| 549 | scaleRef.current = boundingBoxWidth; |
| 550 | }, [boundingBoxWidth]); |
| 551 | |
| 552 | let selectedBoundingBox = null; |
| 553 | let selectedEnvironment = null; |
| 554 | if (isRootSelected) { |
| 555 | selectedEnvironment = rootEnvironment; |
| 556 | } else if ( |
| 557 | inspectedElementID !== null && |
| 558 | // TODO: Separate inspected element and inspected Suspense and use the inspected Suspense ID here. |
| 559 | store.containsSuspense(inspectedElementID) |
| 560 | ) { |
| 561 | const selectedSuspenseNode = store.getSuspenseByID(inspectedElementID); |
| 562 | if ( |
| 563 | selectedSuspenseNode !== null && |
| 564 | (selectedSuspenseNode.hasUniqueSuspenders || !uniqueSuspendersOnly) |
| 565 | ) { |
| 566 | selectedBoundingBox = getBoundingBox(selectedSuspenseNode.rects); |
| 567 | for (let i = 0; i < timeline.length; i++) { |
| 568 | const timelineStep = timeline[i]; |
| 569 | if (timelineStep.id === inspectedElementID) { |
| 570 | selectedEnvironment = timelineStep.environment; |
| 571 | break; |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | return ( |
| 578 | <div |
| 579 | className={ |
| 580 | styles.SuspenseRectsContainer + |
| 581 | (hasRootSuspenders && |
| 582 | // We don't want to draw attention to the root if we're looking at a Transition. |
| 583 | // TODO: Draw bounding rect of Transition and check if the Transition |
| 584 | // has unique suspenders. |
| 585 | activityID === null |
| 586 | ? ' ' + styles.SuspenseRectsRoot |
| 587 | : '') + |
| 588 | (isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') + |
| 589 | ' ' + |
| 590 | getClassNameForEnvironment(rootEnvironment) |
| 591 | } |
| 592 | onClick={handleClick} |
| 593 | onDoubleClick={handleDoubleClick} |
| 594 | data-hovered={isRootHovered}> |
| 595 | <ViewBox.Provider value={boundingBox}> |
| 596 | <div |
| 597 | className={styles.SuspenseRectsViewBox} |
| 598 | style={{aspectRatio, width}}> |
| 599 | {activityID === null ? ( |
| 600 | <SuspenseRectsInitialPaint /> |
| 601 | ) : ( |
| 602 | <SuspenseRectsTransition id={activityID} /> |
| 603 | )} |
| 604 | {selectedBoundingBox !== null ? ( |
| 605 | <ScaledRect |
| 606 | className={ |
| 607 | styles.SuspenseRectOutline + |
| 608 | ' ' + |
| 609 | getClassNameForEnvironment(selectedEnvironment) |
| 610 | } |
| 611 | rect={selectedBoundingBox} |
| 612 | adjust={true} |
| 613 | /> |
| 614 | ) : null} |
| 615 | </div> |
| 616 | </ViewBox.Provider> |
| 617 | </div> |
| 618 | ); |
| 619 | } |
| 620 | |
| 621 | export default SuspenseRectsContainer; |