Ant tree -> React complex tree
Summary: Upgraded from ant tree library to the much more capable React complex tree. Added the following: 1. Ability to expand / collapse nodes while automatically expanding / collapsing active/inactive children when they change 2. Keyboard controls of tree all the time 3. Basic search functionality 4. Selecting node in tree focuses and scrolls in the tree 5. Hover state for tree Reviewed By: lblasa Differential Revision: D40633876 fbshipit-source-id: 8dcef5ec2c277e476a3eb3cdaef62b15c25323c0
This commit is contained in:
committed by
Facebook GitHub Bot
parent
0c52ad307e
commit
f282a5eb8a
@@ -8,76 +8,108 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {Id, UINode} from '../types';
|
import {Id, UINode} from '../types';
|
||||||
import {Tree as AntTree, TreeDataNode} from 'antd';
|
import React, {useEffect, useRef} from 'react';
|
||||||
import {DownOutlined} from '@ant-design/icons';
|
import {
|
||||||
import React from 'react';
|
Tree as ComplexTree,
|
||||||
|
ControlledTreeEnvironment,
|
||||||
|
TreeItem,
|
||||||
|
} from 'react-complex-tree';
|
||||||
|
|
||||||
|
import {plugin} from '../index';
|
||||||
|
import {usePlugin, useValue} from 'flipper-plugin';
|
||||||
|
import {
|
||||||
|
InteractionMode,
|
||||||
|
TreeEnvironmentRef,
|
||||||
|
} from 'react-complex-tree/lib/esm/types';
|
||||||
|
|
||||||
export function Tree(props: {
|
export function Tree(props: {
|
||||||
rootId: Id;
|
rootId: Id;
|
||||||
nodes: Map<Id, UINode>;
|
nodes: Map<Id, UINode>;
|
||||||
selectedNode?: Id;
|
selectedNode?: Id;
|
||||||
|
hoveredNode?: Id;
|
||||||
onSelectNode: (id: Id) => void;
|
onSelectNode: (id: Id) => void;
|
||||||
onHoveredNode: (id?: Id) => void;
|
onHoveredNode: (id?: Id) => void;
|
||||||
}) {
|
}) {
|
||||||
const [antTree, inactive] = nodesToAntTree(props.rootId, props.nodes);
|
const instance = usePlugin(plugin);
|
||||||
|
const expandedItems = useValue(instance.treeState).expandedNodes;
|
||||||
|
const items = toComplexTree(props.nodes);
|
||||||
|
|
||||||
|
const treeRef = useRef<TreeEnvironmentRef>();
|
||||||
|
useEffect(() => {
|
||||||
|
//this makes the keyboard arrow controls work always, even when using the visualiser
|
||||||
|
treeRef.current?.focusTree('tree', true);
|
||||||
|
}, [props.hoveredNode, props.selectedNode]);
|
||||||
return (
|
return (
|
||||||
<div
|
<ControlledTreeEnvironment
|
||||||
onMouseLeave={() => {
|
ref={treeRef as any}
|
||||||
//This div exists so when mouse exits the entire tree then unhover
|
items={items}
|
||||||
props.onHoveredNode(undefined);
|
getItemTitle={(item) => item.data.name}
|
||||||
}}>
|
canRename={false}
|
||||||
<AntTree
|
canDragAndDrop={false}
|
||||||
showIcon
|
canSearch
|
||||||
showLine
|
autoFocus
|
||||||
titleRender={(node) => {
|
viewState={{
|
||||||
return (
|
tree: {
|
||||||
<div
|
focusedItem: props.hoveredNode,
|
||||||
onMouseEnter={() => {
|
expandedItems,
|
||||||
props.onHoveredNode(node.key as Id);
|
selectedItems: props.selectedNode ? [props.selectedNode] : [],
|
||||||
}}>
|
},
|
||||||
{node.title}
|
}}
|
||||||
</div>
|
onFocusItem={(item) => props.onHoveredNode(item.index)}
|
||||||
|
onExpandItem={(item) => {
|
||||||
|
instance.treeState.update((draft) => {
|
||||||
|
draft.expandedNodes.push(item.index);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCollapseItem={(item) =>
|
||||||
|
instance.treeState.update((draft) => {
|
||||||
|
draft.expandedNodes = draft.expandedNodes.filter(
|
||||||
|
(expandedItemIndex) => expandedItemIndex !== item.index,
|
||||||
);
|
);
|
||||||
}}
|
})
|
||||||
selectedKeys={[props.selectedNode ?? '']}
|
}
|
||||||
onSelect={(selected) => {
|
onSelectItems={(items) => props.onSelectNode(items[0])}
|
||||||
props.onSelectNode(selected[0] as Id);
|
defaultInteractionMode={{
|
||||||
}}
|
mode: 'custom',
|
||||||
defaultExpandAll
|
extends: InteractionMode.DoubleClickItemToExpand,
|
||||||
expandedKeys={[...props.nodes.keys()].filter(
|
createInteractiveElementProps: (
|
||||||
(key) => !inactive.includes(key),
|
item,
|
||||||
)}
|
treeId,
|
||||||
switcherIcon={<DownOutlined />}
|
actions,
|
||||||
treeData={[antTree]}
|
renderFlags,
|
||||||
|
) => ({
|
||||||
|
onClick: () => {
|
||||||
|
if (renderFlags.isSelected) {
|
||||||
|
actions.unselectItem();
|
||||||
|
} else {
|
||||||
|
actions.selectItem();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseOver: () => {
|
||||||
|
props.onHoveredNode(item.index);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}>
|
||||||
|
<ComplexTree
|
||||||
|
treeId="tree"
|
||||||
|
rootItem={props.rootId as any} //the typing in in the library is wrong here
|
||||||
|
treeLabel="UI"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ControlledTreeEnvironment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodesToAntTree(
|
function toComplexTree(nodes: Map<Id, UINode>): Record<Id, TreeItem<UINode>> {
|
||||||
root: Id,
|
const res: Record<Id, TreeItem<UINode>> = {};
|
||||||
nodes: Map<Id, UINode>,
|
for (const node of nodes.values()) {
|
||||||
): [TreeDataNode, Id[]] {
|
res[node.id] = {
|
||||||
const inactive: Id[] = [];
|
index: node.id,
|
||||||
|
canMove: false,
|
||||||
function uiNodeToAntNode(id: Id): TreeDataNode {
|
canRename: false,
|
||||||
const node = nodes.get(id);
|
children: node.children,
|
||||||
|
data: node,
|
||||||
if (node?.activeChild) {
|
hasChildren: node.children.length > 0,
|
||||||
for (const child of node.children) {
|
|
||||||
if (child !== node?.activeChild) {
|
|
||||||
inactive.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: id,
|
|
||||||
title: node?.name,
|
|
||||||
children: node?.children.map((id) => uiNodeToAntNode(id)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return res;
|
||||||
return [uiNodeToAntNode(root), inactive];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Id, Snapshot, Tag, UINode} from '../types';
|
import {Id, Snapshot, Tag, UINode} from '../types';
|
||||||
import {styled, Layout, theme} from 'flipper-plugin';
|
import {styled, Layout, theme} from 'flipper-plugin';
|
||||||
import {Typography} from 'antd';
|
|
||||||
|
|
||||||
export const Visualization2D: React.FC<
|
export const Visualization2D: React.FC<
|
||||||
{
|
{
|
||||||
root: Id;
|
rootId: Id;
|
||||||
nodes: Map<Id, UINode>;
|
nodes: Map<Id, UINode>;
|
||||||
snapshots: Map<Id, Snapshot>;
|
snapshots: Map<Id, Snapshot>;
|
||||||
hoveredNode?: Id;
|
hoveredNode?: Id;
|
||||||
@@ -24,7 +23,7 @@ export const Visualization2D: React.FC<
|
|||||||
modifierPressed: boolean;
|
modifierPressed: boolean;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>
|
} & React.HTMLAttributes<HTMLDivElement>
|
||||||
> = ({
|
> = ({
|
||||||
root,
|
rootId,
|
||||||
nodes,
|
nodes,
|
||||||
snapshots,
|
snapshots,
|
||||||
hoveredNode,
|
hoveredNode,
|
||||||
@@ -34,53 +33,49 @@ export const Visualization2D: React.FC<
|
|||||||
modifierPressed,
|
modifierPressed,
|
||||||
}) => {
|
}) => {
|
||||||
//todo, do a bfs search for the first bounds found
|
//todo, do a bfs search for the first bounds found
|
||||||
const rootBounds = nodes.get(root)?.bounds;
|
const rootBounds = nodes.get(rootId)?.bounds;
|
||||||
const rootSnapshot = snapshots.get(root);
|
const rootSnapshot = snapshots.get(rootId);
|
||||||
|
|
||||||
if (!rootBounds) {
|
if (!rootBounds) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Layout.Container gap="large">
|
<div
|
||||||
<Typography.Title>Visualizer</Typography.Title>
|
onMouseLeave={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
<div
|
onHoverNode(undefined);
|
||||||
onMouseLeave={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
style={{
|
||||||
onHoverNode(undefined);
|
/**
|
||||||
}}
|
* This relative position is so the root visualization 2DNode and outer border has a non static element to
|
||||||
style={{
|
* position itself relative to.
|
||||||
/**
|
*
|
||||||
* This relative position is so the root visualization 2DNode and outer border has a non static element to
|
* Subsequent Visualization2DNode are positioned relative to their parent as each one is position absolute
|
||||||
* position itself relative to.
|
* which despite the name acts are a reference point for absolute positioning...
|
||||||
*
|
*/
|
||||||
* Subsequent Visualization2DNode are positioned relative to their parent as each one is position absolute
|
position: 'relative',
|
||||||
* which despite the name acts are a reference point for absolute positioning...
|
width: toPx(rootBounds.width),
|
||||||
*/
|
height: toPx(rootBounds.height),
|
||||||
position: 'relative',
|
overflow: 'hidden',
|
||||||
width: toPx(rootBounds.width),
|
}}>
|
||||||
height: toPx(rootBounds.height),
|
<OuterBorder />
|
||||||
overflow: 'hidden',
|
{rootSnapshot ? (
|
||||||
}}>
|
<img
|
||||||
<OuterBorder />
|
src={'data:image/jpeg;base64,' + rootSnapshot}
|
||||||
{rootSnapshot ? (
|
style={{maxWidth: '100%'}}
|
||||||
<img
|
|
||||||
src={'data:image/jpeg;base64,' + rootSnapshot}
|
|
||||||
style={{maxWidth: '100%'}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Visualization2DNode
|
|
||||||
nodeId={root}
|
|
||||||
nodes={nodes}
|
|
||||||
snapshots={snapshots}
|
|
||||||
hoveredNode={hoveredNode}
|
|
||||||
selectedNode={selectedNode}
|
|
||||||
onSelectNode={onSelectNode}
|
|
||||||
onHoverNode={onHoverNode}
|
|
||||||
modifierPressed={modifierPressed}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : null}
|
||||||
</Layout.Container>
|
<Visualization2DNode
|
||||||
|
nodeId={rootId}
|
||||||
|
nodes={nodes}
|
||||||
|
snapshots={snapshots}
|
||||||
|
hoveredNode={hoveredNode}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onHoverNode={onHoverNode}
|
||||||
|
modifierPressed={modifierPressed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,32 +47,31 @@ export function Component() {
|
|||||||
|
|
||||||
if (rootId) {
|
if (rootId) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Layout.Horizontal grow>
|
||||||
<Layout.ScrollContainer>
|
<Layout.ScrollContainer>
|
||||||
<Layout.Horizontal>
|
<Tree
|
||||||
<Tree
|
selectedNode={selectedNode}
|
||||||
selectedNode={selectedNode}
|
hoveredNode={hoveredNode}
|
||||||
onSelectNode={setSelectedNode}
|
onSelectNode={setSelectedNode}
|
||||||
onHoveredNode={setHoveredNode}
|
onHoveredNode={setHoveredNode}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
rootId={rootId}
|
rootId={rootId}
|
||||||
/>
|
/>
|
||||||
<Visualization2D
|
|
||||||
root={rootId}
|
|
||||||
nodes={nodes}
|
|
||||||
snapshots={snapshots}
|
|
||||||
hoveredNode={hoveredNode}
|
|
||||||
onHoverNode={setHoveredNode}
|
|
||||||
selectedNode={selectedNode}
|
|
||||||
onSelectNode={setSelectedNode}
|
|
||||||
modifierPressed={ctrlPressed}
|
|
||||||
/>
|
|
||||||
</Layout.Horizontal>
|
|
||||||
</Layout.ScrollContainer>
|
</Layout.ScrollContainer>
|
||||||
|
<Visualization2D
|
||||||
|
rootId={rootId}
|
||||||
|
nodes={nodes}
|
||||||
|
snapshots={snapshots}
|
||||||
|
hoveredNode={hoveredNode}
|
||||||
|
onHoverNode={setHoveredNode}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
onSelectNode={setSelectedNode}
|
||||||
|
modifierPressed={ctrlPressed}
|
||||||
|
/>
|
||||||
{selectedNode && renderSidebar(nodes.get(selectedNode))}
|
{selectedNode && renderSidebar(nodes.get(selectedNode))}
|
||||||
</>
|
</Layout.Horizontal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>Nothing yet</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {PluginClient, createState, createDataSource} from 'flipper-plugin';
|
import {PluginClient, createState, createDataSource} from 'flipper-plugin';
|
||||||
import {Events, Id, PerfStatsEvent, Snapshot, UINode} from './types';
|
import {Events, Id, PerfStatsEvent, Snapshot, TreeState, UINode} from './types';
|
||||||
|
|
||||||
export function plugin(client: PluginClient<Events>) {
|
export function plugin(client: PluginClient<Events>) {
|
||||||
const rootId = createState<Id | undefined>(undefined);
|
const rootId = createState<Id | undefined>(undefined);
|
||||||
@@ -25,6 +25,8 @@ export function plugin(client: PluginClient<Events>) {
|
|||||||
const nodesAtom = createState<Map<Id, UINode>>(new Map());
|
const nodesAtom = createState<Map<Id, UINode>>(new Map());
|
||||||
const snapshotsAtom = createState<Map<Id, Snapshot>>(new Map());
|
const snapshotsAtom = createState<Map<Id, Snapshot>>(new Map());
|
||||||
|
|
||||||
|
const treeState = createState<TreeState>({expandedNodes: []});
|
||||||
|
|
||||||
client.onMessage('coordinateUpdate', (event) => {
|
client.onMessage('coordinateUpdate', (event) => {
|
||||||
nodesAtom.update((draft) => {
|
nodesAtom.update((draft) => {
|
||||||
const node = draft.get(event.nodeId);
|
const node = draft.get(event.nodeId);
|
||||||
@@ -36,6 +38,8 @@ export function plugin(client: PluginClient<Events>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const seenNodes = new Set<Id>();
|
||||||
client.onMessage('subtreeUpdate', (event) => {
|
client.onMessage('subtreeUpdate', (event) => {
|
||||||
snapshotsAtom.update((draft) => {
|
snapshotsAtom.update((draft) => {
|
||||||
draft.set(event.rootId, event.snapshot);
|
draft.set(event.rootId, event.snapshot);
|
||||||
@@ -45,9 +49,35 @@ export function plugin(client: PluginClient<Events>) {
|
|||||||
draft.set(node.id, node);
|
draft.set(node.id, node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
treeState.update((draft) => {
|
||||||
|
for (const node of event.nodes) {
|
||||||
|
if (!seenNodes.has(node.id)) {
|
||||||
|
draft.expandedNodes.push(node.id);
|
||||||
|
}
|
||||||
|
seenNodes.add(node.id);
|
||||||
|
|
||||||
|
if (node.activeChild) {
|
||||||
|
const inactiveChildren = node.children.filter(
|
||||||
|
(child) => child !== node.activeChild,
|
||||||
|
);
|
||||||
|
|
||||||
|
draft.expandedNodes = draft.expandedNodes.filter(
|
||||||
|
(nodeId) => !inactiveChildren.includes(nodeId),
|
||||||
|
);
|
||||||
|
draft.expandedNodes.push(node.activeChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {rootId, snapshots: snapshotsAtom, nodes: nodesAtom, perfEvents};
|
return {
|
||||||
|
rootId,
|
||||||
|
snapshots: snapshotsAtom,
|
||||||
|
nodes: nodesAtom,
|
||||||
|
perfEvents,
|
||||||
|
treeState,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export {Component} from './components/main';
|
export {Component} from './components/main';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
|
"react-complex-tree" : "^1.1.11",
|
||||||
"react-hotkeys-hook": "^3.4.7"
|
"react-hotkeys-hook": "^3.4.7"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {TreeItemIndex} from 'react-complex-tree';
|
||||||
|
|
||||||
export type Events = {
|
export type Events = {
|
||||||
init: InitEvent;
|
init: InitEvent;
|
||||||
subtreeUpdate: SubtreeUpdateEvent;
|
subtreeUpdate: SubtreeUpdateEvent;
|
||||||
@@ -89,7 +91,9 @@ export type Color = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Snapshot = string;
|
export type Snapshot = string;
|
||||||
export type Id = number;
|
export type Id = number | TreeItemIndex;
|
||||||
|
|
||||||
|
export type TreeState = {expandedNodes: Id[]};
|
||||||
|
|
||||||
export type Tag = 'Native' | 'Declarative' | 'Android' | 'Litho ';
|
export type Tag = 'Native' | 'Declarative' | 'Android' | 'Litho ';
|
||||||
|
|
||||||
|
|||||||
@@ -1644,6 +1644,11 @@ react-color@^2.19.3:
|
|||||||
reactcss "^1.2.0"
|
reactcss "^1.2.0"
|
||||||
tinycolor2 "^1.4.1"
|
tinycolor2 "^1.4.1"
|
||||||
|
|
||||||
|
react-complex-tree@^1.1.11:
|
||||||
|
version "1.1.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-complex-tree/-/react-complex-tree-1.1.11.tgz#430520d12908b033a4b278be0dfd8d0aa6654a85"
|
||||||
|
integrity sha512-hAkm2ZRH2lwZd7NEzZMQI8db/jI5T2fJsbwHX8oNPrG/WPdakc3eNpm2A4gLk2SBa88HeU6mnauVXg6Q6fJLow==
|
||||||
|
|
||||||
react-devtools-core@^4.26.1:
|
react-devtools-core@^4.26.1:
|
||||||
version "4.26.1"
|
version "4.26.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.26.1.tgz#2893fea58089be64c5356d5bd0eebda8d1bbf317"
|
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.26.1.tgz#2893fea58089be64c5356d5bd0eebda8d1bbf317"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
@import '../node_modules/antd/dist/antd.less';
|
@import '../node_modules/antd/dist/antd.less';
|
||||||
@import './typography.less';
|
@import './typography.less';
|
||||||
|
@import (inline) './plugins/public/node_modules/react-complex-tree/lib/style.css';
|
||||||
/* Based on: https://www.figma.com/file/4e6BMdm2SuZ1L7FSuOPQVC/Flipper?node-id=620%3A84636 */
|
/* Based on: https://www.figma.com/file/4e6BMdm2SuZ1L7FSuOPQVC/Flipper?node-id=620%3A84636 */
|
||||||
@background-transparent-hover: rgba(0, 0, 0, 0.1);
|
@background-transparent-hover: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user