/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo, } from 'react'; import { Box, getInnerHeight, getScrollHeight, 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 { keyMatchers, Command } from '../../keyMatchers.js'; interface ScrollableProps { children?: React.ReactNode; width?: number; height?: number | string; maxWidth?: number; maxHeight?: number; hasFocus: boolean; scrollToBottom?: boolean; flexGrow?: number; } export const Scrollable: React.FC = ({ children, width, height, maxWidth, maxHeight, hasFocus, scrollToBottom, flexGrow, }) => { const [scrollTop, setScrollTop] = useState(0); const ref = useRef(null); const [size, setSize] = useState({ innerHeight: 0, scrollHeight: 0, }); const sizeRef = useRef(size); useEffect(() => { sizeRef.current = size; }, [size]); const childrenCountRef = useRef(0); // This effect needs to run on every render to correctly measure the container // and scroll to the bottom if new children are added. // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!ref.current) { return; } const innerHeight = Math.round(getInnerHeight(ref.current)); const scrollHeight = Math.round(getScrollHeight(ref.current)); const isAtBottom = scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; if ( size.innerHeight !== innerHeight || size.scrollHeight !== scrollHeight ) { setSize({ innerHeight, scrollHeight }); if (isAtBottom) { setScrollTop(Math.max(0, scrollHeight - innerHeight)); } } const childCountCurrent = React.Children.count(children); if (scrollToBottom && childrenCountRef.current !== childCountCurrent) { setScrollTop(Math.max(0, scrollHeight - innerHeight)); } childrenCountRef.current = childCountCurrent; }); const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); const scrollBy = useCallback( (delta: number) => { const { scrollHeight, innerHeight } = sizeRef.current; const current = getScrollTop(); const next = Math.min( Math.max(0, current + delta), Math.max(0, scrollHeight - innerHeight), ); setPendingScrollTop(next); setScrollTop(next); }, [sizeRef, 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); // Only capture scroll-up events if there's room; // otherwise allow events to bubble. if (scrollTop > 0) { if (keyMatchers[Command.PAGE_UP](key)) { scrollByWithAnimation(-innerHeight); return true; } if (keyMatchers[Command.SCROLL_UP](key)) { scrollByWithAnimation(-1); return true; } } // Only capture scroll-down events if there's room; // otherwise allow events to bubble. if (scrollTop < maxScroll) { if (keyMatchers[Command.PAGE_DOWN](key)) { scrollByWithAnimation(innerHeight); return true; } if (keyMatchers[Command.SCROLL_DOWN](key)) { scrollByWithAnimation(1); return true; } } // bubble keypress return false; }, { isActive: hasFocus }, ); const getScrollState = useCallback( () => ({ scrollTop: getScrollTop(), scrollHeight: size.scrollHeight, innerHeight: size.innerHeight, }), [getScrollTop, size.scrollHeight, size.innerHeight], ); const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); const scrollableEntry = useMemo( () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ref: ref as React.RefObject, getScrollState, scrollBy: scrollByWithAnimation, hasFocus: hasFocusCallback, flashScrollbar, }), [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], ); useScrollable(scrollableEntry, true); return ( {/* 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. */} {children} ); };