Files
flipper/desktop/plugins/public/ui-debugger/components/Visualization2D.tsx
Luke De Feo c13180a929 Draw selected and hovered node borders in overlay layer
Summary:
The previous approach of setting some of the borders to be thicker and different colours was flakey, sometimes parts of the border would be cut off by a parent

With this approach we figure out the offset relative to the root of the visualiser, and draw a box that is definatley on top. It works much more reliably

Also fixed a couple of other niggles:
1. Can unselect when clicking again
2. Going into focus mode clears selection since your selection may not be in the focused area and there is a phantom box

Reviewed By: lblasa

Differential Revision: D46224034

fbshipit-source-id: 24bed8db38cddab796f786e7e0a4acfe7c6a9089
2023-06-07 06:20:13 -07:00

557 lines
15 KiB
TypeScript

/**
* 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, useState} from 'react';
import {Bounds, Coordinate, Id, NestedNode, Tag, 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';
export const Visualization2D: React.FC<
{
width: number;
nodes: Map<Id, UINode>;
onSelectNode: (id?: Id) => void;
} & React.HTMLAttributes<HTMLDivElement>
> = ({width, nodes, onSelectNode}) => {
const rootNodeRef = useRef<HTMLDivElement>();
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 (
<ContextMenu nodes={nodes}>
<div
//this div is to ensure that the size of the visualiser doesnt change when focusings on a subtree
style={
{
[pxScaleFactorCssVar]: pxScaleFactor,
width: toPx(focusState.actualRoot.bounds.width),
height: toPx(focusState.actualRoot.bounds.height),
} as React.CSSProperties
}>
{hoveredNodeId && (
<OverlayBorder type="hovered" nodeId={hoveredNodeId} nodes={nodes} />
)}
{selectedNodeId && (
<OverlayBorder
type="selected"
nodeId={selectedNodeId}
nodes={nodes}
/>
)}
<div
ref={rootNodeRef as any}
onMouseLeave={(e) => {
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([]);
}
}}
style={{
/**
* This relative position is so the rootNode visualization 2DNode and outer border has a non static element to
* position itself relative to.
*
* Subsequent Visualization2DNode are positioned relative to their parent as each one is position absolute
* which despite the name acts are a reference point for absolute positioning...
*
* When focused the global offset of the focussed node is used to offset and size this 'root' node
*/
position: 'relative',
marginLeft: toPx(focusState.focusedRootGlobalOffset.x),
marginTop: toPx(focusState.focusedRootGlobalOffset.y),
width: toPx(focusState.focusedRoot.bounds.width),
height: toPx(focusState.focusedRoot.bounds.height),
overflow: 'hidden',
}}>
{snapshotNode && (
<img
src={'data:image/png;base64,' + snapshot.data}
style={{
marginLeft: toPx(-focusState.focusedRootGlobalOffset.x),
marginTop: toPx(-focusState.focusedRootGlobalOffset.y),
width: toPx(snapshotNode.bounds.width),
height: toPx(snapshotNode.bounds.height),
}}
/>
)}
<MemoedVisualizationNode2D
node={focusState.focusedRoot}
onSelectNode={onSelectNode}
/>
</div>
</div>
</ContextMenu>
);
};
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 {isLongHovered} = useHoverStates(node.id);
const ref = useRef<HTMLDivElement>(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) => (
<MemoedVisualizationNode2D
key={child.id}
node={child}
onSelectNode={onSelectNode}
/>
));
const isHighlighted = useValue(instance.uiState.highlightedNodes).has(
node.id,
);
return (
<Tooltip
visible={isLongHovered}
placement="top"
zIndex={100}
trigger={[]}
title={node.name}
align={{
offset: [0, 7],
}}>
<div
role="button"
tabIndex={0}
ref={ref}
style={{
position: 'absolute',
cursor: 'pointer',
left: toPx(node.bounds.x),
top: toPx(node.bounds.y),
width: toPx(node.bounds.width),
height: toPx(node.bounds.height),
opacity: isHighlighted ? 0.3 : 1,
backgroundColor: isHighlighted ? 'red' : 'transparent',
}}
onClick={(e) => {
e.stopPropagation();
const hoveredNodes = instance.uiState.hoveredNodes.get();
onSelectNode(hoveredNodes[0]);
}}>
<NodeBorder />
{children}
</div>
</Tooltip>
);
}
const OverlayBorder = styled.div<{
type: 'selected' | 'hovered';
nodeId: Id;
nodes: Map<Id, UINode>;
}>(({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.selectionBackgroundColor,
};
});
/**
* computes the x,y offset of a given node from the root of the visualization
* in node coordinates
*/
function getTotalOffset(id: Id, nodes: Map<Id, UINode>): 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;
}
function useHoverStates(nodeId: Id) {
const instance = usePlugin(plugin);
const [isHovered, setIsHovered] = useState(false);
const [isLongHovered, setIsLongHovered] = useState(false);
useEffect(() => {
const listener = (newValue?: Id[], prevValue?: Id[]) => {
//only change state if the prev or next hover state affect us, this avoids rerendering the whole tree for a hover
//change
if (head(prevValue) === nodeId || head(newValue) === nodeId) {
const hovered = head(newValue) === nodeId;
setIsHovered(hovered);
if (hovered === true) {
setTimeout(() => {
const isStillHovered =
head(instance.uiState.hoveredNodes.get()) === nodeId;
if (isStillHovered) {
setIsLongHovered(true);
}
}, longHoverDelay);
} else {
setIsLongHovered(false);
}
}
};
instance.uiState.hoveredNodes.subscribe(listener);
return () => {
instance.uiState.hoveredNodes.unsubscribe(listener);
};
}, [instance.uiState.hoveredNodes, nodeId]);
return {
isHovered,
isLongHovered,
};
}
const ContextMenu: React.FC<{nodes: Map<Id, UINode>}> = ({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 (
<Dropdown
onVisibleChange={(open) => {
instance.uiState.isContextMenuOpen.set(open);
}}
trigger={['contextMenu']}
overlay={() => {
return (
<Menu>
{hoveredNode != null && hoveredNode?.id !== focusedNodeId && (
<UIDebuggerMenuItem
key="focus"
text={`Focus ${hoveredNode?.name}`}
onClick={() => {
instance.uiActions.onFocusNode(hoveredNode?.id);
}}
/>
)}
{focusedNodeId != null && (
<UIDebuggerMenuItem
key="remove-focus"
text="Remove focus"
onClick={() => {
instance.uiState.focusedNode.set(undefined);
}}
/>
)}
</Menu>
);
}}>
{children}
</Dropdown>
);
};
/**
* 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 = 200;
const pxScaleFactorCssVar = '--pxScaleFactor';
const MouseThrottle = 32;
function toPx(n: number) {
return `calc(${n}px / var(${pxScaleFactorCssVar}))`;
}
function toNestedNode(
rootId: Id,
nodes: Map<Id, UINode>,
): 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;
}