| |
| |
| |
| |
| |
|
|
| import { |
| useRef, |
| forwardRef, |
| useImperativeHandle, |
| useCallback, |
| useMemo, |
| useLayoutEffect, |
| } from 'react'; |
| import type React from 'react'; |
| import { |
| VirtualizedList, |
| type VirtualizedListRef, |
| type VirtualizedListProps, |
| SCROLL_TO_ITEM_END, |
| } from './VirtualizedList.js'; |
| import { useScrollable } from '../../contexts/ScrollProvider.js'; |
| import { Box, type DOMElement } from 'ink'; |
| import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; |
| import { useKeypress, type Key } from '../../hooks/useKeypress.js'; |
| import { Command } from '../../key/keyMatchers.js'; |
| import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; |
|
|
| const ANIMATION_FRAME_DURATION_MS = 33; |
|
|
| interface ScrollableListProps<T> extends VirtualizedListProps<T> { |
| hasFocus: boolean; |
| width?: string | number; |
| scrollbar?: boolean; |
| stableScrollback?: boolean; |
| copyModeEnabled?: boolean; |
| isStatic?: boolean; |
| fixedItemHeight?: boolean; |
| targetScrollIndex?: number; |
| containerHeight?: number; |
| scrollbarThumbColor?: string; |
| } |
|
|
| export type ScrollableListRef<T> = VirtualizedListRef<T>; |
|
|
| function ScrollableList<T>( |
| props: ScrollableListProps<T>, |
| ref: React.Ref<ScrollableListRef<T>>, |
| ) { |
| const keyMatchers = useKeyMatchers(); |
| const { hasFocus, width, scrollbar = true, stableScrollback } = props; |
| const virtualizedListRef = useRef<VirtualizedListRef<T>>(null); |
| const containerRef = useRef<DOMElement>(null); |
|
|
| useImperativeHandle( |
| ref, |
| () => ({ |
| scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta), |
| scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset), |
| scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(), |
| scrollToIndex: (params) => |
| virtualizedListRef.current?.scrollToIndex(params), |
| scrollToItem: (params) => |
| virtualizedListRef.current?.scrollToItem(params), |
| getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0, |
| getScrollState: () => |
| virtualizedListRef.current?.getScrollState() ?? { |
| scrollTop: 0, |
| scrollHeight: 0, |
| innerHeight: 0, |
| }, |
| }), |
| [], |
| ); |
|
|
| const getScrollState = useCallback( |
| () => |
| virtualizedListRef.current?.getScrollState() ?? { |
| scrollTop: 0, |
| scrollHeight: 0, |
| innerHeight: 0, |
| }, |
| [], |
| ); |
|
|
| const scrollBy = useCallback((delta: number) => { |
| virtualizedListRef.current?.scrollBy(delta); |
| }, []); |
|
|
| const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = |
| useAnimatedScrollbar(hasFocus, scrollBy); |
|
|
| const smoothScrollState = useRef<{ |
| active: boolean; |
| start: number; |
| from: number; |
| to: number; |
| duration: number; |
| timer: NodeJS.Timeout | null; |
| }>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null }); |
|
|
| const stopSmoothScroll = useCallback(() => { |
| if (smoothScrollState.current.timer) { |
| clearInterval(smoothScrollState.current.timer); |
| smoothScrollState.current.timer = null; |
| } |
| smoothScrollState.current.active = false; |
| }, []); |
|
|
| useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]); |
|
|
| const smoothScrollTo = useCallback( |
| ( |
| targetScrollTop: number, |
| duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200, |
| ) => { |
| stopSmoothScroll(); |
|
|
| const scrollState = virtualizedListRef.current?.getScrollState() ?? { |
| scrollTop: 0, |
| scrollHeight: 0, |
| innerHeight: 0, |
| }; |
| const { |
| scrollTop: rawStartScrollTop, |
| scrollHeight, |
| innerHeight, |
| } = scrollState; |
|
|
| const maxScrollTop = Math.max(0, scrollHeight - innerHeight); |
| const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop); |
|
|
| let effectiveTarget = targetScrollTop; |
| if ( |
| targetScrollTop === SCROLL_TO_ITEM_END || |
| targetScrollTop >= maxScrollTop |
| ) { |
| effectiveTarget = maxScrollTop; |
| } |
|
|
| const clampedTarget = Math.max( |
| 0, |
| Math.min(maxScrollTop, effectiveTarget), |
| ); |
|
|
| if (duration === 0) { |
| if ( |
| targetScrollTop === SCROLL_TO_ITEM_END || |
| targetScrollTop >= maxScrollTop |
| ) { |
| virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); |
| } else { |
| virtualizedListRef.current?.scrollTo(Math.round(clampedTarget)); |
| } |
| flashScrollbar(); |
| return; |
| } |
|
|
| smoothScrollState.current = { |
| active: true, |
| start: Date.now(), |
| from: startScrollTop, |
| to: clampedTarget, |
| duration, |
| timer: setInterval(() => { |
| const now = Date.now(); |
| const elapsed = now - smoothScrollState.current.start; |
| const progress = Math.min(elapsed / duration, 1); |
|
|
| |
| const t = progress; |
| const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; |
|
|
| const current = |
| smoothScrollState.current.from + |
| (smoothScrollState.current.to - smoothScrollState.current.from) * |
| ease; |
|
|
| if (progress >= 1) { |
| if ( |
| targetScrollTop === SCROLL_TO_ITEM_END || |
| targetScrollTop >= maxScrollTop |
| ) { |
| virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER); |
| } else { |
| virtualizedListRef.current?.scrollTo(Math.round(current)); |
| } |
| stopSmoothScroll(); |
| flashScrollbar(); |
| } else { |
| virtualizedListRef.current?.scrollTo(Math.round(current)); |
| } |
| }, ANIMATION_FRAME_DURATION_MS), |
| }; |
| }, |
| [stopSmoothScroll, flashScrollbar], |
| ); |
|
|
| useKeypress( |
| (key: Key) => { |
| if (keyMatchers[Command.SCROLL_UP](key)) { |
| stopSmoothScroll(); |
| scrollByWithAnimation(-1); |
| return true; |
| } else if (keyMatchers[Command.SCROLL_DOWN](key)) { |
| stopSmoothScroll(); |
| scrollByWithAnimation(1); |
| return true; |
| } else if ( |
| keyMatchers[Command.PAGE_UP](key) || |
| keyMatchers[Command.PAGE_DOWN](key) |
| ) { |
| const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1; |
| const scrollState = getScrollState(); |
| const maxScroll = Math.max( |
| 0, |
| scrollState.scrollHeight - scrollState.innerHeight, |
| ); |
| const current = smoothScrollState.current.active |
| ? smoothScrollState.current.to |
| : Math.min(scrollState.scrollTop, maxScroll); |
| const innerHeight = scrollState.innerHeight; |
| smoothScrollTo(current + direction * innerHeight); |
| return true; |
| } else if (keyMatchers[Command.SCROLL_HOME](key)) { |
| smoothScrollTo(0); |
| return true; |
| } else if (keyMatchers[Command.SCROLL_END](key)) { |
| smoothScrollTo(SCROLL_TO_ITEM_END); |
| return true; |
| } |
| return false; |
| }, |
| { isActive: hasFocus }, |
| ); |
|
|
| const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); |
|
|
| const scrollableEntry = useMemo( |
| () => ({ |
| |
| ref: containerRef as React.RefObject<DOMElement>, |
| getScrollState, |
| scrollBy: scrollByWithAnimation, |
| scrollTo: smoothScrollTo, |
| hasFocus: hasFocusCallback, |
| flashScrollbar, |
| }), |
| [ |
| getScrollState, |
| hasFocusCallback, |
| flashScrollbar, |
| scrollByWithAnimation, |
| smoothScrollTo, |
| ], |
| ); |
|
|
| useScrollable(scrollableEntry, true); |
|
|
| return ( |
| <Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}> |
| <VirtualizedList |
| ref={virtualizedListRef} |
| {...props} |
| scrollbar={scrollbar} |
| scrollbarThumbColor={scrollbarColor} |
| stableScrollback={stableScrollback} |
| /> |
| </Box> |
| ); |
| } |
|
|
| |
| const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>( |
| props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> }, |
| ) => React.ReactElement; |
|
|
| export { ScrollableListWithForwardRef as ScrollableList }; |
|
|