Clean up tooltip on long hover for visualiser
Summary: Previously every single visualisation node would have jsx for a tooltip and would control its own tooltop. now we have the overlay we can have just one. this improves perf a bit and simplifies the code. i also increased the delay slightly Reviewed By: lblasa Differential Revision: D46274098 fbshipit-source-id: cb8afbc4804c549da9abf33d69aaf190397f74c7
This commit is contained in:
committed by
Facebook GitHub Bot
parent
c13180a929
commit
74ecbec9e6
@@ -7,14 +7,15 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
import React, {useEffect, useMemo, useRef} from 'react';
|
||||||
import {Bounds, Coordinate, Id, NestedNode, Tag, UINode} from '../types';
|
import {Bounds, Coordinate, Id, NestedNode, UINode} from '../types';
|
||||||
|
|
||||||
import {produce, styled, theme, usePlugin, useValue} from 'flipper-plugin';
|
import {produce, styled, theme, usePlugin, useValue} from 'flipper-plugin';
|
||||||
import {plugin} from '../index';
|
import {plugin} from '../index';
|
||||||
import {head, isEqual, throttle} from 'lodash';
|
import {head, isEqual, throttle} from 'lodash';
|
||||||
import {Dropdown, Menu, Tooltip} from 'antd';
|
import {Dropdown, Menu, Tooltip} from 'antd';
|
||||||
import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem';
|
import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem';
|
||||||
|
import {useDelay} from '../hooks/useDelay';
|
||||||
|
|
||||||
export const Visualization2D: React.FC<
|
export const Visualization2D: React.FC<
|
||||||
{
|
{
|
||||||
@@ -100,6 +101,13 @@ export const Visualization2D: React.FC<
|
|||||||
return (
|
return (
|
||||||
<ContextMenu nodes={nodes}>
|
<ContextMenu nodes={nodes}>
|
||||||
<div
|
<div
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
//this div is to ensure that the size of the visualiser doesnt change when focusings on a subtree
|
//this div is to ensure that the size of the visualiser doesnt change when focusings on a subtree
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
@@ -109,7 +117,11 @@ export const Visualization2D: React.FC<
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}>
|
}>
|
||||||
{hoveredNodeId && (
|
{hoveredNodeId && (
|
||||||
<OverlayBorder type="hovered" nodeId={hoveredNodeId} nodes={nodes} />
|
<HoveredOverlay
|
||||||
|
key={hoveredNodeId}
|
||||||
|
nodeId={hoveredNodeId}
|
||||||
|
nodes={nodes}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedNodeId && (
|
{selectedNodeId && (
|
||||||
<OverlayBorder
|
<OverlayBorder
|
||||||
@@ -120,13 +132,6 @@ export const Visualization2D: React.FC<
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={rootNodeRef as any}
|
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={{
|
style={{
|
||||||
/**
|
/**
|
||||||
* This relative position is so the rootNode visualization 2DNode and outer border has a non static element to
|
* This relative position is so the rootNode visualization 2DNode and outer border has a non static element to
|
||||||
@@ -181,7 +186,6 @@ function Visualization2DNode({
|
|||||||
}) {
|
}) {
|
||||||
const instance = usePlugin(plugin);
|
const instance = usePlugin(plugin);
|
||||||
|
|
||||||
const {isLongHovered} = useHoverStates(node.id);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
let nestedChildren: NestedNode[];
|
let nestedChildren: NestedNode[];
|
||||||
|
|
||||||
@@ -205,41 +209,52 @@ function Visualization2DNode({
|
|||||||
node.id,
|
node.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoveredOverlay({nodeId, nodes}: {nodeId: Id; nodes: Map<Id, UINode>}) {
|
||||||
|
const node = nodes.get(nodeId);
|
||||||
|
|
||||||
|
const isVisible = useDelay(longHoverDelay);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
visible={isLongHovered}
|
visible={isVisible}
|
||||||
|
key={nodeId}
|
||||||
placement="top"
|
placement="top"
|
||||||
zIndex={100}
|
zIndex={100}
|
||||||
trigger={[]}
|
trigger={[]}
|
||||||
title={node.name}
|
title={node?.name}
|
||||||
align={{
|
align={{
|
||||||
offset: [0, 7],
|
offset: [0, 7],
|
||||||
}}>
|
}}>
|
||||||
<div
|
<OverlayBorder nodeId={nodeId} nodes={nodes} type="hovered" />
|
||||||
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>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,7 +280,7 @@ const OverlayBorder = styled.div<{
|
|||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
borderColor:
|
borderColor:
|
||||||
type === 'selected' ? theme.primaryColor : theme.selectionBackgroundColor,
|
type === 'selected' ? theme.primaryColor : theme.textColorPlaceholder,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,43 +304,6 @@ function getTotalOffset(id: Id, nodes: Map<Id, UINode>): Coordinate {
|
|||||||
return offset;
|
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 ContextMenu: React.FC<{nodes: Map<Id, UINode>}> = ({children}) => {
|
||||||
const instance = usePlugin(plugin);
|
const instance = usePlugin(plugin);
|
||||||
|
|
||||||
@@ -387,7 +365,7 @@ const NodeBorder = styled.div({
|
|||||||
borderColor: theme.disabledColor,
|
borderColor: theme.disabledColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
const longHoverDelay = 200;
|
const longHoverDelay = 500;
|
||||||
const pxScaleFactorCssVar = '--pxScaleFactor';
|
const pxScaleFactorCssVar = '--pxScaleFactor';
|
||||||
const MouseThrottle = 32;
|
const MouseThrottle = 32;
|
||||||
|
|
||||||
|
|||||||
30
desktop/plugins/public/ui-debugger/hooks/useDelay.tsx
Normal file
30
desktop/plugins/public/ui-debugger/hooks/useDelay.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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 {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
|
export function useDelay(delayTimeMs: number) {
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
const delayTimerStarted = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let handle: NodeJS.Timeout | null = null;
|
||||||
|
if (delayTimerStarted.current === false) {
|
||||||
|
handle = setTimeout(() => setIsDone(true), delayTimeMs);
|
||||||
|
delayTimerStarted.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (handle !== null) {
|
||||||
|
clearTimeout(handle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [delayTimeMs]);
|
||||||
|
|
||||||
|
return isDone;
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 {Atom} from 'flipper-plugin';
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A hook similar to useValue that Subscribes to an atom and returns the current value.
|
|
||||||
* However the value only updates if the predicate passes.
|
|
||||||
*
|
|
||||||
* Usefull for skipping expensive react renders if an update doesnt concern you
|
|
||||||
* @param atom
|
|
||||||
* @param predicate
|
|
||||||
*/
|
|
||||||
export function useFilteredValue<T>(
|
|
||||||
atom: Atom<T>,
|
|
||||||
predicate: (newValue: T, prevValue?: T) => boolean,
|
|
||||||
) {
|
|
||||||
const [value, setValue] = useState(atom.get());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener = (newValue: T, prevValue?: T) => {
|
|
||||||
if (predicate(newValue, prevValue)) {
|
|
||||||
setValue(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
atom.subscribe(listener);
|
|
||||||
return () => {
|
|
||||||
atom.unsubscribe(listener);
|
|
||||||
};
|
|
||||||
}, [atom, predicate]);
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user