Files
Luke De Feo bf67b19c4a Allow searching on inline attribute keys
Summary: useful for bloks debugging

Reviewed By: lblasa

Differential Revision: D51349211

fbshipit-source-id: a11eca19bdecf989ceb6a95e9a3cb504020c7467
2023-11-17 04:18:41 -08:00

673 lines
20 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 {Id, ClientNode, NodeMap, MetadataId, Metadata} from '../../ClientTypes';
import {Color, OnSelectNode} from '../../DesktopTypes';
import React, {
CSSProperties,
Ref,
RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import {
HighlightManager,
HighlightProvider,
Layout,
styled,
theme,
useHighlighter,
usePlugin,
useValue,
} from 'flipper-plugin';
import {plugin} from '../../index';
import {head, last} from 'lodash';
import {Badge, Tooltip, Typography} from 'antd';
import {useVirtualizer} from '@tanstack/react-virtual';
import {ContextMenu} from './ContextMenu';
import {MillisSinceEpoch, useKeyboardControls} from './useKeyboardControls';
import {toTreeList} from './toTreeList';
import {CaretDownOutlined, WarningOutlined} from '@ant-design/icons';
const {Text} = Typography;
type NodeIndentGuide = {
depth: number;
addHorizontalMarker: boolean;
trimBottom: boolean;
color: 'primary' | 'secondary';
};
export type TreeNode = ClientNode & {
depth: number;
idx: number;
isExpanded: boolean;
indentGuides: NodeIndentGuide[];
frameworkEvents: number | null;
};
export function Tree2({
nodes,
metadata,
rootId,
additionalHeightOffset,
}: {
additionalHeightOffset: number;
nodes: NodeMap;
metadata: Map<MetadataId, Metadata>;
rootId: Id;
}) {
const instance = usePlugin(plugin);
const focusedNode = useValue(instance.uiState.focusedNode);
const expandedNodes = useValue(instance.uiState.expandedNodes);
const searchTerm = useValue(instance.uiState.searchTerm);
const selectedNode = useValue(instance.uiState.selectedNode);
const isContextMenuOpen = useValue(instance.uiState.isContextMenuOpen);
const hoveredNode = head(useValue(instance.uiState.hoveredNodes));
const filterMainThreadMonitoring = useValue(
instance.uiState.filterMainThreadMonitoring,
);
const frameworkEventsMonitoring = useValue(
instance.uiState.frameworkEventMonitoring,
);
const highlightedNodes = useValue(instance.uiState.highlightedNodes);
const {treeNodes, refs} = useMemo(() => {
const treeNodes = toTreeList(
nodes,
focusedNode || rootId,
expandedNodes,
selectedNode?.id,
instance.frameworkEvents,
frameworkEventsMonitoring,
filterMainThreadMonitoring,
);
const refs: React.RefObject<HTMLLIElement>[] = treeNodes.map(() =>
React.createRef<HTMLLIElement>(),
);
return {treeNodes, refs};
}, [
expandedNodes,
filterMainThreadMonitoring,
focusedNode,
frameworkEventsMonitoring,
instance.frameworkEvents,
nodes,
rootId,
selectedNode?.id,
]);
const isUsingKBToScrollUtill = useRef<MillisSinceEpoch>(0);
const grandParentRef = useRef<HTMLDivElement>(null);
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: treeNodes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => TreeItemHeightNumber,
overscan: 20,
});
const prevSearchTerm = useRef<string | null>(null);
useEffect(() => {
if (prevSearchTerm.current === searchTerm) {
return;
}
prevSearchTerm.current = searchTerm;
const matchingNodesIds = findMatchingNodes(nodes, searchTerm);
matchingNodesIds.forEach((id) => {
instance.uiActions.ensureAncestorsExpanded(id);
});
if (matchingNodesIds.length > 0) {
const firstTreeNode = treeNodes.find(searchPredicate(searchTerm));
const idx = firstTreeNode?.idx;
if (idx != null) {
rowVirtualizer.scrollToIndex(idx, {align: 'start'});
}
}
}, [instance.uiActions, nodes, rowVirtualizer, searchTerm, treeNodes]);
useKeyboardControls(
treeNodes,
rowVirtualizer,
selectedNode?.id,
hoveredNode,
instance.uiActions.onSelectNode,
instance.uiActions.onHoverNode,
instance.uiActions.onExpandNode,
instance.uiActions.onCollapseNode,
isUsingKBToScrollUtill,
);
const initialHeightOffset = useRef<number | null>(null);
useLayoutEffect(() => {
//the grand parent gets its size correclty via flex box, we use its initial
//position to size the scroll parent ref for react virtual, It uses vh which accounts for window size changes
//However if we dynamically add content above or below we may need to revisit this approach
const boundingClientRect = grandParentRef?.current?.getBoundingClientRect();
if (initialHeightOffset.current == null) {
//it is important to capture the initial height offset as we dont want to consider them again if elements are added dynamically later
initialHeightOffset.current =
boundingClientRect!!.top +
(window.innerHeight - boundingClientRect!!.bottom) -
additionalHeightOffset;
}
parentRef.current!!.style.height = `calc(100vh - ${initialHeightOffset.current}px - ${additionalHeightOffset}px )`;
}, [additionalHeightOffset]);
useLayoutEffect(() => {
//scroll width is the width of the element including overflow, we grab the scroll width
//of the parent scroll container and set each divs actual width to this to make sure the
//size is correct for the selection and hover states
const range = rowVirtualizer.range;
const end = Math.min(
refs.length,
range.endIndex + 1, //need to add 1 extra otherwise last one doesnt get the treatment
);
const width = parentRef.current?.scrollWidth ?? 0;
for (let i = range.startIndex; i < end; i++) {
//set the width explicitly of all tree items to parent scroll width
const ref = refs[i];
if (ref.current) {
ref.current.style.width = `${width}px`;
}
}
});
useLayoutEffect(() => {
if (selectedNode != null) {
const selectedTreeNode = treeNodes.find(
(node) => node.id === selectedNode?.id,
);
const ref = parentRef.current;
if (
ref != null &&
selectedTreeNode != null &&
selectedNode?.source === 'visualiser'
) {
ref.scrollLeft =
Math.max(0, selectedTreeNode.depth - 10) * renderDepthOffset;
let scrollToIndex = selectedTreeNode.idx;
if (selectedTreeNode.idx > rowVirtualizer.range.endIndex) {
//when scrolling down the scrollbar gets in the way if you scroll to the precise node
scrollToIndex = Math.min(scrollToIndex + 1, treeNodes.length);
}
rowVirtualizer.scrollToIndex(scrollToIndex, {align: 'auto'});
}
}
// NOTE: We don't want to add refs or tree nodes to the dependency list since when new data comes in over the wire
// otherwise we will keep scrolling back to the selected node overriding the users manual scroll offset.
// We only should scroll when selection changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNode, focusedNode]);
return (
<HighlightProvider
text={searchTerm}
highlightColor={theme.searchHighlightBackground.yellow}>
<ContextMenu
metadata={metadata}
frameworkEvents={instance.frameworkEvents}
focusedNodeId={focusedNode}
hoveredNodeId={hoveredNode}
nodes={nodes}
onSelectNode={instance.uiActions.onSelectNode}
onSetViewMode={instance.uiActions.onSetViewMode}
onContextMenuOpen={instance.uiActions.onContextMenuOpen}
onFocusNode={instance.uiActions.onFocusNode}
onCollapseNonAncestors={instance.uiActions.onCollapseAllNonAncestors}
onCollapseRecursively={instance.uiActions.onCollapseAllRecursively}
onExpandRecursively={instance.uiActions.onExpandAllRecursively}>
<div
//We use this normal divs flexbox sizing to measure how much vertical space we need for the child div
ref={grandParentRef}
style={{
paddingLeft: theme.space.medium,
height: '100%',
width: '100%',
}}>
<div
//this is scrollable div is expected by react virtual, see their docs
ref={parentRef}
style={{
height: 0, //this get replaced by an effect
overflow: 'auto',
}}>
<div
//this is is the actual scrollable content, its height is faked by react virtual
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
onMouseLeave={() => {
if (isContextMenuOpen === false) {
instance.uiActions.onHoverNode();
}
}}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<TreeNodeRow
transform={`translateY(${virtualRow.start}px)`}
innerRef={refs[virtualRow.index]}
key={virtualRow.index}
treeNode={treeNodes[virtualRow.index]}
highlightedNodes={highlightedNodes}
selectedNode={selectedNode?.id}
hoveredNode={hoveredNode}
isUsingKBToScroll={isUsingKBToScrollUtill}
isContextMenuOpen={isContextMenuOpen}
onSelectNode={instance.uiActions.onSelectNode}
onExpandNode={instance.uiActions.onExpandNode}
onCollapseNode={instance.uiActions.onCollapseNode}
onHoverNode={instance.uiActions.onHoverNode}
/>
))}
</div>
</div>
</div>
</ContextMenu>
</HighlightProvider>
);
}
const secondaryColor = theme.buttonDefaultBackground;
const GuideOffset = 11;
const IndentGuides = React.memo(
({
isSelected,
indentGuides,
hasExpandChildrenIcon,
}: {
isSelected: boolean;
hasExpandChildrenIcon: boolean;
indentGuides: NodeIndentGuide[];
}) => {
const lastGuide = last(indentGuides);
const lastGuidePadding = `${
renderDepthOffset * (lastGuide?.depth ?? 0) + GuideOffset
}px`;
return (
<div style={{pointerEvents: 'none'}}>
{indentGuides.map((guide, idx) => {
const indentGuideLinePadding = `${
renderDepthOffset * guide.depth + GuideOffset
}px`;
const isLastGuide = idx === indentGuides.length - 1;
const drawHalfprimary = isSelected && isLastGuide;
const firstHalf =
guide.color === 'primary' ? theme.primaryColor : secondaryColor;
const secondHalf = guide.trimBottom
? 'transparent'
: guide.color === 'primary' && !drawHalfprimary
? theme.primaryColor
: secondaryColor;
return (
<div
key={guide.depth}
style={{
position: 'absolute',
width: indentGuideLinePadding,
height: TreeItemHeight,
borderRight: `1px solid`,
borderImageSource: `linear-gradient(to bottom, ${firstHalf} 50%, ${secondHalf} 50%)`,
borderImageSlice: 1,
}}
/>
);
})}
{lastGuide?.addHorizontalMarker && (
<div
style={{
position: 'absolute',
width: hasExpandChildrenIcon
? renderDepthOffset / 2
: renderDepthOffset,
height: HalfTreeItemHeight,
borderBottom: `1px solid ${
lastGuide.color === 'primary'
? theme.primaryColor
: secondaryColor
}`,
marginLeft: lastGuidePadding,
}}></div>
)}
</div>
);
},
(props, nextProps) =>
props.hasExpandChildrenIcon === nextProps.hasExpandChildrenIcon &&
props.indentGuides === nextProps.indentGuides &&
props.isSelected === nextProps.isSelected,
);
function TreeNodeRow({
transform,
innerRef,
treeNode,
highlightedNodes,
selectedNode,
hoveredNode,
isUsingKBToScroll: isUsingKBToScrollUntill,
isContextMenuOpen,
onSelectNode,
onExpandNode,
onCollapseNode,
onHoverNode,
}: {
transform: string;
innerRef: Ref<any>;
treeNode: TreeNode;
highlightedNodes: Map<Id, Color>;
selectedNode?: Id;
hoveredNode?: Id;
isUsingKBToScroll: RefObject<MillisSinceEpoch>;
isContextMenuOpen: boolean;
onSelectNode: OnSelectNode;
onExpandNode: (node: Id) => void;
onCollapseNode: (node: Id) => void;
onHoverNode: (node: Id) => void;
}) {
const showExpandChildrenIcon = treeNode.children.length > 0;
const isSelected = treeNode.id === selectedNode;
const expandOrCollapse = () => {
if (treeNode.isExpanded) {
onCollapseNode(treeNode.id);
} else {
onExpandNode(treeNode.id);
}
};
return (
<div
ref={innerRef}
style={{
position: 'absolute',
top: 0,
left: 0,
height: TreeItemHeight,
transform: transform,
//Due to absolute positioning width is set outside of react via a useLayoutEffect in parent
}}>
<IndentGuides
isSelected={isSelected}
hasExpandChildrenIcon={showExpandChildrenIcon}
indentGuides={treeNode.indentGuides}
/>
<TreeNodeContent
highlightColor={highlightedNodes.get(treeNode.id)}
isSelected={isSelected}
isHovered={hoveredNode === treeNode.id}
onMouseEnter={() => {
const kbIsNoLongerReservingScroll =
new Date().getTime() > (isUsingKBToScrollUntill.current ?? 0);
if (kbIsNoLongerReservingScroll && isContextMenuOpen == false) {
onHoverNode(treeNode.id);
}
}}
onClick={(event) => {
if (event.detail === 1) {
//single click
onSelectNode(treeNode.id, 'tree');
} else if (event.detail === 2) {
//double click
expandOrCollapse();
}
}}
item={treeNode}
style={{overflow: 'visible'}}>
<ExpandedIconOrSpace
expanded={treeNode.isExpanded}
showIcon={showExpandChildrenIcon}
onClick={expandOrCollapse}
/>
{nodeIcon(treeNode)}
<TreeNodeTextContent treeNode={treeNode} />
{treeNode.frameworkEvents && (
<Badge
count={treeNode.frameworkEvents}
style={{
backgroundColor: theme.primaryColor,
marginLeft: theme.space.small,
}}
/>
)}
</TreeNodeContent>
</div>
);
}
function TreeNodeTextContent({treeNode}: {treeNode: TreeNode}) {
const isZero = treeNode.bounds.width === 0 && treeNode.bounds.height === 0;
const invisible = treeNode.hiddenAttributes?.['invisible'] === true;
return (
<Layout.Horizontal
style={{
fontFamily: 'monospace',
opacity: isZero || invisible ? 0.5 : 1,
alignItems: 'baseline',
userSelect: 'none',
}}>
<HighlightedText text={treeNode.name} />
<InlineAttributes attributes={treeNode.inlineAttributes} />
</Layout.Horizontal>
);
}
const TreeAttributeContainer = styled(Text)({
color: theme.textColorSecondary,
fontWeight: 300,
marginLeft: 5,
fontSize: 12,
});
function InlineAttributes({attributes}: {attributes: Record<string, string>}) {
const highlightManager: HighlightManager = useHighlighter();
return (
<>
{Object.entries(attributes ?? {}).map(([key, value]) => (
<TreeAttributeContainer key={key}>
<span style={{color: theme.warningColor}}>
{highlightManager.render(key)}
</span>
<span>={highlightManager.render(value)}</span>
</TreeAttributeContainer>
))}
</>
);
}
const TreeItemHeightNumber = 24;
const TreeItemHeight = `${TreeItemHeightNumber}px`;
const HalfTreeItemHeight = `calc(${TreeItemHeight} / 2)`;
const TreeNodeContent = styled.li<{
item: TreeNode;
isHovered: boolean;
isSelected: boolean;
highlightColor?: string;
}>(({item, isHovered, isSelected, highlightColor}) => ({
display: 'flex',
alignItems: 'center',
height: TreeItemHeight,
paddingLeft: `${item.depth * renderDepthOffset}px`,
borderWidth: '1px',
borderRadius: '3px',
borderColor: 'transparent',
borderStyle: 'solid',
overflow: 'hidden',
whiteSpace: 'nowrap',
opacity: highlightColor != null ? 0.6 : 1,
backgroundColor:
highlightColor != null
? highlightColor
: isSelected
? theme.selectionBackgroundColor
: isHovered
? theme.backgroundWash
: theme.backgroundDefault,
}));
function ExpandedIconOrSpace(props: {
onClick: () => void;
expanded: boolean;
showIcon: boolean;
}) {
if (props.showIcon) {
return (
<div
role="button"
tabIndex={0}
style={{
display: 'flex',
height: TreeItemHeight,
width: 20,
alignItems: 'center',
justifyContent: 'center',
}}
onClick={(e) => {
e.stopPropagation();
props.onClick();
}}>
<CaretDownOutlined
style={{
cursor: 'pointer',
color: theme.textColorPlaceholder,
fontSize: 14,
transform: props.expanded ? '' : 'rotate(-90deg)',
}}
/>
</div>
);
} else {
return (
<div
style={{
width: 20,
height: TreeItemHeight,
}}></div>
);
}
}
function HighlightedText(props: {text: string}) {
const highlightManager: HighlightManager = useHighlighter();
return (
<Typography.Text>{highlightManager.render(props.text)} </Typography.Text>
);
}
function nodeIcon(node: TreeNode) {
const [icon, tooltip] = nodeData(node);
const iconComp =
typeof icon === 'string' ? <NodeIconImage src={icon} /> : icon;
if (tooltip == null) {
return iconComp;
} else {
return <Tooltip title={tooltip}>{iconComp}</Tooltip>;
}
}
function nodeData(node: TreeNode) {
if (node.tags.includes('LithoMountable')) {
return ['icons/litho-logo-blue.png', 'Litho Mountable (Primitive)'];
} else if (node.tags.includes('Litho')) {
return ['icons/litho-logo.png', 'Litho Component'];
} else if (node.tags.includes('CK')) {
if (node.tags.includes('iOS')) {
return ['icons/ck-mounted-logo.png', 'CK Mounted Component'];
}
return ['icons/ck-logo.png', 'CK Component'];
} else if (node.tags.includes('BloksBoundTree')) {
return ['facebook/bloks-logo-orange.png', 'Bloks Bridged component'];
} else if (node.tags.includes('BloksDerived')) {
return ['facebook/bloks-logo-blue.png', 'Bloks Derived (Server) component'];
} else if (node.tags.includes('Warning')) {
return [
<WarningOutlined
key="0"
style={{...nodeiconStyle, color: theme.errorColor}}
/>,
null,
];
} else {
return [
<div
key="0"
style={{
height: NodeIconSize,
width: 0,
marginRight: IconRightMargin,
}}
/>,
null,
];
}
}
const NodeIconSize = 14;
const IconRightMargin = '4px';
const nodeiconStyle: CSSProperties = {
height: NodeIconSize,
width: NodeIconSize,
marginRight: IconRightMargin,
userSelect: 'none',
};
const NodeIconImage = styled.img({...nodeiconStyle});
const renderDepthOffset = 12;
function findMatchingNodes(nodes: NodeMap, searchTerm: string): Id[] {
if (!searchTerm) {
return [];
}
return [...nodes.values()]
.filter(searchPredicate(searchTerm))
.map((node) => node.id);
}
function searchPredicate(
searchTerm: string,
): (node: ClientNode) => string | true | undefined {
return (node: ClientNode): string | true | undefined =>
node.name.toLowerCase().includes(searchTerm) ||
Object.keys(node.inlineAttributes).find((inlineAttr) =>
inlineAttr.toLocaleLowerCase().includes(searchTerm),
) ||
Object.values(node.inlineAttributes).find((inlineAttr) =>
inlineAttr.toLocaleLowerCase().includes(searchTerm),
);
}