From d8fda847b827a1c1dd2764a6410889876a970954 Mon Sep 17 00:00:00 2001 From: Luke De Feo Date: Fri, 21 Jul 2023 07:17:31 -0700 Subject: [PATCH] UIDREfactor 7/n Split tree.tsx Reviewed By: lblasa Differential Revision: D47548809 fbshipit-source-id: bdcbc96129a289c64a4a0b17fab29ac91d73d48e --- .../components/tree/ContextMenu.tsx | 149 ++++++ .../ui-debugger/components/tree/Tree.tsx | 443 +----------------- .../components/tree/toTreeList.tsx | 132 ++++++ .../components/tree/useKeyboardControls.tsx | 183 ++++++++ 4 files changed, 474 insertions(+), 433 deletions(-) create mode 100644 desktop/plugins/public/ui-debugger/components/tree/ContextMenu.tsx create mode 100644 desktop/plugins/public/ui-debugger/components/tree/toTreeList.tsx create mode 100644 desktop/plugins/public/ui-debugger/components/tree/useKeyboardControls.tsx diff --git a/desktop/plugins/public/ui-debugger/components/tree/ContextMenu.tsx b/desktop/plugins/public/ui-debugger/components/tree/ContextMenu.tsx new file mode 100644 index 000000000..6deaa3fe7 --- /dev/null +++ b/desktop/plugins/public/ui-debugger/components/tree/ContextMenu.tsx @@ -0,0 +1,149 @@ +/** + * 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 {FrameworkEvent, Id, ClientNode} from '../../ClientTypes'; +import {ViewMode} from '../../DesktopTypes'; +import React, {ReactNode} from 'react'; +import {DataSource, getFlipperLib} from 'flipper-plugin'; +import {Dropdown, Menu} from 'antd'; +import {UIDebuggerMenuItem} from '../util/UIDebuggerMenuItem'; +import {tracker} from '../../utils/tracker'; +import { + BigGrepContextMenuItems, + IDEContextMenuItems, +} from '../fb-stubs/IDEContextMenu'; +import { + CopyOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + SnippetsOutlined, + TableOutlined, +} from '@ant-design/icons'; + +export const ContextMenu: React.FC<{ + frameworkEvents: DataSource; + nodes: Map; + hoveredNodeId?: Id; + focusedNodeId?: Id; + onFocusNode: (id?: Id) => void; + onContextMenuOpen: (open: boolean) => void; + onSetViewMode: (viewMode: ViewMode) => void; +}> = ({ + nodes, + frameworkEvents, + hoveredNodeId, + children, + focusedNodeId, + onFocusNode, + onContextMenuOpen, + onSetViewMode, +}) => { + const copyItems: ReactNode[] = []; + const hoveredNode = nodes.get(hoveredNodeId ?? Number.MAX_SAFE_INTEGER); + + if (hoveredNode) { + copyItems.push( + } + onClick={() => { + tracker.track('context-menu-name-copied', {name: hoveredNode.name}); + getFlipperLib().writeTextToClipboard(hoveredNode.name); + }} + />, + ); + + copyItems.push( + Object.entries(hoveredNode.inlineAttributes).map(([key, value]) => ( + } + onClick={() => { + tracker.track('context-menu-copied', { + name: hoveredNode.name, + key, + value, + }); + getFlipperLib().writeTextToClipboard(value); + }} + /> + )), + ); + + copyItems.push( + , + ); + } + const focus = hoveredNode != null && + focusedNodeId !== hoveredNodeId && + hoveredNode.bounds.height !== 0 && + hoveredNode.bounds.width !== 0 && ( + } + onClick={() => { + onFocusNode(hoveredNodeId); + }} + /> + ); + + const removeFocus = focusedNodeId && ( + } + onClick={() => { + onFocusNode(undefined); + }} + /> + ); + + const matchingFrameworkEvents = + (hoveredNode && + frameworkEvents.getAllRecordsByIndex({nodeId: hoveredNode.id})) ?? + []; + + const frameworkEventsTable = matchingFrameworkEvents.length > 0 && ( + { + onSetViewMode({ + mode: 'frameworkEventsTable', + treeRootId: hoveredNode?.id ?? '', + }); + }} + icon={} + /> + ); + + return ( + { + onContextMenuOpen(visible); + }} + overlay={() => ( + + {focus} + {removeFocus} + {frameworkEventsTable} + {(focus || removeFocus || frameworkEventsTable) && ( + + )} + {copyItems} + + {hoveredNode && } + + )} + trigger={['contextMenu']}> + {children} + + ); +}; diff --git a/desktop/plugins/public/ui-debugger/components/tree/Tree.tsx b/desktop/plugins/public/ui-debugger/components/tree/Tree.tsx index 4f45c8ccd..051fd26ce 100644 --- a/desktop/plugins/public/ui-debugger/components/tree/Tree.tsx +++ b/desktop/plugins/public/ui-debugger/components/tree/Tree.tsx @@ -7,15 +7,9 @@ * @format */ -import { - FrameworkEvent, - FrameworkEventType, - Id, - ClientNode, -} from '../../ClientTypes'; -import {OnSelectNode, ViewMode} from '../../DesktopTypes'; +import {Id, ClientNode} from '../../ClientTypes'; +import {OnSelectNode} from '../../DesktopTypes'; import React, { - ReactNode, Ref, RefObject, useEffect, @@ -24,8 +18,6 @@ import React, { useRef, } from 'react'; import { - DataSource, - getFlipperLib, HighlightManager, HighlightProvider, styled, @@ -36,30 +28,18 @@ import { } from 'flipper-plugin'; import {plugin} from '../../index'; import {Glyph} from 'flipper'; -import {head, last} from 'lodash'; -import {reverse} from 'lodash/fp'; -import {Badge, Dropdown, Menu, Typography} from 'antd'; -import {UIDebuggerMenuItem} from '../util/UIDebuggerMenuItem'; -import {tracker} from '../../utils/tracker'; +import {head} from 'lodash'; +import {Badge, Typography} from 'antd'; -import {useVirtualizer, Virtualizer} from '@tanstack/react-virtual'; -import { - BigGrepContextMenuItems, - IDEContextMenuItems, -} from '../fb-stubs/IDEContextMenu'; -import { - CopyOutlined, - FullscreenExitOutlined, - FullscreenOutlined, - SnippetsOutlined, - TableOutlined, -} from '@ant-design/icons'; +import {useVirtualizer} from '@tanstack/react-virtual'; +import {ContextMenu} from './ContextMenu'; +import {MillisSinceEpoch, useKeyboardControls} from './useKeyboardControls'; +import {toTreeList} from './toTreeList'; const {Text} = Typography; type LineStyle = 'ToParent' | 'ToChildren'; -type MillisSinceEpoch = number; type NodeIndentGuide = { depth: number; style: LineStyle; @@ -97,7 +77,7 @@ export function Tree2({ const highlightedNodes = useValue(instance.uiState.highlightedNodes); const {treeNodes, refs} = useMemo(() => { - const treeNodes = toTreeNodes( + const treeNodes = toTreeList( nodes, focusedNode || rootId, expandedNodes, @@ -143,7 +123,7 @@ export function Tree2({ } }, [rowVirtualizer, searchTerm, treeNodes]); - useKeyboardShortcuts( + useKeyboardControls( treeNodes, rowVirtualizer, selectedNode?.id, @@ -512,409 +492,6 @@ const DecorationImage = styled.img({ const renderDepthOffset = 12; -const ContextMenu: React.FC<{ - frameworkEvents: DataSource; - nodes: Map; - hoveredNodeId?: Id; - focusedNodeId?: Id; - onFocusNode: (id?: Id) => void; - onContextMenuOpen: (open: boolean) => void; - onSetViewMode: (viewMode: ViewMode) => void; -}> = ({ - nodes, - frameworkEvents, - hoveredNodeId, - children, - focusedNodeId, - onFocusNode, - onContextMenuOpen, - onSetViewMode, -}) => { - const copyItems: ReactNode[] = []; - const hoveredNode = nodes.get(hoveredNodeId ?? Number.MAX_SAFE_INTEGER); - - if (hoveredNode) { - copyItems.push( - } - onClick={() => { - tracker.track('context-menu-name-copied', {name: hoveredNode.name}); - getFlipperLib().writeTextToClipboard(hoveredNode.name); - }} - />, - ); - - copyItems.push( - Object.entries(hoveredNode.inlineAttributes).map(([key, value]) => ( - } - onClick={() => { - tracker.track('context-menu-copied', { - name: hoveredNode.name, - key, - value, - }); - getFlipperLib().writeTextToClipboard(value); - }} - /> - )), - ); - - copyItems.push( - , - ); - } - const focus = hoveredNode != null && - focusedNodeId !== hoveredNodeId && - hoveredNode.bounds.height !== 0 && - hoveredNode.bounds.width !== 0 && ( - } - onClick={() => { - onFocusNode(hoveredNodeId); - }} - /> - ); - - const removeFocus = focusedNodeId && ( - } - onClick={() => { - onFocusNode(undefined); - }} - /> - ); - - const matchingFrameworkEvents = - (hoveredNode && - frameworkEvents.getAllRecordsByIndex({nodeId: hoveredNode.id})) ?? - []; - - const frameworkEventsTable = matchingFrameworkEvents.length > 0 && ( - { - onSetViewMode({ - mode: 'frameworkEventsTable', - treeRootId: hoveredNode?.id ?? '', - }); - }} - icon={} - /> - ); - - return ( - { - onContextMenuOpen(visible); - }} - overlay={() => ( - - {focus} - {removeFocus} - {frameworkEventsTable} - {(focus || removeFocus || frameworkEventsTable) && ( - - )} - {copyItems} - - {hoveredNode && } - - )} - trigger={['contextMenu']}> - {children} - - ); -}; - -type TreeListStackItem = { - node: ClientNode; - depth: number; - isChildOfSelectedNode: boolean; - selectedNodeDepth: number; -}; - -function toTreeNodes( - nodes: Map, - rootId: Id, - expandedNodes: Set, - selectedNode: Id | undefined, - frameworkEvents: DataSource, - frameworkEventsMonitoring: Map, - filterMainThreadMonitoring: boolean, -): TreeNode[] { - const root = nodes.get(rootId); - if (root == null) { - return []; - } - const stack = [ - {node: root, depth: 0, isChildOfSelectedNode: false, selectedNodeDepth: 0}, - ] as TreeListStackItem[]; - - const treeNodes = [] as TreeNode[]; - - let i = 0; - while (stack.length > 0) { - const stackItem = stack.pop()!!; - - const {node, depth} = stackItem; - - //if the previous item has an indent guide but we don't then it was the last segment - //so we trim the bottom - const prevItemLine = last(treeNodes)?.indentGuide; - if (prevItemLine != null && stackItem.isChildOfSelectedNode === false) { - prevItemLine.trimBottom = true; - } - - const isExpanded = expandedNodes.has(node.id); - const isSelected = node.id === selectedNode; - - let events = frameworkEvents.getAllRecordsByIndex({nodeId: node.id}); - if (events) { - events = events - .filter((e) => frameworkEventsMonitoring.get(e.type)) - .filter( - (e) => filterMainThreadMonitoring === false || e.thread === 'main', - ); - } - - treeNodes.push({ - ...node, - idx: i, - depth, - isExpanded, - frameworkEvents: events.length > 0 ? events.length : null, - indentGuide: stackItem.isChildOfSelectedNode - ? { - depth: stackItem.selectedNodeDepth, - style: 'ToChildren', - //if first child of selected node add horizontal marker - addHorizontalMarker: depth === stackItem.selectedNodeDepth + 1, - trimBottom: false, - } - : null, - }); - i++; - - let isChildOfSelectedNode = stackItem.isChildOfSelectedNode; - let selectedNodeDepth = stackItem.selectedNodeDepth; - if (isSelected) { - isChildOfSelectedNode = true; - selectedNodeDepth = depth; - // walk back through tree nodes, while depth is greater or equal than current it is your - // parents child / your previous cousin so set dashed line - for (let i = treeNodes.length - 1; i >= 0; i--) { - const prevNode = treeNodes[i]; - if (prevNode.depth < depth) { - break; - } - prevNode.indentGuide = { - depth: selectedNodeDepth - 1, - style: 'ToParent', - addHorizontalMarker: prevNode.depth == depth, - trimBottom: prevNode.id === selectedNode, - }; - } - } - - if (isExpanded) { - //since we do dfs and use a stack we have to reverse children to get the order correct - for (const childId of reverse(node.children)) { - const child = nodes.get(childId); - if (child != null) { - stack.push({ - node: child, - depth: depth + 1, - isChildOfSelectedNode: isChildOfSelectedNode, - selectedNodeDepth: selectedNodeDepth, - }); - } - } - } - } - - //always trim last indent guide - const prevItemLine = last(treeNodes)?.indentGuide; - if (prevItemLine != null) { - prevItemLine.trimBottom = true; - } - - return treeNodes; -} - -function useKeyboardShortcuts( - treeNodes: TreeNode[], - rowVirtualizer: Virtualizer, - selectedNode: Id | undefined, - hoveredNodeId: Id | undefined, - onSelectNode: OnSelectNode, - onHoverNode: (...id: Id[]) => void, - onExpandNode: (id: Id) => void, - onCollapseNode: (id: Id) => void, - isUsingKBToScrollUntill: React.MutableRefObject, -) { - const instance = usePlugin(plugin); - - useEffect(() => { - const listener = (event: KeyboardEvent) => { - const hoveredNode = treeNodes.find((item) => item.id === hoveredNodeId); - switch (event.key) { - case 'Enter': { - if (hoveredNodeId != null) { - onSelectNode(hoveredNodeId, 'keyboard'); - } - - break; - } - - case 'ArrowRight': - event.preventDefault(); - if (hoveredNode) { - if (hoveredNode.isExpanded) { - moveSelectedNodeUpOrDown( - 'ArrowDown', - treeNodes, - rowVirtualizer, - hoveredNodeId, - selectedNode, - onSelectNode, - onHoverNode, - isUsingKBToScrollUntill, - ); - } else { - onExpandNode(hoveredNode.id); - } - } - break; - case 'ArrowLeft': { - event.preventDefault(); - if (hoveredNode) { - if (hoveredNode.isExpanded) { - onCollapseNode(hoveredNode.id); - } else { - const parentIdx = treeNodes.findIndex( - (treeNode) => treeNode.id === hoveredNode.parent, - ); - moveSelectedNodeViaKeyBoard( - parentIdx, - treeNodes, - rowVirtualizer, - onSelectNode, - onHoverNode, - isUsingKBToScrollUntill, - ); - } - } - break; - } - - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - - moveSelectedNodeUpOrDown( - event.key, - treeNodes, - rowVirtualizer, - hoveredNodeId, - selectedNode, - onSelectNode, - onHoverNode, - isUsingKBToScrollUntill, - ); - - break; - } - }; - window.addEventListener('keydown', listener); - return () => { - window.removeEventListener('keydown', listener); - }; - }, [ - treeNodes, - onSelectNode, - selectedNode, - isUsingKBToScrollUntill, - onExpandNode, - onCollapseNode, - instance.uiState.hoveredNodes, - hoveredNodeId, - rowVirtualizer, - onHoverNode, - ]); -} - -export type UpOrDown = 'ArrowDown' | 'ArrowUp'; - -function moveSelectedNodeUpOrDown( - direction: UpOrDown, - treeNodes: TreeNode[], - rowVirtualizer: Virtualizer, - hoveredNode: Id | undefined, - selectedNode: Id | undefined, - onSelectNode: OnSelectNode, - onHoverNode: (...id: Id[]) => void, - isUsingKBToScrollUntill: React.MutableRefObject, -) { - const nodeToUse = selectedNode != null ? selectedNode : hoveredNode; - const curIdx = treeNodes.findIndex((item) => item.id === nodeToUse); - if (curIdx != -1) { - const increment = direction === 'ArrowDown' ? 1 : -1; - const newIdx = curIdx + increment; - - moveSelectedNodeViaKeyBoard( - newIdx, - treeNodes, - rowVirtualizer, - onSelectNode, - onHoverNode, - isUsingKBToScrollUntill, - ); - } -} - -function moveSelectedNodeViaKeyBoard( - newIdx: number, - treeNodes: TreeNode[], - rowVirtualizer: Virtualizer, - onSelectNode: OnSelectNode, - onHoverNode: (...id: Id[]) => void, - isUsingKBToScrollUntil: React.MutableRefObject, -) { - if (newIdx >= 0 && newIdx < treeNodes.length) { - const newNode = treeNodes[newIdx]; - - extendKBControlLease(isUsingKBToScrollUntil); - onSelectNode(newNode.id, 'keyboard'); - onHoverNode(newNode.id); - - rowVirtualizer.scrollToIndex(newIdx, {align: 'auto'}); - } -} - -const KBScrollOverrideTimeMS = 250; -function extendKBControlLease( - isUsingKBToScrollUntil: React.MutableRefObject, -) { - /** - * The reason for this grossness is that when scrolling to an element via keyboard, it will move a new dom node - * under the cursor which will trigger the onmouseenter event for that node even if the mouse never actually was moved. - * This will in turn cause that event handler to hover that node rather than the one the user is trying to get to via keyboard. - * This is a dubious way to work around this. We set this to indicate how long into the future we should disable the - * onmouseenter -> hover behaviour - */ - isUsingKBToScrollUntil.current = - new Date().getTime() + KBScrollOverrideTimeMS; -} - //due to virtualisation the out of the box dom based scrolling doesnt work function findSearchMatchingIndexes( treeNodes: TreeNode[], diff --git a/desktop/plugins/public/ui-debugger/components/tree/toTreeList.tsx b/desktop/plugins/public/ui-debugger/components/tree/toTreeList.tsx new file mode 100644 index 000000000..804567ab2 --- /dev/null +++ b/desktop/plugins/public/ui-debugger/components/tree/toTreeList.tsx @@ -0,0 +1,132 @@ +/** + * 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 { + FrameworkEvent, + FrameworkEventType, + Id, + ClientNode, +} from '../../ClientTypes'; +import {DataSource} from 'flipper-plugin'; +import {last} from 'lodash'; +import {reverse} from 'lodash/fp'; +import {TreeNode} from './Tree'; + +type TreeListStackItem = { + node: ClientNode; + depth: number; + isChildOfSelectedNode: boolean; + selectedNodeDepth: number; +}; +export function toTreeList( + nodes: Map, + rootId: Id, + expandedNodes: Set, + selectedNode: Id | undefined, + frameworkEvents: DataSource, + frameworkEventsMonitoring: Map, + filterMainThreadMonitoring: boolean, +): TreeNode[] { + const root = nodes.get(rootId); + if (root == null) { + return []; + } + const stack = [ + {node: root, depth: 0, isChildOfSelectedNode: false, selectedNodeDepth: 0}, + ] as TreeListStackItem[]; + + const treeNodes = [] as TreeNode[]; + + let i = 0; + while (stack.length > 0) { + const stackItem = stack.pop()!!; + + const {node, depth} = stackItem; + + //if the previous item has an indent guide but we don't then it was the last segment + //so we trim the bottom + const prevItemLine = last(treeNodes)?.indentGuide; + if (prevItemLine != null && stackItem.isChildOfSelectedNode === false) { + prevItemLine.trimBottom = true; + } + + const isExpanded = expandedNodes.has(node.id); + const isSelected = node.id === selectedNode; + + let events = frameworkEvents.getAllRecordsByIndex({nodeId: node.id}); + if (events) { + events = events + .filter((e) => frameworkEventsMonitoring.get(e.type)) + .filter( + (e) => filterMainThreadMonitoring === false || e.thread === 'main', + ); + } + + treeNodes.push({ + ...node, + idx: i, + depth, + isExpanded, + frameworkEvents: events.length > 0 ? events.length : null, + indentGuide: stackItem.isChildOfSelectedNode + ? { + depth: stackItem.selectedNodeDepth, + style: 'ToChildren', + //if first child of selected node add horizontal marker + addHorizontalMarker: depth === stackItem.selectedNodeDepth + 1, + trimBottom: false, + } + : null, + }); + i++; + + let isChildOfSelectedNode = stackItem.isChildOfSelectedNode; + let selectedNodeDepth = stackItem.selectedNodeDepth; + if (isSelected) { + isChildOfSelectedNode = true; + selectedNodeDepth = depth; + // walk back through tree nodes, while depth is greater or equal than current it is your + // parents child / your previous cousin so set dashed line + for (let i = treeNodes.length - 1; i >= 0; i--) { + const prevNode = treeNodes[i]; + if (prevNode.depth < depth) { + break; + } + prevNode.indentGuide = { + depth: selectedNodeDepth - 1, + style: 'ToParent', + addHorizontalMarker: prevNode.depth == depth, + trimBottom: prevNode.id === selectedNode, + }; + } + } + + if (isExpanded) { + //since we do dfs and use a stack we have to reverse children to get the order correct + for (const childId of reverse(node.children)) { + const child = nodes.get(childId); + if (child != null) { + stack.push({ + node: child, + depth: depth + 1, + isChildOfSelectedNode: isChildOfSelectedNode, + selectedNodeDepth: selectedNodeDepth, + }); + } + } + } + } + + //always trim last indent guide + const prevItemLine = last(treeNodes)?.indentGuide; + if (prevItemLine != null) { + prevItemLine.trimBottom = true; + } + + return treeNodes; +} diff --git a/desktop/plugins/public/ui-debugger/components/tree/useKeyboardControls.tsx b/desktop/plugins/public/ui-debugger/components/tree/useKeyboardControls.tsx new file mode 100644 index 000000000..f38a76787 --- /dev/null +++ b/desktop/plugins/public/ui-debugger/components/tree/useKeyboardControls.tsx @@ -0,0 +1,183 @@ +/** + * 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} from '../../ClientTypes'; +import {OnSelectNode} from '../../DesktopTypes'; +import {TreeNode} from './Tree'; +import {Virtualizer} from '@tanstack/react-virtual'; +import {usePlugin} from 'flipper-plugin'; +import {plugin} from '../../index'; +import {useEffect} from 'react'; + +export type MillisSinceEpoch = number; + +export function useKeyboardControls( + treeNodes: TreeNode[], + rowVirtualizer: Virtualizer, + selectedNode: Id | undefined, + hoveredNodeId: Id | undefined, + onSelectNode: OnSelectNode, + onHoverNode: (...id: Id[]) => void, + onExpandNode: (id: Id) => void, + onCollapseNode: (id: Id) => void, + isUsingKBToScrollUntill: React.MutableRefObject, +) { + const instance = usePlugin(plugin); + + useEffect(() => { + const listener = (event: KeyboardEvent) => { + const hoveredNode = treeNodes.find((item) => item.id === hoveredNodeId); + switch (event.key) { + case 'Enter': { + if (hoveredNodeId != null) { + onSelectNode(hoveredNodeId, 'keyboard'); + } + + break; + } + + case 'ArrowRight': + event.preventDefault(); + if (hoveredNode) { + if (hoveredNode.isExpanded) { + moveSelectedNodeUpOrDown( + 'ArrowDown', + treeNodes, + rowVirtualizer, + hoveredNodeId, + selectedNode, + onSelectNode, + onHoverNode, + isUsingKBToScrollUntill, + ); + } else { + onExpandNode(hoveredNode.id); + } + } + break; + case 'ArrowLeft': { + event.preventDefault(); + if (hoveredNode) { + if (hoveredNode.isExpanded) { + onCollapseNode(hoveredNode.id); + } else { + const parentIdx = treeNodes.findIndex( + (treeNode) => treeNode.id === hoveredNode.parent, + ); + moveSelectedNodeViaKeyBoard( + parentIdx, + treeNodes, + rowVirtualizer, + onSelectNode, + onHoverNode, + isUsingKBToScrollUntill, + ); + } + } + break; + } + + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + + moveSelectedNodeUpOrDown( + event.key, + treeNodes, + rowVirtualizer, + hoveredNodeId, + selectedNode, + onSelectNode, + onHoverNode, + isUsingKBToScrollUntill, + ); + + break; + } + }; + window.addEventListener('keydown', listener); + return () => { + window.removeEventListener('keydown', listener); + }; + }, [ + treeNodes, + onSelectNode, + selectedNode, + isUsingKBToScrollUntill, + onExpandNode, + onCollapseNode, + instance.uiState.hoveredNodes, + hoveredNodeId, + rowVirtualizer, + onHoverNode, + ]); +} + +export type UpOrDown = 'ArrowDown' | 'ArrowUp'; + +function moveSelectedNodeUpOrDown( + direction: UpOrDown, + treeNodes: TreeNode[], + rowVirtualizer: Virtualizer, + hoveredNode: Id | undefined, + selectedNode: Id | undefined, + onSelectNode: OnSelectNode, + onHoverNode: (...id: Id[]) => void, + isUsingKBToScrollUntill: React.MutableRefObject, +) { + const nodeToUse = selectedNode != null ? selectedNode : hoveredNode; + const curIdx = treeNodes.findIndex((item) => item.id === nodeToUse); + if (curIdx != -1) { + const increment = direction === 'ArrowDown' ? 1 : -1; + const newIdx = curIdx + increment; + + moveSelectedNodeViaKeyBoard( + newIdx, + treeNodes, + rowVirtualizer, + onSelectNode, + onHoverNode, + isUsingKBToScrollUntill, + ); + } +} + +function moveSelectedNodeViaKeyBoard( + newIdx: number, + treeNodes: TreeNode[], + rowVirtualizer: Virtualizer, + onSelectNode: OnSelectNode, + onHoverNode: (...id: Id[]) => void, + isUsingKBToScrollUntil: React.MutableRefObject, +) { + if (newIdx >= 0 && newIdx < treeNodes.length) { + const newNode = treeNodes[newIdx]; + + extendKBControlLease(isUsingKBToScrollUntil); + onSelectNode(newNode.id, 'keyboard'); + onHoverNode(newNode.id); + + rowVirtualizer.scrollToIndex(newIdx, {align: 'auto'}); + } +} + +const KBScrollOverrideTimeMS = 250; +function extendKBControlLease( + isUsingKBToScrollUntil: React.MutableRefObject, +) { + /** + * The reason for this grossness is that when scrolling to an element via keyboard, it will move a new dom node + * under the cursor which will trigger the onmouseenter event for that node even if the mouse never actually was moved. + * This will in turn cause that event handler to hover that node rather than the one the user is trying to get to via keyboard. + * This is a dubious way to work around this. We set this to indicate how long into the future we should disable the + * onmouseenter -> hover behaviour + */ + isUsingKBToScrollUntil.current = + new Date().getTime() + KBScrollOverrideTimeMS; +}