Add in / out behaviour to left / right arrow

Summary:
Following feedback from https://fb.workplace.com/groups/443457641253219/permalink/587444816854500/

When pressing left arrow and is already collapse goes to parent, when pressing right arrow and is expanded will go to first child. this mimics behaviours in mac os and other ides.

Also refactored kb scroll to use row virtualiser instead of dom refs

I also fixed the kb scroll hijacking, previously we were using a setTimeout, if you held a key down for a long time then the timeout would fire and the mouse enter event would briefly fire causing the hover position to jump. I now use a more robust approach were we just reserve the focus input for 250ms from the keyboard input, each time the key is held this reservation is extended slightly.

Changelog: UIDebugger, pressing left arrow jumps to parent after collapse. Pressing right arrow enters after expand. Similar to file browsers in IDES

Reviewed By: aigoncharov

Differential Revision: D46760448

fbshipit-source-id: da45d81056aa070be84b2db972400d650b86a172
This commit is contained in:
Luke De Feo
2023-06-19 05:06:52 -07:00
committed by Facebook GitHub Bot
parent e9d098b9cd
commit 43c7dc39c8
2 changed files with 104 additions and 52 deletions

View File

@@ -36,12 +36,13 @@ import {Badge, Dropdown, Menu, Typography} from 'antd';
import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem'; import {UIDebuggerMenuItem} from './util/UIDebuggerMenuItem';
import {tracker} from '../tracker'; import {tracker} from '../tracker';
import {useVirtualizer} from '@tanstack/react-virtual'; import {useVirtualizer, Virtualizer} from '@tanstack/react-virtual';
const {Text} = Typography; const {Text} = Typography;
type LineStyle = 'ToParent' | 'ToChildren'; type LineStyle = 'ToParent' | 'ToChildren';
type MillisSinceEpoch = number;
type NodeIndentGuide = { type NodeIndentGuide = {
depth: number; depth: number;
style: LineStyle; style: LineStyle;
@@ -50,6 +51,7 @@ type NodeIndentGuide = {
}; };
export type TreeNode = UINode & { export type TreeNode = UINode & {
depth: number; depth: number;
idx: number;
isExpanded: boolean; isExpanded: boolean;
indentGuide: NodeIndentGuide | null; indentGuide: NodeIndentGuide | null;
}; };
@@ -84,7 +86,7 @@ export function Tree2({nodes, rootId}: {nodes: Map<Id, UINode>; rootId: Id}) {
return {treeNodes, refs}; return {treeNodes, refs};
}, [expandedNodes, focusedNode, nodes, rootId, selectedNode]); }, [expandedNodes, focusedNode, nodes, rootId, selectedNode]);
const isUsingKBToScroll = useRef(false); const isUsingKBToScrollUtill = useRef<MillisSinceEpoch>(0);
const grandParentRef = useRef<HTMLDivElement>(null); const grandParentRef = useRef<HTMLDivElement>(null);
const parentRef = React.useRef<HTMLDivElement>(null); const parentRef = React.useRef<HTMLDivElement>(null);
@@ -98,13 +100,13 @@ export function Tree2({nodes, rootId}: {nodes: Map<Id, UINode>; rootId: Id}) {
useKeyboardShortcuts( useKeyboardShortcuts(
treeNodes, treeNodes,
refs, rowVirtualizer,
selectedNode, selectedNode,
hoveredNode, hoveredNode,
instance.uiActions.onSelectNode, instance.uiActions.onSelectNode,
instance.uiActions.onExpandNode, instance.uiActions.onExpandNode,
instance.uiActions.onCollapseNode, instance.uiActions.onCollapseNode,
isUsingKBToScroll, isUsingKBToScrollUtill,
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -203,7 +205,7 @@ export function Tree2({nodes, rootId}: {nodes: Map<Id, UINode>; rootId: Id}) {
highlightedNodes={highlightedNodes} highlightedNodes={highlightedNodes}
selectedNode={selectedNode} selectedNode={selectedNode}
hoveredNode={hoveredNode} hoveredNode={hoveredNode}
isUsingKBToScroll={isUsingKBToScroll} isUsingKBToScroll={isUsingKBToScrollUtill}
isContextMenuOpen={isContextMenuOpen} isContextMenuOpen={isContextMenuOpen}
onSelectNode={instance.uiActions.onSelectNode} onSelectNode={instance.uiActions.onSelectNode}
onExpandNode={instance.uiActions.onExpandNode} onExpandNode={instance.uiActions.onExpandNode}
@@ -254,7 +256,7 @@ function TreeItemContainer({
highlightedNodes, highlightedNodes,
selectedNode, selectedNode,
hoveredNode, hoveredNode,
isUsingKBToScroll, isUsingKBToScroll: isUsingKBToScrollUntill,
isContextMenuOpen, isContextMenuOpen,
onSelectNode, onSelectNode,
onExpandNode, onExpandNode,
@@ -269,7 +271,7 @@ function TreeItemContainer({
frameworkEventsMonitoring: Map<FrameworkEventType, boolean>; frameworkEventsMonitoring: Map<FrameworkEventType, boolean>;
selectedNode?: Id; selectedNode?: Id;
hoveredNode?: Id; hoveredNode?: Id;
isUsingKBToScroll: RefObject<boolean>; isUsingKBToScroll: RefObject<MillisSinceEpoch>;
isContextMenuOpen: boolean; isContextMenuOpen: boolean;
onSelectNode: (node?: Id) => void; onSelectNode: (node?: Id) => void;
onExpandNode: (node: Id) => void; onExpandNode: (node: Id) => void;
@@ -300,10 +302,10 @@ function TreeItemContainer({
isSelected={treeNode.id === selectedNode} isSelected={treeNode.id === selectedNode}
isHovered={hoveredNode === treeNode.id} isHovered={hoveredNode === treeNode.id}
onMouseEnter={() => { onMouseEnter={() => {
if ( const kbIsNoLongerReservingScroll =
isUsingKBToScroll.current === false && new Date().getTime() > (isUsingKBToScrollUntill.current ?? 0);
isContextMenuOpen == false
) { if (kbIsNoLongerReservingScroll && isContextMenuOpen == false) {
onHoverNode(treeNode.id); onHoverNode(treeNode.id);
} }
}} }}
@@ -568,6 +570,7 @@ function toTreeNodes(
const treeNodes = [] as TreeNode[]; const treeNodes = [] as TreeNode[];
let i = 0;
while (stack.length > 0) { while (stack.length > 0) {
const stackItem = stack.pop()!!; const stackItem = stack.pop()!!;
@@ -585,6 +588,7 @@ function toTreeNodes(
treeNodes.push({ treeNodes.push({
...node, ...node,
idx: i,
depth, depth,
isExpanded, isExpanded,
indentGuide: stackItem.isChildOfSelectedNode indentGuide: stackItem.isChildOfSelectedNode
@@ -597,6 +601,7 @@ function toTreeNodes(
} }
: null, : null,
}); });
i++;
let isChildOfSelectedNode = stackItem.isChildOfSelectedNode; let isChildOfSelectedNode = stackItem.isChildOfSelectedNode;
let selectedNodeDepth = stackItem.selectedNodeDepth; let selectedNodeDepth = stackItem.selectedNodeDepth;
@@ -646,22 +651,24 @@ function toTreeNodes(
function useKeyboardShortcuts( function useKeyboardShortcuts(
treeNodes: TreeNode[], treeNodes: TreeNode[],
refs: React.RefObject<HTMLLIElement>[], rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
selectedNode: Id | undefined, selectedNode: Id | undefined,
hoveredNode: Id | undefined, hoveredNodeId: Id | undefined,
onSelectNode: (id?: Id) => void, onSelectNode: (id?: Id) => void,
onExpandNode: (id: Id) => void, onExpandNode: (id: Id) => void,
onCollapseNode: (id: Id) => void, onCollapseNode: (id: Id) => void,
isUsingKBToScroll: React.MutableRefObject<boolean>, isUsingKBToScrollUntill: React.MutableRefObject<number>,
) { ) {
const instance = usePlugin(plugin); const instance = usePlugin(plugin);
useEffect(() => { useEffect(() => {
const listener = (event: KeyboardEvent) => { const listener = (event: KeyboardEvent) => {
const hoveredNode = treeNodes.find((item) => item.id === hoveredNodeId);
switch (event.key) { switch (event.key) {
case 'Enter': { case 'Enter': {
if (hoveredNode != null) { if (hoveredNodeId != null) {
onSelectNode(hoveredNode); extendKBControlLease(isUsingKBToScrollUntill);
onSelectNode(hoveredNodeId);
} }
break; break;
@@ -670,26 +677,50 @@ function useKeyboardShortcuts(
case 'ArrowRight': case 'ArrowRight':
event.preventDefault(); event.preventDefault();
if (hoveredNode) { if (hoveredNode) {
onExpandNode(hoveredNode); if (hoveredNode.isExpanded) {
moveHoveredNodeUpOrDown(
'ArrowDown',
treeNodes,
rowVirtualizer,
instance.uiState.hoveredNodes,
isUsingKBToScrollUntill,
);
} else {
onExpandNode(hoveredNode.id);
}
} }
break; break;
case 'ArrowLeft': { case 'ArrowLeft': {
event.preventDefault(); event.preventDefault();
if (hoveredNode) { if (hoveredNode) {
onCollapseNode(hoveredNode); if (hoveredNode.isExpanded) {
onCollapseNode(hoveredNode.id);
} else {
const parentIdx = treeNodes.findIndex(
(treeNode) => treeNode.id === hoveredNode.parent,
);
moveHoveredNodeViaKeyBoard(
parentIdx,
treeNodes,
rowVirtualizer,
instance.uiState.hoveredNodes,
isUsingKBToScrollUntill,
);
}
} }
break; break;
} }
case 'ArrowDown':
case 'ArrowUp': case 'ArrowUp':
case 'ArrowDown':
event.preventDefault(); event.preventDefault();
moveHoveredNodeUpOrDown( moveHoveredNodeUpOrDown(
event.key, event.key,
treeNodes, treeNodes,
refs, rowVirtualizer,
instance.uiState.hoveredNodes, instance.uiState.hoveredNodes,
isUsingKBToScroll, isUsingKBToScrollUntill,
); );
break; break;
@@ -700,15 +731,15 @@ function useKeyboardShortcuts(
window.removeEventListener('keydown', listener); window.removeEventListener('keydown', listener);
}; };
}, [ }, [
refs,
treeNodes, treeNodes,
onSelectNode, onSelectNode,
selectedNode, selectedNode,
isUsingKBToScroll, isUsingKBToScrollUntill,
onExpandNode, onExpandNode,
onCollapseNode, onCollapseNode,
instance.uiState.hoveredNodes, instance.uiState.hoveredNodes,
hoveredNode, hoveredNodeId,
rowVirtualizer,
]); ]);
} }
@@ -717,9 +748,9 @@ export type UpOrDown = 'ArrowDown' | 'ArrowUp';
function moveHoveredNodeUpOrDown( function moveHoveredNodeUpOrDown(
direction: UpOrDown, direction: UpOrDown,
treeNodes: TreeNode[], treeNodes: TreeNode[],
refs: React.RefObject<HTMLLIElement>[], rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
hoveredNodes: Atom<Id[]>, hoveredNodes: Atom<Id[]>,
isUsingKBToScroll: React.MutableRefObject<boolean>, isUsingKBToScrollUntill: React.MutableRefObject<MillisSinceEpoch>,
) { ) {
const curIdx = treeNodes.findIndex( const curIdx = treeNodes.findIndex(
(item) => item.id === head(hoveredNodes.get()), (item) => item.id === head(hoveredNodes.get()),
@@ -727,23 +758,44 @@ function moveHoveredNodeUpOrDown(
if (curIdx != -1) { if (curIdx != -1) {
const increment = direction === 'ArrowDown' ? 1 : -1; const increment = direction === 'ArrowDown' ? 1 : -1;
const newIdx = curIdx + increment; const newIdx = curIdx + increment;
if (newIdx >= 0 && newIdx < treeNodes.length) {
const newNode = treeNodes[newIdx];
hoveredNodes.set([newNode.id]);
const newNodeDomRef = refs[newIdx].current; moveHoveredNodeViaKeyBoard(
/** newIdx,
* The reason for this grossness is that when scrolling to an element via keyboard, it will move a new dom node treeNodes,
* under the cursor which will trigger the onmouseenter event for that node even if the mouse never actually was moved. rowVirtualizer,
* 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. hoveredNodes,
* This is a dubious way to work around this. Effectively set this flag for a few hundred milliseconds after using keyboard movement isUsingKBToScrollUntill,
* to disable the onmouseenter -> hover behaviour temporarily );
*/
isUsingKBToScroll.current = true;
newNodeDomRef?.scrollIntoView({block: 'nearest'});
setTimeout(() => {
isUsingKBToScroll.current = false;
}, 250);
}
} }
} }
function moveHoveredNodeViaKeyBoard(
newIdx: number,
treeNodes: TreeNode[],
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
hoveredNodes: Atom<Id[]>,
isUsingKBToScrollUntil: React.MutableRefObject<number>,
) {
if (newIdx >= 0 && newIdx < treeNodes.length) {
const newNode = treeNodes[newIdx];
hoveredNodes.set([newNode.id]);
extendKBControlLease(isUsingKBToScrollUntil);
rowVirtualizer.scrollToIndex(newIdx, {align: 'auto'});
}
}
const KBScrollOverrideTimeMS = 250;
function extendKBControlLease(
isUsingKBToScrollUntil: React.MutableRefObject<number>,
) {
/**
* 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;
}

View File

@@ -387,16 +387,16 @@ function uiActions(uiState: UIState, nodes: Atom<Map<Id, UINode>>): UIActions {
if (tags) { if (tags) {
tracker.track('node-selected', {name: selectedNode.name, tags}); tracker.track('node-selected', {name: selectedNode.name, tags});
} }
}
let current = node; let current = selectedNode?.parent;
// expand entire ancestory in case it has been manually collapsed // expand entire ancestory in case it has been manually collapsed
uiState.expandedNodes.update((expandedNodesDraft) => { uiState.expandedNodes.update((expandedNodesDraft) => {
while (current != null) { while (current != null) {
expandedNodesDraft.add(current); expandedNodesDraft.add(current);
current = nodes.get().get(current)?.parent; current = nodes.get().get(current)?.parent;
} }
}); });
}
}; };
const onCollapseNode = (node: Id) => { const onCollapseNode = (node: Id) => {