import { MutableRefObject, useCallback, useEffect, useMemo, useReducer, useRef, useState, } from 'react'; import {Theme, useTheme} from '@emotion/react'; import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender'; import {VirtualizedTree} from './VirtualizedTree'; import {VirtualizedTreeNode} from './VirtualizedTreeNode'; type AnimationTimeoutId = { id: number; }; const cancelAnimationTimeout = (frame: AnimationTimeoutId) => window.cancelAnimationFrame(frame.id); /** * Recursively calls requestAnimationFrame until a specified delay has been met or exceeded. * When the delay time has been reached the function you're timing out will be called. * This was copied from react-virtualized, with credits to the original author. * * Credit: Joe Lambert (https://gist.github.com/joelambert/1002116#file-requesttimeout-js) */ const requestAnimationTimeout = ( callback: Function, delay: number ): AnimationTimeoutId => { let start; // wait for end of processing current event handler, because event handler may be long Promise.resolve().then(() => { start = Date.now(); }); const timeout = () => { if (start === undefined) { frame.id = window.requestAnimationFrame(timeout); return; } if (Date.now() - start >= delay) { callback(); } else { frame.id = window.requestAnimationFrame(timeout); } }; const frame: AnimationTimeoutId = { id: window.requestAnimationFrame(timeout), }; return frame; }; export interface TreeLike { children: TreeLike[]; } interface VirtualizedState { overscroll: number; roots: T[]; scrollHeight: number; scrollTop: number; tabIndexKey: number | null; } interface SetScrollTop { payload: number; type: 'set scroll top'; } interface SetTabIndexKey { payload: number | null; type: 'set tab index key'; } interface SetContainerHeight { payload: number; type: 'set scroll height'; } type VirtualizedStateAction = SetScrollTop | SetContainerHeight | SetTabIndexKey; export function VirtualizedTreeStateReducer( state: VirtualizedState, action: VirtualizedStateAction ): VirtualizedState { switch (action.type) { case 'set tab index key': { return {...state, tabIndexKey: action.payload}; } case 'set scroll top': { return {...state, scrollTop: action.payload}; } case 'set scroll height': { return {...state, scrollHeight: action.payload}; } default: { return state; } } } function hideGhostRow({ref}: {ref: MutableRefObject}) { if (ref.current) { ref.current.style.opacity = '0'; } } function updateGhostRow({ ref, tabIndexKey, rowHeight, scrollTop, interaction, theme, }: { interaction: 'hover' | 'active'; ref: MutableRefObject; rowHeight: number; scrollTop: number; tabIndexKey: number; theme: Theme; }) { if (!ref.current) { return; } ref.current.style.left = '0'; ref.current.style.right = '0'; ref.current.style.height = `${rowHeight}px`; ref.current.style.position = 'absolute'; ref.current.style.backgroundColor = interaction === 'active' ? theme.blue300 : theme.surface100; ref.current.style.pointerEvents = 'none'; ref.current.style.willChange = 'transform, opacity'; ref.current.style.transform = `translateY(${rowHeight * tabIndexKey - scrollTop}px)`; ref.current.style.opacity = '1'; } function findOptimisticStartIndex({ items, overscroll, rowHeight, scrollTop, viewport, }: { items: VirtualizedTreeNode[]; overscroll: number; rowHeight: number; scrollTop: number; viewport: {bottom: number; top: number}; }): number { if (!items.length || viewport.top === 0) { return 0; } return Math.max(Math.floor(scrollTop / rowHeight) - overscroll, 0); } function findVisibleItems({ items, overscroll, rowHeight, scrollHeight, scrollTop, }: { items: VirtualizedTreeNode[]; overscroll: NonNullable['overscroll']>; rowHeight: UseVirtualizedListProps['rowHeight']; scrollHeight: VirtualizedState['scrollHeight']; scrollTop: VirtualizedState['scrollTop']; }) { // This is overscroll height for single direction, when computing the total, // we need to multiply this by 2 because we overscroll in both directions. const OVERSCROLL_HEIGHT = overscroll * rowHeight; const visibleItems: VisibleItem[] = []; // Clamp viewport to scrollHeight bounds [0, length * rowHeight] because some browsers may fire // scrollTop with negative values when the user scrolls up past the top of the list (overscroll behavior) const viewport = { top: Math.max(scrollTop - OVERSCROLL_HEIGHT, 0), bottom: Math.min( scrollTop + scrollHeight + OVERSCROLL_HEIGHT, items.length * rowHeight ), }; // Points to the position inside the visible array let visibleItemIndex = 0; // Points to the currently iterated item let indexPointer = findOptimisticStartIndex({ items, viewport, scrollTop, rowHeight, overscroll, }); // Max number of visible items in our list const MAX_VISIBLE_ITEMS = Math.ceil((scrollHeight + OVERSCROLL_HEIGHT * 2) / rowHeight); const ALL_ITEMS = items.length; // While number of visible items is less than max visible items, and we haven't reached the end of the list while (visibleItemIndex < MAX_VISIBLE_ITEMS && indexPointer < ALL_ITEMS) { const elementTop = indexPointer * rowHeight; const elementBottom = elementTop + rowHeight; // An element is inside a viewport if the top of the element is below the top of the viewport // and the bottom of the element is above the bottom of the viewport if (elementTop >= viewport.top && elementBottom <= viewport.bottom) { visibleItems[visibleItemIndex] = { key: indexPointer, ref: null, styles: {position: 'absolute', top: elementTop}, item: items[indexPointer], }; visibleItemIndex++; } indexPointer++; } return visibleItems; } interface VisibleItem { item: VirtualizedTreeNode; key: number; ref: HTMLElement | null; styles: React.CSSProperties; } export interface UseVirtualizedListProps { renderRow: ( item: VisibleItem, itemHandlers: { handleExpandTreeNode: ( node: VirtualizedTreeNode, opts?: {expandChildren: boolean} ) => void; handleRowClick: (evt: React.MouseEvent) => void; handleRowKeyDown: (event: React.KeyboardEvent) => void; handleRowMouseEnter: (event: React.MouseEvent) => void; tabIndexKey: number | null; } ) => React.ReactNode; rowHeight: number; scrollContainer: HTMLElement | null; tree: T[]; expanded?: boolean; overscroll?: number; skipFunction?: (node: VirtualizedTreeNode) => boolean; sortFunction?: (a: VirtualizedTreeNode, b: VirtualizedTreeNode) => number; } const DEFAULT_OVERSCROLL_ITEMS = 5; function findCarryOverIndex( previousNode: VirtualizedTreeNode | null | undefined, newTree: VirtualizedTree ): number | null { if (!newTree.flattened.length || !previousNode) { return null; } const newIndex = newTree.flattened.findIndex(n => n.node === previousNode.node); if (newIndex === -1) { return null; } return newIndex; } export function useVirtualizedTree( props: UseVirtualizedListProps ) { const theme = useTheme(); const clickedGhostRowRef = useRef(null); const hoveredGhostRowRef = useRef(null); const previousHoveredRow = useRef(null); const [state, dispatch] = useReducer(VirtualizedTreeStateReducer, { scrollTop: 0, roots: props.tree, tabIndexKey: null, overscroll: props.overscroll ?? DEFAULT_OVERSCROLL_ITEMS, scrollHeight: props.scrollContainer?.getBoundingClientRect()?.height ?? 0, }); // Keep a ref to latest state to avoid re-rendering const latestStateRef = useRef(state); latestStateRef.current = state; const [tree, setTree] = useState(() => { const initialTree = VirtualizedTree.fromRoots( props.tree, props.expanded, props.skipFunction ); if (props.sortFunction) { initialTree.sort(props.sortFunction); } return initialTree; }); const cleanupAllHoveredRows = useCallback(() => { previousHoveredRow.current = null; for (const row of latestItemsRef.current) { if (row.ref && row.ref.dataset.hovered) { delete row.ref.dataset.hovered; } } }, []); const flattenedHistory = useRef>>(tree.flattened); const expandedHistory = useRef>(tree.getAllExpandedNodes(new Set())); useEffectAfterFirstRender(() => { const newTree = VirtualizedTree.fromRoots( props.tree, props.expanded, props.skipFunction, expandedHistory.current ); if (props.sortFunction) { newTree.sort(props.sortFunction); } const tabIndex = findCarryOverIndex( typeof latestStateRef.current.tabIndexKey === 'number' ? flattenedHistory.current[latestStateRef.current.tabIndexKey] : null, newTree ); if (tabIndex) { updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: tabIndex, rowHeight: props.rowHeight, scrollTop: latestStateRef.current.scrollTop, interaction: 'active', theme, }); } else { hideGhostRow({ref: clickedGhostRowRef}); } cleanupAllHoveredRows(); hideGhostRow({ref: hoveredGhostRowRef}); dispatch({type: 'set tab index key', payload: tabIndex}); setTree(newTree); expandedHistory.current = newTree.getAllExpandedNodes(expandedHistory.current); flattenedHistory.current = newTree.flattened; }, [ props.tree, props.expanded, props.skipFunction, props.sortFunction, props.rowHeight, cleanupAllHoveredRows, theme, ]); const items = useMemo(() => { return findVisibleItems({ items: tree.flattened, scrollHeight: state.scrollHeight, scrollTop: state.scrollTop, overscroll: state.overscroll, rowHeight: props.rowHeight, }); }, [tree, state.overscroll, state.scrollHeight, state.scrollTop, props.rowHeight]); const latestItemsRef = useRef(items); latestItemsRef.current = items; // On scroll, we update scrollTop position. // Keep a rafId reference in the unlikely event where component unmounts before raf is executed. const scrollEndTimeoutId = useRef(undefined); const previousScrollHeight = useRef(0); useEffect(() => { const scrollContainer = props.scrollContainer; if (!scrollContainer) { return undefined; } function handleScroll(evt) { const top = Math.max(evt.target.scrollTop, 0); if (previousScrollHeight.current === top) { return; } evt.target.firstChild.style.pointerEvents = 'none'; if (scrollEndTimeoutId.current !== undefined) { cancelAnimationTimeout(scrollEndTimeoutId.current); } scrollEndTimeoutId.current = requestAnimationTimeout(() => { evt.target.firstChild.style.pointerEvents = 'auto'; }, 150); dispatch({ type: 'set scroll top', payload: top, }); // On scroll, we need to update the selected ghost row and clear the hovered ghost row if (latestStateRef.current.tabIndexKey !== null) { updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: latestStateRef.current.tabIndexKey, scrollTop: Math.max(evt.target.scrollTop, 0), interaction: 'active', rowHeight: props.rowHeight, theme, }); } cleanupAllHoveredRows(); hideGhostRow({ ref: hoveredGhostRowRef, }); previousScrollHeight.current = top; } scrollContainer.addEventListener('scroll', handleScroll, { passive: true, }); return () => { scrollContainer.removeEventListener('scroll', handleScroll); }; }, [props.scrollContainer, props.rowHeight, cleanupAllHoveredRows, theme]); useEffect(() => { const scrollContainer = props.scrollContainer; if (!scrollContainer) { return undefined; } // Because nodes dont span the full width, it's possible for users to // click or hover on a node at the far right end which is outside of the row width. // In that case, we check if the cursor position overlaps with a row and select that row. const handleClick = (evt: MouseEvent) => { if (evt.target !== scrollContainer) { // user clicked on an element inside the container, defer to onClick return; } const rect = (evt.target as HTMLDivElement).getBoundingClientRect(); const index = Math.floor( (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight ); // If a node exists at the index, select it if (tree.flattened[index]) { dispatch({type: 'set tab index key', payload: index}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: index, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); } }; // Because nodes dont span the full width, it's possible for users to // click on a node at the far right end which is outside of the row width. // In that case, check if the top position where the user clicked overlaps // with a row and select that row. const handleMouseMove = (evt: MouseEvent) => { if (evt.target !== scrollContainer) { // user clicked on an element inside the container, defer to onClick return; } const rect = (evt.target as HTMLDivElement).getBoundingClientRect(); const index = Math.floor( (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight ); cleanupAllHoveredRows(); const element = latestItemsRef.current.find(item => item.key === index); if (element?.ref) { element.ref.dataset.hovered = 'true'; } // If a node exists at the index, select it, else clear whatever is selected if (tree.flattened[index] && index !== latestStateRef.current.tabIndexKey) { updateGhostRow({ ref: hoveredGhostRowRef, tabIndexKey: index, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'hover', theme, }); } else { hideGhostRow({ ref: hoveredGhostRowRef, }); } }; scrollContainer.addEventListener('click', handleClick); scrollContainer.addEventListener('mousemove', handleMouseMove); return () => { scrollContainer.removeEventListener('click', handleClick); scrollContainer.removeEventListener('mousemove', handleMouseMove); }; }, [ props.rowHeight, props.scrollContainer, tree.flattened, cleanupAllHoveredRows, theme, ]); // When mouseleave is triggered on the contianer, // we need to hide the ghost row to avoid an orphaned row useEffect(() => { const container = props.scrollContainer; if (!container) { return undefined; } function onMouseLeave() { cleanupAllHoveredRows(); hideGhostRow({ ref: hoveredGhostRowRef, }); } container.addEventListener('mouseleave', onMouseLeave, { passive: true, }); return () => { container.removeEventListener('mouseleave', onMouseLeave); }; }, [cleanupAllHoveredRows, props.scrollContainer]); // When a node is expanded, the underlying tree is recomputed (the flattened tree is updated) // We copy the properties of the old tree by creating a new instance of VirtualizedTree // and passing in the roots and its flattened representation so that no extra work is done. const handleExpandTreeNode = useCallback( (node: VirtualizedTreeNode, opts?: {expandChildren: boolean}) => { // When we expand nodes, tree.expand will mutate the underlying tree which then // gets copied to the new tree instance. To get the right index, we need to read // it before any mutations are made const previousNode = latestStateRef.current.tabIndexKey ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null : null; tree.expandNode(node, !node.expanded, opts); const newTree = new VirtualizedTree(tree.roots, tree.flattened); expandedHistory.current = newTree.getAllExpandedNodes(new Set()); // Hide or update the ghost if necessary const tabIndex = findCarryOverIndex(previousNode, newTree); if (tabIndex === null) { hideGhostRow({ref: clickedGhostRowRef}); } else { updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: tabIndex, scrollTop: Math.max(latestStateRef.current.scrollTop, 0), interaction: 'active', rowHeight: props.rowHeight, theme, }); } dispatch({type: 'set tab index key', payload: tabIndex}); setTree(newTree); }, [tree, props.rowHeight, theme] ); // When a tree is sorted, we sort all of the nodes in the tree and not just the visible ones // We could probably optimize this to lazily sort as we scroll, but since we want the least amount // of work during scrolling, we just sort the entire tree every time. const handleSortingChange = useCallback( (sortFn: (a: VirtualizedTreeNode, b: VirtualizedTreeNode) => number) => { // When we sort nodes, tree.sort will mutate the underlying tree which then // gets copied to the new tree instance. To get the right index, we need to read // it before any mutations are made const previousNode = latestStateRef.current.tabIndexKey ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null : null; tree.sort(sortFn); const newTree = new VirtualizedTree(tree.roots, tree.flattened); // Hide or update the ghost if necessary const tabIndex = findCarryOverIndex(previousNode, newTree); if (tabIndex === null) { hideGhostRow({ref: clickedGhostRowRef}); } else { updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: tabIndex, scrollTop: Math.max(latestStateRef.current.scrollTop, 0), interaction: 'active', rowHeight: props.rowHeight, theme, }); } dispatch({type: 'set tab index key', payload: tabIndex}); setTree(newTree); }, [tree, props.rowHeight, theme] ); // When a row is clicked, we update the selected node const handleRowClick = useCallback( (tabIndexKey: number) => { return (_evt: React.MouseEvent) => { dispatch({type: 'set tab index key', payload: tabIndexKey}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey, scrollTop: state.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); }; }, [state.scrollTop, props.rowHeight, theme] ); // Keyboard navigation for row const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (latestStateRef.current.tabIndexKey === null) { return; } // Cant move anywhere if there are no nodes if (!tree.flattened.length) { return; } if (event.key === 'Enter') { handleExpandTreeNode(tree.flattened[latestStateRef.current.tabIndexKey], { expandChildren: true, }); } if (event.key === 'ArrowDown') { event.preventDefault(); if (event.metaKey || event.ctrlKey) { const index = tree.flattened.length - 1; props.scrollContainer!.scrollTo({ // We need to offset for the scrollMargin top: index * props.rowHeight + props.rowHeight, }); dispatch({type: 'set tab index key', payload: index}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: index, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); return; } // This is fine because we are only searching visible items // and not the entire tree of nodes const indexInVisibleItems = items.findIndex( i => i.key === latestStateRef.current.tabIndexKey ); if (indexInVisibleItems !== -1) { const nextIndex = indexInVisibleItems + 1; // Bounds check if we are at end of list if (nextIndex > tree.flattened.length - 1) { return; } dispatch({type: 'set tab index key', payload: items[nextIndex].key}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: items[nextIndex].key, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); items[nextIndex].ref?.focus({preventScroll: true}); items[nextIndex].ref?.scrollIntoView({block: 'nearest'}); } } if (event.key === 'ArrowUp') { event.preventDefault(); if (event.metaKey || event.ctrlKey) { props.scrollContainer!.scrollTo({top: 0}); dispatch({type: 'set tab index key', payload: 0}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: 0, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); return; } // This is fine because we are only searching visible items // and not the entire tree of nodes const indexInVisibleItems = items.findIndex( i => i.key === latestStateRef.current.tabIndexKey ); if (indexInVisibleItems !== -1) { const nextIndex = indexInVisibleItems - 1; // Bound check if we are at start of list if (nextIndex < 0) { return; } dispatch({type: 'set tab index key', payload: items[nextIndex].key}); updateGhostRow({ ref: clickedGhostRowRef, tabIndexKey: items[nextIndex].key, scrollTop: latestStateRef.current.scrollTop, rowHeight: props.rowHeight, interaction: 'active', theme, }); items[nextIndex].ref?.focus({preventScroll: true}); items[nextIndex].ref?.scrollIntoView({block: 'nearest'}); } } }, [ handleExpandTreeNode, items, tree.flattened, props.rowHeight, props.scrollContainer, theme, ] ); // When a row is hovered, we update the ghost row const handleRowMouseEnter = useCallback( (key: number) => { return (_evt: React.MouseEvent) => { if (previousHoveredRow.current !== key) { cleanupAllHoveredRows(); (_evt.currentTarget as HTMLElement).dataset.hovered = 'true'; previousHoveredRow.current = key; } updateGhostRow({ ref: hoveredGhostRowRef, tabIndexKey: key, scrollTop: state.scrollTop, rowHeight: props.rowHeight, interaction: 'hover', theme, }); }; }, [state.scrollTop, props.rowHeight, cleanupAllHoveredRows, theme] ); // Register a resize observer for when the scroll container is resized. // When the container is resized, update the scroll height in our state. // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI useEffect(() => { if (!props.scrollContainer) { return undefined; } let rafId: number | undefined; const resizeObserver = new window.ResizeObserver(elements => { rafId = window.requestAnimationFrame(() => { dispatch({ type: 'set scroll height', payload: elements[0]?.contentRect?.height ?? 0, }); cleanupAllHoveredRows(); hideGhostRow({ ref: hoveredGhostRowRef, }); }); }); resizeObserver.observe(props.scrollContainer); return () => { if (typeof rafId === 'number') { window.cancelAnimationFrame(rafId); } resizeObserver.disconnect(); }; }, [props.scrollContainer, cleanupAllHoveredRows]); // Basic required styles for the scroll container const scrollContainerStyles: React.CSSProperties = useMemo(() => { return { height: '100%', overflow: 'auto', position: 'relative', willChange: 'transform', }; }, []); // Basic styles for the element container. We fake the height so that the // scrollbar is sized according to the number of items in the list. const containerStyles: React.CSSProperties = useMemo(() => { const height = tree.flattened.length * props.rowHeight; return {height, maxHeight: height}; }, [tree.flattened.length, props.rowHeight]); const renderRow = props.renderRow; const renderedItems: React.ReactNode[] = useMemo(() => { const renderered: React.ReactNode[] = []; // It is important that we do not create a copy of item // because refs will assign the dom node to the item. // If we map, we get a new object that our internals will not be able to access. for (let i = 0; i < items.length; i++) { const item = items[i]; renderered.push( renderRow(item, { handleRowClick: handleRowClick(item.key), handleExpandTreeNode, handleRowKeyDown, handleRowMouseEnter: handleRowMouseEnter(item.key), tabIndexKey: state.tabIndexKey, }) ); } return renderered; }, [ items, handleRowClick, handleRowKeyDown, state.tabIndexKey, handleRowMouseEnter, handleExpandTreeNode, renderRow, ]); // Register a resize observer for when the scroll container is resized. // When the container is resized, update the scroll height in our state. // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI useEffect(() => { if (!props.scrollContainer) { return undefined; } let rafId: number | undefined; const resizeObserver = new window.ResizeObserver(elements => { rafId = window.requestAnimationFrame(() => { dispatch({ type: 'set scroll height', payload: elements[0]?.contentRect?.height ?? 0, }); }); }); resizeObserver.observe(props.scrollContainer); return () => { if (typeof rafId === 'number') { window.cancelAnimationFrame(rafId); } resizeObserver.disconnect(); }; }, [props.scrollContainer]); return { tree, items, renderedItems, tabIndexKey: state.tabIndexKey, dispatch, handleRowClick, handleRowKeyDown, handleRowMouseEnter, handleExpandTreeNode, handleSortingChange, scrollContainerStyles, containerStyles, clickedGhostRowRef, hoveredGhostRowRef, }; }