Hit test can produce multiple nodes

Summary:
There are situations where multiple siblings overlap and they are both hit. Previously we picked the first one in the hierachy. Now we produce a list of hit children. The list will not have 2 nodes in the same ancestor path.

We store the hovered nodes as a list as we may want to present a modal in future to ask user which node they indented to select. That said simply sorting nodes by area seems to give decent results so we can start with this

Reviewed By: lblasa

Differential Revision: D41220271

fbshipit-source-id: 643a369113da28e8c4749725a7aee7aa5d08c401
This commit is contained in:
Luke De Feo
2022-11-14 07:05:58 -08:00
committed by Facebook GitHub Bot
parent 062e87f50f
commit 477eae1993
3 changed files with 65 additions and 45 deletions

View File

@@ -21,6 +21,7 @@ import {
InteractionMode,
TreeEnvironmentRef,
} from 'react-complex-tree/lib/esm/types';
import {head} from 'lodash';
export function Tree(props: {
rootId: Id;
@@ -32,13 +33,13 @@ export function Tree(props: {
const expandedItems = useValue(instance.treeState).expandedNodes;
const items = useMemo(() => toComplexTree(props.nodes), [props.nodes]);
const hoveredNode = useValue(instance.hoveredNode);
const hoveredNodes = useValue(instance.hoveredNodes);
const treeRef = useRef<TreeEnvironmentRef>();
useEffect(() => {
//this makes the keyboard arrow controls work always, even when using the visualiser
treeRef.current?.focusTree('tree', true);
}, [hoveredNode, props.selectedNode]);
}, [hoveredNodes, props.selectedNode]);
return (
<ControlledTreeEnvironment
ref={treeRef as any}
@@ -50,13 +51,13 @@ export function Tree(props: {
autoFocus
viewState={{
tree: {
focusedItem: hoveredNode,
focusedItem: head(hoveredNodes),
expandedItems,
selectedItems: props.selectedNode ? [props.selectedNode] : [],
},
}}
onFocusItem={(item) => {
instance.hoveredNode.set(item.index);
instance.hoveredNodes.set([item.index]);
}}
onExpandItem={(item) => {
instance.treeState.update((draft) => {
@@ -89,7 +90,7 @@ export function Tree(props: {
},
onMouseOver: () => {
instance.hoveredNode.set(item.index);
instance.hoveredNodes.set([item.index]);
},
}),
}}>

View File

@@ -18,9 +18,9 @@ import {
UINode,
} from '../types';
import {styled, theme, usePlugin, Atom} from 'flipper-plugin';
import {styled, theme, usePlugin} from 'flipper-plugin';
import {plugin} from '../index';
import {throttle} from 'lodash';
import {throttle, isEqual, head} from 'lodash';
export const Visualization2D: React.FC<
{
@@ -60,9 +60,13 @@ export const Visualization2D: React.FC<
y: offsetMouse.y * pxScaleFactor,
};
const targeted = hitTest(root, scaledMouse, root.bounds);
if (targeted && targeted.id !== instance.hoveredNode.get()) {
instance.hoveredNode.set(targeted.id);
const hitNodes = hitTest(root, scaledMouse).map((node) => node.id);
if (
hitNodes.length > 0 &&
!isEqual(hitNodes, instance.hoveredNodes.get())
) {
instance.hoveredNodes.set(hitNodes);
}
}, MouseThrottle);
window.addEventListener('mousemove', mouseListener);
@@ -70,7 +74,7 @@ export const Visualization2D: React.FC<
return () => {
window.removeEventListener('mousemove', mouseListener);
};
}, [instance.hoveredNode, root]);
}, [instance.hoveredNodes, root]);
if (!root) {
return null;
@@ -81,7 +85,7 @@ export const Visualization2D: React.FC<
ref={rootNodeRef as any}
onMouseLeave={(e) => {
e.stopPropagation();
instance.hoveredNode.set(undefined);
instance.hoveredNodes.set([]);
}}
style={{
/**
@@ -137,16 +141,16 @@ function Visualization2DNode({
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const listener = (newValue?: Id, prevValue?: Id) => {
if (prevValue === node.id || newValue === node.id) {
setIsHovered(newValue === node.id);
const listener = (newValue?: Id[], prevValue?: Id[]) => {
if (head(prevValue) === node.id || head(newValue) === node.id) {
setIsHovered(head(newValue) === node.id);
}
};
instance.hoveredNode.subscribe(listener);
instance.hoveredNodes.subscribe(listener);
return () => {
instance.hoveredNode.unsubscribe(listener);
instance.hoveredNodes.unsubscribe(listener);
};
}, [instance.hoveredNode, node.id]);
}, [instance.hoveredNodes, node.id]);
const isSelected = selectedNode === node.id;
@@ -198,11 +202,11 @@ function Visualization2DNode({
onClick={(e) => {
e.stopPropagation();
const hoveredNode = instance.hoveredNode.get();
if (hoveredNode === selectedNode) {
const hoveredNodes = instance.hoveredNodes.get();
if (hoveredNodes[0] === selectedNode) {
onSelectNode(undefined);
} else {
onSelectNode(hoveredNode);
onSelectNode(hoveredNodes[0]);
}
}}>
<NodeBorder hovered={isHovered} tags={node.tags}></NodeBorder>
@@ -291,35 +295,48 @@ function toNestedNode(
return root ? uiNodeToNestedNode(root) : undefined;
}
function hitTest(
node: NestedNode,
mouseCoordinate: Coordinate,
parentBounds: Bounds,
): NestedNode | undefined {
const nodeBounds = node.bounds || parentBounds;
function hitTest(node: NestedNode, mouseCoordinate: Coordinate): NestedNode[] {
const res: NestedNode[] = [];
function hitTestRec(node: NestedNode, mouseCoordinate: Coordinate): boolean {
const nodeBounds = node.bounds;
if (boundsContainsCoordinate(nodeBounds, mouseCoordinate)) {
let children = node.children;
if (node.activeChildIdx) {
if (node.activeChildIdx != null) {
children = [node.children[node.activeChildIdx]];
}
const offsetMouseCoord = offsetCoordinate(mouseCoordinate, nodeBounds);
let childHit = false;
for (const child of children) {
const childHit = hitTest(
child,
offsetMouseCoord,
(parentBounds = nodeBounds),
);
if (childHit) {
return childHit;
}
childHit = hitTestRec(child, offsetMouseCoord) || childHit;
}
return node;
if (!childHit) {
res.push(node);
}
return undefined;
return true;
}
return false;
}
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) {

View File

@@ -52,7 +52,9 @@ export function plugin(client: PluginClient<Events>) {
const treeState = createState<TreeState>({expandedNodes: []});
const hoveredNode = createState<Id | undefined>(undefined);
//The reason for the array as that user could be hovering multiple overlapping nodes at once in the visualiser.
//The nodes are sorted by area since you most likely want to select the smallest node under your cursor
const hoveredNodes = createState<Id[]>([]);
client.onMessage('coordinateUpdate', (event) => {
nodes.update((draft) => {
@@ -103,7 +105,7 @@ export function plugin(client: PluginClient<Events>) {
nodes,
metadata,
snapshots,
hoveredNode,
hoveredNodes,
perfEvents,
treeState,
};