Tree2 feedback
Summary: 1. only useValue from tree root 2. Pass down props for ui state instead subscribing ad hoc 3. Pass down callbacks, instead of updating atoms ad hoc. 4. Create ui actions object holding api, will use this later on in the vizualizer as some of the same In general its more verbose but with memoizing perf should be fine should hopefully be easier to reason about components and what they can do as things are more explicit Hopefully this serves as a general template for how to organise the react code going forward Reviewed By: lblasa Differential Revision: D41872490 fbshipit-source-id: 94a33b0e951c04df367ba102fa0a097d4a0389cd
This commit is contained in:
committed by
Facebook GitHub Bot
parent
1a9724d790
commit
040240ec34
@@ -8,14 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {Id, UINode} from '../types';
|
import {Id, UINode} from '../types';
|
||||||
import React, {
|
import React, {Ref, RefObject, useEffect, useMemo, useRef} from 'react';
|
||||||
Ref,
|
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
import {
|
||||||
Atom,
|
Atom,
|
||||||
HighlightManager,
|
HighlightManager,
|
||||||
@@ -50,6 +43,8 @@ export function Tree2({
|
|||||||
const focusedNode = useValue(instance.uiState.focusedNode);
|
const focusedNode = useValue(instance.uiState.focusedNode);
|
||||||
const expandedNodes = useValue(instance.uiState.expandedNodes);
|
const expandedNodes = useValue(instance.uiState.expandedNodes);
|
||||||
const searchTerm = useValue(instance.uiState.searchTerm);
|
const searchTerm = useValue(instance.uiState.searchTerm);
|
||||||
|
const isContextMenuOpen = useValue(instance.uiState.isContextMenuOpen);
|
||||||
|
const hoveredNode = head(useValue(instance.uiState.hoveredNodes));
|
||||||
|
|
||||||
const {treeNodes, refs} = useMemo(() => {
|
const {treeNodes, refs} = useMemo(() => {
|
||||||
const treeNodes = toTreeList(nodes, focusedNode || rootId, expandedNodes);
|
const treeNodes = toTreeList(nodes, focusedNode || rootId, expandedNodes);
|
||||||
@@ -67,7 +62,10 @@ export function Tree2({
|
|||||||
treeNodes,
|
treeNodes,
|
||||||
refs,
|
refs,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
|
hoveredNode,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
instance.uiActions.onExpandNode,
|
||||||
|
instance.uiActions.onCollapseNode,
|
||||||
isUsingKBToScroll,
|
isUsingKBToScroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,16 +85,26 @@ export function Tree2({
|
|||||||
highlightColor={theme.searchHighlightBackground.yellow}>
|
highlightColor={theme.searchHighlightBackground.yellow}>
|
||||||
<div
|
<div
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
instance.uiState.hoveredNodes.set([]);
|
if (isContextMenuOpen === false) {
|
||||||
|
instance.uiState.hoveredNodes.set([]);
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
{treeNodes.map((treeNode, index) => (
|
{treeNodes.map((treeNode, index) => (
|
||||||
<MemoTreeItemContainer
|
<MemoTreeItemContainer
|
||||||
innerRef={refs[index]}
|
innerRef={refs[index]}
|
||||||
isUsingKBToScroll={isUsingKBToScroll}
|
|
||||||
key={treeNode.id}
|
key={treeNode.id}
|
||||||
treeNode={treeNode}
|
treeNode={treeNode}
|
||||||
selectedNode={selectedNode}
|
selectedNode={selectedNode}
|
||||||
|
hoveredNode={hoveredNode}
|
||||||
|
focusedNode={focusedNode}
|
||||||
|
isUsingKBToScroll={isUsingKBToScroll}
|
||||||
|
isContextMenuOpen={isContextMenuOpen}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onExpandNode={instance.uiActions.onExpandNode}
|
||||||
|
onCollapseNode={instance.uiActions.onCollapseNode}
|
||||||
|
onContextMenuOpen={instance.uiActions.onContextMenuOpen}
|
||||||
|
onFocusNode={instance.uiActions.onFocusNode}
|
||||||
|
onHoverNode={instance.uiActions.onHoverNode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,43 +120,65 @@ export type TreeNode = UINode & {
|
|||||||
const MemoTreeItemContainer = React.memo(
|
const MemoTreeItemContainer = React.memo(
|
||||||
TreeItemContainer,
|
TreeItemContainer,
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
const id = prevProps.treeNode.id;
|
const id = nextProps.treeNode.id;
|
||||||
return (
|
return (
|
||||||
prevProps.treeNode === nextProps.treeNode &&
|
prevProps.treeNode === nextProps.treeNode &&
|
||||||
id !== prevProps.selectedNode &&
|
prevProps.isContextMenuOpen === nextProps.isContextMenuOpen &&
|
||||||
id !== nextProps.selectedNode
|
//make sure that prev or next hover/selected node doesnt concern this tree node
|
||||||
|
prevProps.hoveredNode !== id &&
|
||||||
|
nextProps.hoveredNode !== id &&
|
||||||
|
prevProps.selectedNode !== id &&
|
||||||
|
nextProps.selectedNode !== id
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function TreeItemContainer({
|
function TreeItemContainer({
|
||||||
innerRef,
|
innerRef,
|
||||||
isUsingKBToScroll,
|
|
||||||
treeNode,
|
treeNode,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
|
hoveredNode,
|
||||||
|
focusedNode,
|
||||||
|
isUsingKBToScroll,
|
||||||
|
isContextMenuOpen,
|
||||||
|
onFocusNode,
|
||||||
|
onContextMenuOpen,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onExpandNode,
|
||||||
|
onCollapseNode,
|
||||||
|
onHoverNode,
|
||||||
}: {
|
}: {
|
||||||
innerRef: Ref<any>;
|
innerRef: Ref<any>;
|
||||||
isUsingKBToScroll: RefObject<boolean>;
|
|
||||||
treeNode: TreeNode;
|
treeNode: TreeNode;
|
||||||
selectedNode?: Id;
|
selectedNode?: Id;
|
||||||
hoveredNode?: Id;
|
hoveredNode?: Id;
|
||||||
|
focusedNode?: Id;
|
||||||
|
isUsingKBToScroll: RefObject<boolean>;
|
||||||
|
isContextMenuOpen: boolean;
|
||||||
|
onFocusNode: (id?: Id) => void;
|
||||||
|
onContextMenuOpen: (open: boolean) => void;
|
||||||
onSelectNode: (node?: Id) => void;
|
onSelectNode: (node?: Id) => void;
|
||||||
|
onExpandNode: (node: Id) => void;
|
||||||
|
onCollapseNode: (node: Id) => void;
|
||||||
|
onHoverNode: (node: Id) => void;
|
||||||
}) {
|
}) {
|
||||||
const instance = usePlugin(plugin);
|
|
||||||
const isHovered = useIsHovered(treeNode.id);
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu node={treeNode}>
|
<ContextMenu
|
||||||
|
onContextMenuOpen={onContextMenuOpen}
|
||||||
|
onFocusNode={onFocusNode}
|
||||||
|
focusedNode={focusedNode}
|
||||||
|
hoveredNode={hoveredNode}
|
||||||
|
node={treeNode}>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
isSelected={treeNode.id === selectedNode}
|
isSelected={treeNode.id === selectedNode}
|
||||||
isHovered={isHovered}
|
isHovered={hoveredNode === treeNode.id}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (
|
if (
|
||||||
isUsingKBToScroll.current === false &&
|
isUsingKBToScroll.current === false &&
|
||||||
instance.uiState.isContextMenuOpen.get() == false
|
isContextMenuOpen == false
|
||||||
) {
|
) {
|
||||||
instance.uiState.hoveredNodes.set([treeNode.id]);
|
onHoverNode(treeNode.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -157,15 +187,13 @@ function TreeItemContainer({
|
|||||||
item={treeNode}>
|
item={treeNode}>
|
||||||
<ExpandedIconOrSpace
|
<ExpandedIconOrSpace
|
||||||
expanded={treeNode.isExpanded}
|
expanded={treeNode.isExpanded}
|
||||||
children={treeNode.children}
|
showIcon={treeNode.children.length > 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
instance.uiState.expandedNodes.update((draft) => {
|
if (treeNode.isExpanded) {
|
||||||
if (draft.has(treeNode.id)) {
|
onCollapseNode(treeNode.id);
|
||||||
draft.delete(treeNode.id);
|
} else {
|
||||||
} else {
|
onExpandNode(treeNode.id);
|
||||||
draft.add(treeNode.id);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{nodeIcon(treeNode)}
|
{nodeIcon(treeNode)}
|
||||||
@@ -198,27 +226,6 @@ function InlineAttributes({attributes}: {attributes: Record<string, string>}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIsHovered(nodeId: Id) {
|
|
||||||
const instance = usePlugin(plugin);
|
|
||||||
const [isHovered, setIsHovered] = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
instance.uiState.hoveredNodes.subscribe(listener);
|
|
||||||
return () => {
|
|
||||||
instance.uiState.hoveredNodes.unsubscribe(listener);
|
|
||||||
};
|
|
||||||
}, [instance.uiState.hoveredNodes, nodeId]);
|
|
||||||
|
|
||||||
return isHovered;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TreeItem = styled.li<{
|
const TreeItem = styled.li<{
|
||||||
item: TreeNode;
|
item: TreeNode;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
@@ -238,24 +245,30 @@ const TreeItem = styled.li<{
|
|||||||
function ExpandedIconOrSpace(props: {
|
function ExpandedIconOrSpace(props: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
children: Id[];
|
showIcon: boolean;
|
||||||
}) {
|
}) {
|
||||||
return props.children.length > 0 ? (
|
if (props.showIcon) {
|
||||||
<div style={{display: 'flex'}} onClick={props.onClick}>
|
return (
|
||||||
<Glyph
|
<div
|
||||||
style={{
|
role="button"
|
||||||
transform: props.expanded ? 'rotate(90deg)' : '',
|
tabIndex={0}
|
||||||
marginRight: '4px',
|
style={{display: 'flex'}}
|
||||||
marginBottom: props.expanded ? '2px' : '',
|
onClick={props.onClick}>
|
||||||
}}
|
<Glyph
|
||||||
name="chevron-right"
|
style={{
|
||||||
size={12}
|
transform: props.expanded ? 'rotate(90deg)' : '',
|
||||||
color="grey"
|
marginRight: '4px',
|
||||||
/>
|
marginBottom: props.expanded ? '2px' : '',
|
||||||
</div>
|
}}
|
||||||
) : (
|
name="chevron-right"
|
||||||
<div style={{width: '12px'}}></div>
|
size={12}
|
||||||
);
|
color="grey"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <div style={{width: '12px'}}></div>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedText(props: {text: string}) {
|
function HighlightedText(props: {text: string}) {
|
||||||
@@ -277,23 +290,33 @@ const DecorationImage = styled.img({
|
|||||||
|
|
||||||
const renderDepthOffset = 4;
|
const renderDepthOffset = 4;
|
||||||
|
|
||||||
const ContextMenu: React.FC<{node: TreeNode}> = ({node, children}) => {
|
const ContextMenu: React.FC<{
|
||||||
const instance = usePlugin(plugin);
|
node: TreeNode;
|
||||||
const focusedNode = instance.uiState.focusedNode.get();
|
hoveredNode?: Id;
|
||||||
|
focusedNode?: Id;
|
||||||
|
onFocusNode: (id?: Id) => void;
|
||||||
|
onContextMenuOpen: (open: boolean) => void;
|
||||||
|
}> = ({
|
||||||
|
node,
|
||||||
|
hoveredNode,
|
||||||
|
children,
|
||||||
|
focusedNode,
|
||||||
|
onFocusNode,
|
||||||
|
onContextMenuOpen,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
onVisibleChange={(visible) => {
|
onVisibleChange={(visible) => {
|
||||||
instance.uiState.isContextMenuOpen.set(visible);
|
onContextMenuOpen(visible);
|
||||||
}}
|
}}
|
||||||
overlay={() => (
|
overlay={() => (
|
||||||
<Menu>
|
<Menu>
|
||||||
{focusedNode !== head(instance.uiState.hoveredNodes.get()) && (
|
{focusedNode !== hoveredNode && (
|
||||||
<UIDebuggerMenuItem
|
<UIDebuggerMenuItem
|
||||||
key="focus"
|
key="focus"
|
||||||
text={`Focus ${node.name}`}
|
text={`Focus ${node.name}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
instance.uiState.focusedNode.set(node.id);
|
onFocusNode(node.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +326,7 @@ const ContextMenu: React.FC<{node: TreeNode}> = ({node, children}) => {
|
|||||||
key="remove-focus"
|
key="remove-focus"
|
||||||
text="Remove focus"
|
text="Remove focus"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
instance.uiState.focusedNode.set(undefined);
|
onFocusNode(undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -356,7 +379,10 @@ function useKeyboardShortcuts(
|
|||||||
treeNodes: TreeNode[],
|
treeNodes: TreeNode[],
|
||||||
refs: React.RefObject<HTMLLIElement>[],
|
refs: React.RefObject<HTMLLIElement>[],
|
||||||
selectedNode: Id | undefined,
|
selectedNode: Id | undefined,
|
||||||
|
hoveredNode: Id | undefined,
|
||||||
onSelectNode: (id?: Id) => void,
|
onSelectNode: (id?: Id) => void,
|
||||||
|
onExpandNode: (id: Id) => void,
|
||||||
|
onCollapseNode: (id: Id) => void,
|
||||||
isUsingKBToScroll: React.MutableRefObject<boolean>,
|
isUsingKBToScroll: React.MutableRefObject<boolean>,
|
||||||
) {
|
) {
|
||||||
const instance = usePlugin(plugin);
|
const instance = usePlugin(plugin);
|
||||||
@@ -365,31 +391,24 @@ function useKeyboardShortcuts(
|
|||||||
const listener = (event: KeyboardEvent) => {
|
const listener = (event: KeyboardEvent) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
const hoveredNode = head(instance.uiState.hoveredNodes.get());
|
|
||||||
if (hoveredNode != null) {
|
if (hoveredNode != null) {
|
||||||
onSelectNode(hoveredNode);
|
onSelectNode(hoveredNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ArrowRight': {
|
|
||||||
|
case 'ArrowRight':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (hoveredNode) {
|
||||||
instance.uiState.expandedNodes.update((draft) => {
|
onExpandNode(hoveredNode);
|
||||||
if (selectedNode) {
|
}
|
||||||
draft.add(selectedNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case 'ArrowLeft': {
|
case 'ArrowLeft': {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
instance.uiState.expandedNodes.update((draft) => {
|
if (hoveredNode) {
|
||||||
if (selectedNode) {
|
onCollapseNode(hoveredNode);
|
||||||
draft.delete(selectedNode);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,12 +432,14 @@ function useKeyboardShortcuts(
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
refs,
|
refs,
|
||||||
instance.uiState.expandedNodes,
|
|
||||||
treeNodes,
|
treeNodes,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
instance.uiState.hoveredNodes,
|
|
||||||
isUsingKBToScroll,
|
isUsingKBToScroll,
|
||||||
|
onExpandNode,
|
||||||
|
onCollapseNode,
|
||||||
|
instance.uiState.hoveredNodes,
|
||||||
|
hoveredNode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
MetadataId,
|
MetadataId,
|
||||||
PerfStatsEvent,
|
PerfStatsEvent,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
TreeState,
|
|
||||||
UINode,
|
UINode,
|
||||||
} from './types';
|
} from './types';
|
||||||
import './node_modules/react-complex-tree/lib/style.css';
|
import './node_modules/react-complex-tree/lib/style.css';
|
||||||
@@ -171,6 +170,7 @@ export function plugin(client: PluginClient<Events>) {
|
|||||||
return {
|
return {
|
||||||
rootId,
|
rootId,
|
||||||
uiState,
|
uiState,
|
||||||
|
uiActions: uiActions(uiState),
|
||||||
nodes,
|
nodes,
|
||||||
snapshot,
|
snapshot,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -194,6 +194,48 @@ function setParentPointers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UIActions = {
|
||||||
|
onHoverNode: (node: Id) => void;
|
||||||
|
onFocusNode: (focused?: Id) => void;
|
||||||
|
onContextMenuOpen: (open: boolean) => void;
|
||||||
|
onExpandNode: (node: Id) => void;
|
||||||
|
onCollapseNode: (node: Id) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function uiActions(uiState: UIState): UIActions {
|
||||||
|
const onExpandNode = (node: Id) => {
|
||||||
|
uiState.expandedNodes.update((draft) => {
|
||||||
|
draft.add(node);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCollapseNode = (node: Id) => {
|
||||||
|
uiState.expandedNodes.update((draft) => {
|
||||||
|
draft.delete(node);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHoverNode = (node: Id) => {
|
||||||
|
uiState.hoveredNodes.set([node]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenuOpen = (open: boolean) => {
|
||||||
|
uiState.isContextMenuOpen.set(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFocusNode = (focused?: Id) => {
|
||||||
|
uiState.focusedNode.set(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onExpandNode,
|
||||||
|
onCollapseNode,
|
||||||
|
onHoverNode,
|
||||||
|
onContextMenuOpen,
|
||||||
|
onFocusNode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function checkFocusedNodeStillActive(uiState: UIState, nodes: Map<Id, UINode>) {
|
function checkFocusedNodeStillActive(uiState: UIState, nodes: Map<Id, UINode>) {
|
||||||
const focusedNodeId = uiState.focusedNode.get();
|
const focusedNodeId = uiState.focusedNode.get();
|
||||||
const focusedNode = focusedNodeId && nodes.get(focusedNodeId);
|
const focusedNode = focusedNodeId && nodes.get(focusedNodeId);
|
||||||
|
|||||||
Reference in New Issue
Block a user