Spaces:
Build error
Build error
| /* | |
| Copyright (C) 2025 QuantumNous | |
| This program is free software: you can redistribute it and/or modify | |
| it under the terms of the GNU Affero General Public License as | |
| published by the Free Software Foundation, either version 3 of the | |
| License, or (at your option) any later version. | |
| This program is distributed in the hope that it will be useful, | |
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| GNU Affero General Public License for more details. | |
| You should have received a copy of the GNU Affero General Public License | |
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| For commercial licensing, please contact support@quantumnous.com | |
| */ | |
| import React, { | |
| useRef, | |
| useState, | |
| useEffect, | |
| useCallback, | |
| useMemo, | |
| useImperativeHandle, | |
| forwardRef, | |
| } from 'react'; | |
| /** | |
| * ScrollableContainer 可滚动容器组件 | |
| * | |
| * 提供自动检测滚动状态和显示渐变指示器的功能 | |
| * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器 | |
| * | |
| */ | |
| const ScrollableContainer = forwardRef( | |
| ( | |
| { | |
| children, | |
| maxHeight = '24rem', | |
| className = '', | |
| contentClassName = '', | |
| fadeIndicatorClassName = '', | |
| checkInterval = 100, | |
| scrollThreshold = 5, | |
| debounceDelay = 16, // ~60fps | |
| onScroll, | |
| onScrollStateChange, | |
| ...props | |
| }, | |
| ref, | |
| ) => { | |
| const scrollRef = useRef(null); | |
| const containerRef = useRef(null); | |
| const debounceTimerRef = useRef(null); | |
| const resizeObserverRef = useRef(null); | |
| const onScrollStateChangeRef = useRef(onScrollStateChange); | |
| const onScrollRef = useRef(onScroll); | |
| const [showScrollHint, setShowScrollHint] = useState(false); | |
| useEffect(() => { | |
| onScrollStateChangeRef.current = onScrollStateChange; | |
| }, [onScrollStateChange]); | |
| useEffect(() => { | |
| onScrollRef.current = onScroll; | |
| }, [onScroll]); | |
| const debounce = useCallback((func, delay) => { | |
| return (...args) => { | |
| if (debounceTimerRef.current) { | |
| clearTimeout(debounceTimerRef.current); | |
| } | |
| debounceTimerRef.current = setTimeout(() => func(...args), delay); | |
| }; | |
| }, []); | |
| const checkScrollable = useCallback(() => { | |
| if (!scrollRef.current) return; | |
| const element = scrollRef.current; | |
| const isScrollable = element.scrollHeight > element.clientHeight; | |
| const isAtBottom = | |
| element.scrollTop + element.clientHeight >= | |
| element.scrollHeight - scrollThreshold; | |
| const shouldShowHint = isScrollable && !isAtBottom; | |
| setShowScrollHint(shouldShowHint); | |
| if (onScrollStateChangeRef.current) { | |
| onScrollStateChangeRef.current({ | |
| isScrollable, | |
| isAtBottom, | |
| showScrollHint: shouldShowHint, | |
| scrollTop: element.scrollTop, | |
| scrollHeight: element.scrollHeight, | |
| clientHeight: element.clientHeight, | |
| }); | |
| } | |
| }, [scrollThreshold]); | |
| const debouncedCheckScrollable = useMemo( | |
| () => debounce(checkScrollable, debounceDelay), | |
| [debounce, checkScrollable, debounceDelay], | |
| ); | |
| const handleScroll = useCallback( | |
| (e) => { | |
| debouncedCheckScrollable(); | |
| if (onScrollRef.current) { | |
| onScrollRef.current(e); | |
| } | |
| }, | |
| [debouncedCheckScrollable], | |
| ); | |
| useImperativeHandle( | |
| ref, | |
| () => ({ | |
| checkScrollable: () => { | |
| checkScrollable(); | |
| }, | |
| scrollToTop: () => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = 0; | |
| } | |
| }, | |
| scrollToBottom: () => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, | |
| getScrollInfo: () => { | |
| if (!scrollRef.current) return null; | |
| const element = scrollRef.current; | |
| return { | |
| scrollTop: element.scrollTop, | |
| scrollHeight: element.scrollHeight, | |
| clientHeight: element.clientHeight, | |
| isScrollable: element.scrollHeight > element.clientHeight, | |
| isAtBottom: | |
| element.scrollTop + element.clientHeight >= | |
| element.scrollHeight - scrollThreshold, | |
| }; | |
| }, | |
| }), | |
| [checkScrollable, scrollThreshold], | |
| ); | |
| useEffect(() => { | |
| const timer = setTimeout(() => { | |
| checkScrollable(); | |
| }, checkInterval); | |
| return () => clearTimeout(timer); | |
| }, [checkScrollable, checkInterval]); | |
| useEffect(() => { | |
| if (!scrollRef.current) return; | |
| if (typeof ResizeObserver === 'undefined') { | |
| if (typeof MutationObserver !== 'undefined') { | |
| const observer = new MutationObserver(() => { | |
| debouncedCheckScrollable(); | |
| }); | |
| observer.observe(scrollRef.current, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| characterData: true, | |
| }); | |
| return () => observer.disconnect(); | |
| } | |
| return; | |
| } | |
| resizeObserverRef.current = new ResizeObserver((entries) => { | |
| for (const entry of entries) { | |
| debouncedCheckScrollable(); | |
| } | |
| }); | |
| resizeObserverRef.current.observe(scrollRef.current); | |
| return () => { | |
| if (resizeObserverRef.current) { | |
| resizeObserverRef.current.disconnect(); | |
| } | |
| }; | |
| }, [debouncedCheckScrollable]); | |
| useEffect(() => { | |
| return () => { | |
| if (debounceTimerRef.current) { | |
| clearTimeout(debounceTimerRef.current); | |
| } | |
| }; | |
| }, []); | |
| const containerStyle = useMemo( | |
| () => ({ | |
| maxHeight, | |
| }), | |
| [maxHeight], | |
| ); | |
| const fadeIndicatorStyle = useMemo( | |
| () => ({ | |
| opacity: showScrollHint ? 1 : 0, | |
| }), | |
| [showScrollHint], | |
| ); | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className={`card-content-container ${className}`} | |
| {...props} | |
| > | |
| <div | |
| ref={scrollRef} | |
| className={`overflow-y-auto card-content-scroll ${contentClassName}`} | |
| style={containerStyle} | |
| onScroll={handleScroll} | |
| > | |
| {children} | |
| </div> | |
| <div | |
| className={`card-content-fade-indicator ${fadeIndicatorClassName}`} | |
| style={fadeIndicatorStyle} | |
| /> | |
| </div> | |
| ); | |
| }, | |
| ); | |
| ScrollableContainer.displayName = 'ScrollableContainer'; | |
| export default ScrollableContainer; | |