| |
| |
| |
| |
| |
|
|
| import type React from 'react'; |
| import { |
| useState, |
| useRef, |
| useCallback, |
| useMemo, |
| useLayoutEffect, |
| useEffect, |
| useId, |
| } from 'react'; |
| import { Box, ResizeObserver, type DOMElement } from 'ink'; |
| import { useKeypress, type Key } from '../../hooks/useKeypress.js'; |
| import { useScrollable } from '../../contexts/ScrollProvider.js'; |
| import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; |
| import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; |
| import { Command } from '../../key/keyMatchers.js'; |
| import { useOverflowActions } from '../../contexts/OverflowContext.js'; |
| import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; |
|
|
| interface ScrollableProps { |
| children?: React.ReactNode; |
| width?: number; |
| height?: number | string; |
| maxWidth?: number; |
| maxHeight?: number; |
| hasFocus: boolean; |
| scrollToBottom?: boolean; |
| flexGrow?: number; |
| reportOverflow?: boolean; |
| overflowToBackbuffer?: boolean; |
| scrollbar?: boolean; |
| stableScrollback?: boolean; |
| } |
|
|
| export const Scrollable: React.FC<ScrollableProps> = ({ |
| children, |
| width, |
| height, |
| maxWidth, |
| maxHeight, |
| hasFocus, |
| scrollToBottom, |
| flexGrow, |
| reportOverflow = false, |
| overflowToBackbuffer, |
| scrollbar = true, |
| stableScrollback, |
| }) => { |
| const keyMatchers = useKeyMatchers(); |
| const [scrollTop, setScrollTop] = useState(0); |
| const viewportRef = useRef<DOMElement | null>(null); |
| const contentRef = useRef<DOMElement | null>(null); |
| const overflowActions = useOverflowActions(); |
| const id = useId(); |
| const [size, setSize] = useState({ |
| innerHeight: typeof height === 'number' ? height : 0, |
| scrollHeight: 0, |
| }); |
| const sizeRef = useRef(size); |
| const scrollTopRef = useRef(scrollTop); |
|
|
| useLayoutEffect(() => { |
| sizeRef.current = size; |
| }, [size]); |
|
|
| useLayoutEffect(() => { |
| scrollTopRef.current = scrollTop; |
| }, [scrollTop]); |
|
|
| useEffect(() => { |
| if (reportOverflow && size.scrollHeight > size.innerHeight) { |
| overflowActions?.addOverflowingId?.(id); |
| } else { |
| overflowActions?.removeOverflowingId?.(id); |
| } |
| }, [ |
| reportOverflow, |
| size.scrollHeight, |
| size.innerHeight, |
| id, |
| overflowActions, |
| ]); |
|
|
| useEffect( |
| () => () => { |
| overflowActions?.removeOverflowingId?.(id); |
| }, |
| [id, overflowActions], |
| ); |
|
|
| const viewportObserverRef = useRef<ResizeObserver | null>(null); |
| const contentObserverRef = useRef<ResizeObserver | null>(null); |
|
|
| useEffect( |
| () => () => { |
| viewportObserverRef.current?.disconnect(); |
| contentObserverRef.current?.disconnect(); |
| }, |
| [], |
| ); |
|
|
| const viewportRefCallback = useCallback((node: DOMElement | null) => { |
| viewportObserverRef.current?.disconnect(); |
| viewportRef.current = node; |
|
|
| if (node) { |
| const observer = new ResizeObserver((entries) => { |
| const entry = entries[0]; |
| if (entry) { |
| const innerHeight = Math.round(entry.contentRect.height); |
| setSize((prev) => { |
| const scrollHeight = prev.scrollHeight; |
| const isAtBottom = |
| scrollHeight > prev.innerHeight && |
| scrollTopRef.current >= scrollHeight - prev.innerHeight - 1; |
|
|
| if (isAtBottom) { |
| setScrollTop(Number.MAX_SAFE_INTEGER); |
| } |
| return { ...prev, innerHeight }; |
| }); |
| } |
| }); |
| observer.observe(node); |
| viewportObserverRef.current = observer; |
| } |
| }, []); |
|
|
| const contentRefCallback = useCallback( |
| (node: DOMElement | null) => { |
| contentObserverRef.current?.disconnect(); |
| contentRef.current = node; |
|
|
| if (node) { |
| const observer = new ResizeObserver((entries) => { |
| const entry = entries[0]; |
| if (entry) { |
| const scrollHeight = Math.round(entry.contentRect.height); |
| setSize((prev) => { |
| const innerHeight = prev.innerHeight; |
| const isAtBottom = |
| prev.scrollHeight > innerHeight && |
| scrollTopRef.current >= prev.scrollHeight - innerHeight - 1; |
|
|
| if ( |
| isAtBottom || |
| (scrollToBottom && scrollHeight > prev.scrollHeight) |
| ) { |
| setScrollTop(Number.MAX_SAFE_INTEGER); |
| } |
| return { ...prev, scrollHeight }; |
| }); |
| } |
| }); |
| observer.observe(node); |
| contentObserverRef.current = observer; |
| } |
| }, |
| [scrollToBottom], |
| ); |
|
|
| const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); |
|
|
| const scrollBy = useCallback( |
| (delta: number) => { |
| const { scrollHeight, innerHeight } = sizeRef.current; |
| const maxScroll = Math.max(0, scrollHeight - innerHeight); |
| const current = Math.min(getScrollTop(), maxScroll); |
| let next = Math.max(0, current + delta); |
| if (next >= maxScroll) { |
| next = Number.MAX_SAFE_INTEGER; |
| } |
| setPendingScrollTop(next); |
| setScrollTop(next); |
| }, |
| [getScrollTop, setPendingScrollTop], |
| ); |
|
|
| const { scrollbarColor, flashScrollbar, scrollByWithAnimation } = |
| useAnimatedScrollbar(hasFocus, scrollBy); |
|
|
| useKeypress( |
| (key: Key) => { |
| const { scrollHeight, innerHeight } = sizeRef.current; |
| const scrollTop = getScrollTop(); |
| const maxScroll = Math.max(0, scrollHeight - innerHeight); |
| const actualScrollTop = Math.min(scrollTop, maxScroll); |
|
|
| |
| |
| if (actualScrollTop > 0) { |
| if (keyMatchers[Command.PAGE_UP](key)) { |
| scrollByWithAnimation(-innerHeight); |
| return true; |
| } |
| if (keyMatchers[Command.SCROLL_UP](key)) { |
| scrollByWithAnimation(-1); |
| return true; |
| } |
| } |
|
|
| |
| |
| if (actualScrollTop < maxScroll) { |
| if (keyMatchers[Command.PAGE_DOWN](key)) { |
| scrollByWithAnimation(innerHeight); |
| return true; |
| } |
| if (keyMatchers[Command.SCROLL_DOWN](key)) { |
| scrollByWithAnimation(1); |
| return true; |
| } |
| } |
|
|
| |
| return false; |
| }, |
| { isActive: hasFocus }, |
| ); |
|
|
| const getScrollState = useCallback(() => { |
| const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight); |
| return { |
| scrollTop: Math.min(getScrollTop(), maxScroll), |
| scrollHeight: size.scrollHeight, |
| innerHeight: size.innerHeight, |
| }; |
| }, [getScrollTop, size.scrollHeight, size.innerHeight]); |
|
|
| const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); |
|
|
| const scrollableEntry = useMemo( |
| () => ({ |
| |
| ref: viewportRef as React.RefObject<DOMElement>, |
| getScrollState, |
| scrollBy: scrollByWithAnimation, |
| hasFocus: hasFocusCallback, |
| flashScrollbar, |
| }), |
| [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], |
| ); |
|
|
| useScrollable(scrollableEntry, true); |
|
|
| return ( |
| <Box |
| ref={viewportRefCallback} |
| maxHeight={maxHeight} |
| width={width ?? maxWidth} |
| height={height} |
| flexDirection="column" |
| overflowY="scroll" |
| overflowX="hidden" |
| scrollTop={scrollTop} |
| flexGrow={flexGrow} |
| scrollbarThumbColor={scrollbarColor} |
| overflowToBackbuffer={overflowToBackbuffer} |
| scrollbar={scrollbar} |
| stableScrollback={stableScrollback} |
| > |
| {/* |
| This inner box is necessary to prevent the parent from shrinking |
| based on the children's content. It also adds a right padding to |
| make room for the scrollbar. |
| */} |
| <Box |
| ref={contentRefCallback} |
| flexShrink={0} |
| paddingRight={1} |
| flexDirection="column" |
| > |
| {children} |
| </Box> |
| </Box> |
| ); |
| }; |
|
|