/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import React, {useEffect, useMemo, useRef} from 'react'; import {Bounds, Coordinate, Id, NestedNode, UINode} from '../types'; import {produce, styled, theme, usePlugin, useValue} from 'flipper-plugin'; import {plugin} from '../index'; import {head, isEqual, throttle} from 'lodash'; import {Dropdown, Menu, Tooltip} from 'antd'; import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem'; import {useDelay} from '../hooks/useDelay'; export const Visualization2D: React.FC< { width: number; nodes: Map; onSelectNode: (id?: Id) => void; } & React.HTMLAttributes > = ({width, nodes, onSelectNode}) => { const rootNodeRef = useRef(); const instance = usePlugin(plugin); const snapshot = useValue(instance.snapshot); const snapshotNode = snapshot && nodes.get(snapshot.nodeId); const focusedNodeId = useValue(instance.uiState.focusedNode); const selectedNodeId = useValue(instance.uiState.selectedNode); const hoveredNodeId = head(useValue(instance.uiState.hoveredNodes)); const focusState = useMemo(() => { //use the snapshot node as root since we cant realistically visualise any node above this const rootNode = snapshot && toNestedNode(snapshot.nodeId, nodes); return rootNode && caclulateFocusState(rootNode, focusedNodeId); }, [snapshot, nodes, focusedNodeId]); useEffect(() => { const mouseListener = throttle((ev: MouseEvent) => { const domRect = rootNodeRef.current?.getBoundingClientRect(); if ( !focusState || !domRect || instance.uiState.isContextMenuOpen.get() || !snapshotNode ) { return; } const rawMouse = {x: ev.clientX, y: ev.clientY}; if (!boundsContainsCoordinate(domRect, rawMouse)) { return; } //make the mouse coord relative to the dom rect of the visualizer const pxScaleFactor = calcPxScaleFactor(snapshotNode.bounds, width); const offsetMouse = offsetCoordinate(rawMouse, domRect); const scaledMouse = { x: offsetMouse.x * pxScaleFactor, y: offsetMouse.y * pxScaleFactor, }; const hitNodes = hitTest(focusState.focusedRoot, scaledMouse).map( (node) => node.id, ); if ( hitNodes.length > 0 && !isEqual(hitNodes, instance.uiState.hoveredNodes.get()) ) { instance.uiState.hoveredNodes.set(hitNodes); } }, MouseThrottle); window.addEventListener('mousemove', mouseListener); return () => { window.removeEventListener('mousemove', mouseListener); }; }, [ instance.uiState.hoveredNodes, focusState, nodes, instance.uiState.isContextMenuOpen, width, snapshotNode, ]); if (!focusState || !snapshotNode) { return null; } const pxScaleFactor = calcPxScaleFactor(snapshotNode.bounds, width); return (
{ e.stopPropagation(); //the context menu triggers this callback but we dont want to remove hover effect if (!instance.uiState.isContextMenuOpen.get()) { instance.uiState.hoveredNodes.set([]); } }} //this div is to ensure that the size of the visualiser doesnt change when focusings on a subtree style={ { overflowY: 'auto', overflowX: 'hidden', position: 'relative', //this is for the absolutely positioned overlays [pxScaleFactorCssVar]: pxScaleFactor, width: toPx(focusState.actualRoot.bounds.width), height: toPx(focusState.actualRoot.bounds.height), } as React.CSSProperties }> {hoveredNodeId && ( )} {selectedNodeId && ( )}
{snapshotNode && ( )}
); }; const MemoedVisualizationNode2D = React.memo( Visualization2DNode, (prev, next) => { return prev.node === next.node; }, ); function Visualization2DNode({ node, onSelectNode, }: { node: NestedNode; onSelectNode: (id?: Id) => void; }) { const instance = usePlugin(plugin); const ref = useRef(null); let nestedChildren: NestedNode[]; //if there is an active child don't draw the other children //this means we don't draw overlapping activities / tabs etc if (node.activeChildIdx && node.activeChildIdx < node.children.length) { nestedChildren = [node.children[node.activeChildIdx]]; } else { nestedChildren = node.children; } const children = nestedChildren.map((child) => ( )); const isHighlighted = useValue(instance.uiState.highlightedNodes).has( node.id, ); return (
{ e.stopPropagation(); const hoveredNodes = instance.uiState.hoveredNodes.get(); onSelectNode(hoveredNodes[0]); }}> {children}
); } function HoveredOverlay({nodeId, nodes}: {nodeId: Id; nodes: Map}) { const node = nodes.get(nodeId); const isVisible = useDelay(longHoverDelay); return ( ); } const OverlayBorder = styled.div<{ type: 'selected' | 'hovered'; nodeId: Id; nodes: Map; }>(({type, nodeId, nodes}) => { const offset = getTotalOffset(nodeId, nodes); const node = nodes.get(nodeId); return { zIndex: 100, pointerEvents: 'none', cursor: 'pointer', position: 'absolute', top: toPx(offset.y), left: toPx(offset.x), width: toPx(node?.bounds?.width ?? 0), height: toPx(node?.bounds?.height ?? 0), boxSizing: 'border-box', borderWidth: 2, borderStyle: 'solid', color: 'transparent', borderColor: type === 'selected' ? theme.primaryColor : theme.textColorPlaceholder, }; }); /** * computes the x,y offset of a given node from the root of the visualization * in node coordinates */ function getTotalOffset(id: Id, nodes: Map): Coordinate { const offset = {x: 0, y: 0}; let curId: Id | undefined = id; while (curId != null) { const cur = nodes.get(curId); if (cur != null) { offset.x += cur.bounds.x; offset.y += cur.bounds.y; } curId = cur?.parent; } return offset; } const ContextMenu: React.FC<{nodes: Map}> = ({children}) => { const instance = usePlugin(plugin); const focusedNodeId = useValue(instance.uiState.focusedNode); const hoveredNodeId = head(useValue(instance.uiState.hoveredNodes)); const nodes = useValue(instance.nodes); const hoveredNode = hoveredNodeId ? nodes.get(hoveredNodeId) : null; return ( { instance.uiState.isContextMenuOpen.set(open); }} trigger={['contextMenu']} overlay={() => { return ( {hoveredNode != null && hoveredNode?.id !== focusedNodeId && ( { instance.uiActions.onFocusNode(hoveredNode?.id); }} /> )} {focusedNodeId != null && ( { instance.uiState.focusedNode.set(undefined); }} /> )} ); }}> {children} ); }; /** * this is the border that shows the green or blue line, it is implemented as a sibling to the * node itself so that it has the same size but the border doesnt affect the sizing of its children * as border is part of the box model */ const NodeBorder = styled.div({ position: 'absolute', top: 0, left: 0, bottom: 0, right: 0, boxSizing: 'border-box', borderWidth: '1px', borderStyle: 'solid', color: 'transparent', borderColor: theme.disabledColor, }); const longHoverDelay = 500; const pxScaleFactorCssVar = '--pxScaleFactor'; const MouseThrottle = 32; function toPx(n: number) { return `calc(${n}px / var(${pxScaleFactorCssVar}))`; } function toNestedNode( rootId: Id, nodes: Map, ): NestedNode | undefined { function uiNodeToNestedNode(node: UINode): NestedNode { const nonNullChildren = node.children.filter( (childId) => nodes.get(childId) != null, ); if (nonNullChildren.length !== node.children.length) { console.error( 'Visualization2D.toNestedNode -> child is nullish!', node.children, nonNullChildren.map((childId) => { const child = nodes.get(childId); return child && uiNodeToNestedNode(child); }), ); } const activeChildIdx = node.activeChild ? nonNullChildren.indexOf(node.activeChild) : undefined; return { id: node.id, name: node.name, attributes: node.attributes, children: nonNullChildren.map((childId) => uiNodeToNestedNode(nodes.get(childId)!), ), bounds: node.bounds, tags: node.tags, activeChildIdx: activeChildIdx, }; } const root = nodes.get(rootId); return root ? uiNodeToNestedNode(root) : undefined; } type FocusState = { actualRoot: NestedNode; focusedRoot: NestedNode; focusedRootGlobalOffset: Coordinate; }; function caclulateFocusState(root: NestedNode, target?: Id): FocusState { const rootFocusState = { actualRoot: root, focusedRoot: root, focusedRootGlobalOffset: {x: 0, y: 0}, }; if (target == null) { return rootFocusState; } return ( findNodeAndGlobalOffsetRec(root, {x: 0, y: 0}, root, target) || rootFocusState ); } function findNodeAndGlobalOffsetRec( node: NestedNode, globalOffset: Coordinate, root: NestedNode, target: Id, ): FocusState | undefined { const nextOffset = { x: globalOffset.x + node.bounds.x, y: globalOffset.y + node.bounds.y, }; if (node.id === target) { //since we have already applied the this nodes offset to the root node in the visualiser we zero it out here so it isn't counted twice const focusedRoot = produce(node, (draft) => { draft.bounds.x = 0; draft.bounds.y = 0; }); return { actualRoot: root, focusedRoot, focusedRootGlobalOffset: nextOffset, }; } for (const child of node.children) { const offset = findNodeAndGlobalOffsetRec(child, nextOffset, root, target); if (offset != null) { return offset; } } return undefined; } function hitTest(node: NestedNode, mouseCoordinate: Coordinate): NestedNode[] { const res: NestedNode[] = []; function hitTestRec(node: NestedNode, mouseCoordinate: Coordinate): boolean { const nodeBounds = node.bounds; const thisNodeHit = boundsContainsCoordinate(nodeBounds, mouseCoordinate); let children = node.children; if (node.activeChildIdx != null) { children = [node.children[node.activeChildIdx]]; } const offsetMouseCoord = offsetCoordinate(mouseCoordinate, nodeBounds); let childHit = false; for (const child of children) { childHit = hitTestRec(child, offsetMouseCoord) || childHit; } const hit = thisNodeHit && !childHit; if (hit) { res.push(node); } return hit; } hitTestRec(node, mouseCoordinate); return res.sort((a, b) => { const areaA = a.bounds.height * a.bounds.width; const areaB = b.bounds.height * b.bounds.width; if (areaA > areaB) { return 1; } else if (areaA < areaB) { return -1; } else { return 0; } }); } function boundsContainsCoordinate(bounds: Bounds, coordinate: Coordinate) { return ( coordinate.x >= bounds.x && coordinate.x <= bounds.x + bounds.width && coordinate.y >= bounds.y && coordinate.y <= bounds.y + bounds.height ); } function offsetCoordinate( coordinate: Coordinate, offset: Coordinate, ): Coordinate { return { x: coordinate.x - offset.x, y: coordinate.y - offset.y, }; } function calcPxScaleFactor(snapshotBounds: Bounds, availableWidth: number) { return snapshotBounds.width / availableWidth; }