new-api / web /src /components /common /ui /ScrollableContainer.jsx
liuzhao521
Deploy New API v0.9.25+ (commit b47cf4ef) to HuggingFace Spaces
4674012
/*
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;