| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- /*
- 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;
|