Files
flipper/desktop/plugins/public/ui-debugger/components/Tree.tsx
Luke De Feo 1a9724d790 Added inline tree attributes
Summary:
This is temporary solution to get to parity with the old plugin. In future would like to make this more flexible on the desktop side

Additionally getData was renamed to getAttributes for consistency

Reviewed By: lblasa

Differential Revision: D41845248

fbshipit-source-id: 50e94a7712f5d42938229134e212cef5d379475d
2022-12-12 07:28:37 -08:00

307 lines
9.1 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, UINode} from '../types';
import React, {useEffect, useMemo, useRef} from 'react';
import {
Tree as ComplexTree,
ControlledTreeEnvironment,
TreeItem,
TreeInformation,
TreeItemRenderContext,
InteractionMode,
TreeEnvironmentRef,
} from 'react-complex-tree';
import {plugin} from '../index';
import {
usePlugin,
useValue,
HighlightManager,
HighlightProvider,
useHighlighter,
theme,
styled,
} from 'flipper-plugin';
import {head} from 'lodash';
import {Dropdown, Menu} from 'antd';
import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem';
export function Tree(props: {
rootId: Id;
nodes: Map<Id, UINode>;
selectedNode?: Id;
onSelectNode: (id: Id) => void;
}) {
const instance = usePlugin(plugin);
const expandedItems = useValue(instance.uiState.expandedNodes);
const focused = useValue(instance.uiState.focusedNode);
const items = useMemo(
() => toComplexTree(focused || props.rootId, props.nodes),
[focused, props.nodes, props.rootId],
);
const hoveredNodes = useValue(instance.uiState.hoveredNodes);
const treeEnvRef = useRef<TreeEnvironmentRef>();
const searchTerm = useValue(instance.uiState.searchTerm);
useEffect(() => {
//this makes the keyboard arrow controls work always, even when using the visualiser
treeEnvRef.current?.focusTree('tree', true);
}, [props.selectedNode]);
return (
<div
onMouseLeave={() => {
instance.uiState.hoveredNodes.set([]);
}}
style={
{
'--rct-color-tree-bg': theme.white,
'--rct-color-tree-focus-outline': theme.dividerColor,
'--rct-color-focustree-item-focused-border':
theme.selectionBackgroundColor,
'--rct-color-focustree-item-selected-bg':
theme.selectionBackgroundColor,
'--rct-color-nonfocustree-item-selected-bg':
theme.selectionBackgroundColor,
} as React.CSSProperties
}>
<HighlightProvider
text={searchTerm}
highlightColor={theme.searchHighlightBackground.yellow}>
<ControlledTreeEnvironment
ref={treeEnvRef as any}
items={items}
getItemTitle={(item) => item.data.name}
canRename={false}
canDragAndDrop={false}
viewState={{
tree: {
focusedItem: head(hoveredNodes),
expandedItems: [...expandedItems],
selectedItems: props.selectedNode ? [props.selectedNode] : [],
},
}}
onFocusItem={(item) => {
instance.uiState.hoveredNodes.set([item.index]);
}}
onExpandItem={(item) => {
instance.uiState.expandedNodes.update((draft) => {
draft.add(item.index);
});
}}
onCollapseItem={(item) =>
instance.uiState.expandedNodes.update((draft) => {
draft.delete(item.index);
})
}
renderItem={renderItem}
onSelectItems={(items) => props.onSelectNode(items[0])}
defaultInteractionMode={{
mode: 'custom',
extends: InteractionMode.DoubleClickItemToExpand,
createInteractiveElementProps: (
item,
treeId,
actions,
renderFlags,
) => ({
onClick: () => {
if (renderFlags.isSelected) {
actions.unselectItem();
} else {
actions.selectItem();
}
},
onMouseOver: () => {
if (!instance.uiState.isContextMenuOpen.get()) {
instance.uiState.hoveredNodes.set([item.index]);
}
},
}),
}}>
<ComplexTree
treeId="tree"
rootItem={FakeNode.id as any} //the typing in in the library is wrong here
treeLabel="UI"
/>
</ControlledTreeEnvironment>
</HighlightProvider>
</div>
);
}
//copied from https://github.com/lukasbach/react-complex-tree/blob/e3dcc435933284376a0fc6e3cc651e67ead678b5/packages/core/src/renderers/createDefaultRenderers.tsx
const cx = (...classNames: Array<string | undefined | false>) =>
classNames.filter((cn) => !!cn).join(' ');
const renderDepthOffset = 5;
const DecorationImage = styled.img({
height: 12,
marginRight: 5,
width: 12,
});
function defaultIcon(node: UINode) {
if (node.tags.includes('Litho')) {
return <DecorationImage src="icons/litho-logo.png" />;
}
}
function renderItem<C extends string = never>({
item,
depth,
children,
arrow,
context,
}: {
item: TreeItem<UINode>;
depth: number;
children: React.ReactNode | null;
title: React.ReactNode;
arrow: React.ReactNode;
context: TreeItemRenderContext<C>;
info: TreeInformation;
}) {
return (
<li
{...(context.itemContainerWithChildrenProps as any)}
className={cx(
'rct-tree-item-li',
item.hasChildren && 'rct-tree-item-li-hasChildren',
context.isSelected && 'rct-tree-item-li-selected',
context.isExpanded && 'rct-tree-item-li-expanded',
context.isFocused && 'rct-tree-item-li-focused',
context.isDraggingOver && 'rct-tree-item-li-dragging-over',
context.isSearchMatching && 'rct-tree-item-li-search-match',
)}>
<ContextMenu node={item.data} id={item.index} title={item.data.name}>
<div
{...(context.itemContainerWithoutChildrenProps as any)}
style={{
paddingLeft: `${(depth + 1) * renderDepthOffset}px`,
}}
className={cx(
'rct-tree-item-title-container',
item.hasChildren && 'rct-tree-item-title-container-hasChildren',
context.isSelected && 'rct-tree-item-title-container-selected',
context.isExpanded && 'rct-tree-item-title-container-expanded',
context.isFocused && 'rct-tree-item-title-container-focused',
context.isDraggingOver &&
'rct-tree-item-title-container-dragging-over',
context.isSearchMatching &&
'rct-tree-item-title-container-search-match',
)}>
{arrow}
<div
{...(context.interactiveElementProps as any)}
className={cx(
'rct-tree-item-button',
item.hasChildren && 'rct-tree-item-button-hasChildren',
context.isSelected && 'rct-tree-item-button-selected',
context.isExpanded && 'rct-tree-item-button-expanded',
context.isFocused && 'rct-tree-item-button-focused',
context.isDraggingOver && 'rct-tree-item-button-dragging-over',
context.isSearchMatching && 'rct-tree-item-button-search-match',
)}>
{defaultIcon(item.data)}
<HighlightedText text={item.data.name} />
</div>
</div>
</ContextMenu>
{children}
</li>
);
}
type ContextMenuProps = {node: UINode; id: Id; title: string};
const ContextMenu: React.FC<ContextMenuProps> = ({id, title, children}) => {
const instance = usePlugin(plugin);
const focusedNode = instance.uiState.focusedNode.get();
return (
<Dropdown
onVisibleChange={(visible) => {
instance.uiState.isContextMenuOpen.set(visible);
}}
overlay={() => (
<Menu>
{focusedNode !== head(instance.uiState.hoveredNodes.get()) && (
<UIDebuggerMenuItem
key="focus"
text={`Focus ${title}`}
onClick={() => {
instance.uiState.focusedNode.set(id);
}}
/>
)}
{focusedNode && (
<UIDebuggerMenuItem
key="remove-focus"
text="Remove focus"
onClick={() => {
instance.uiState.focusedNode.set(undefined);
}}
/>
)}
</Menu>
)}
trigger={['contextMenu']}>
<div>{children}</div>
</Dropdown>
);
};
function HighlightedText(props: {text: string}) {
const highlightManager: HighlightManager = useHighlighter();
return <span>{highlightManager.render(props.text)}</span>;
}
const FakeNode: UINode = {
id: 'Fakeroot',
qualifiedName: 'Fakeroot',
name: 'Fakeroot',
inlineAttributes: {},
children: [],
attributes: {},
bounds: {x: 0, y: 0, height: 0, width: 0},
tags: [],
};
function toComplexTree(
root: Id,
nodes: Map<Id, UINode>,
): Record<Id, TreeItem<UINode>> {
const res: Record<Id, TreeItem<UINode>> = {};
for (const node of nodes.values()) {
res[node.id] = {
index: node.id,
children: node.children,
data: node,
hasChildren: node.children.length > 0,
};
}
//the library doesnt render the root node so we insert a fake one which will never be rendered
//https://github.com/lukasbach/react-complex-tree/issues/42
res[FakeNode.id] = {
index: FakeNode.id,
children: [root],
hasChildren: true,
data: FakeNode,
};
return res;
}