|
@@ -17,16 +17,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
-import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
|
|
|
-import PropTypes from 'prop-types';
|
|
|
|
|
|
|
+import React, {
|
|
|
|
|
+ useRef,
|
|
|
|
|
+ useState,
|
|
|
|
|
+ useEffect,
|
|
|
|
|
+ useCallback,
|
|
|
|
|
+ useMemo,
|
|
|
|
|
+ useImperativeHandle,
|
|
|
|
|
+ forwardRef
|
|
|
|
|
+} from 'react';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* ScrollableContainer 可滚动容器组件
|
|
* ScrollableContainer 可滚动容器组件
|
|
|
*
|
|
*
|
|
|
* 提供自动检测滚动状态和显示渐变指示器的功能
|
|
* 提供自动检测滚动状态和显示渐变指示器的功能
|
|
|
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
|
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
|
|
|
|
+ *
|
|
|
*/
|
|
*/
|
|
|
-const ScrollableContainer = ({
|
|
|
|
|
|
|
+const ScrollableContainer = forwardRef(({
|
|
|
children,
|
|
children,
|
|
|
maxHeight = '24rem',
|
|
maxHeight = '24rem',
|
|
|
className = '',
|
|
className = '',
|
|
@@ -34,98 +42,179 @@ const ScrollableContainer = ({
|
|
|
fadeIndicatorClassName = '',
|
|
fadeIndicatorClassName = '',
|
|
|
checkInterval = 100,
|
|
checkInterval = 100,
|
|
|
scrollThreshold = 5,
|
|
scrollThreshold = 5,
|
|
|
|
|
+ debounceDelay = 16, // ~60fps
|
|
|
onScroll,
|
|
onScroll,
|
|
|
onScrollStateChange,
|
|
onScrollStateChange,
|
|
|
...props
|
|
...props
|
|
|
-}) => {
|
|
|
|
|
|
|
+}, ref) => {
|
|
|
const scrollRef = useRef(null);
|
|
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);
|
|
const [showScrollHint, setShowScrollHint] = useState(false);
|
|
|
|
|
|
|
|
- // 检查是否可滚动且未滚动到底部
|
|
|
|
|
- const checkScrollable = useCallback(() => {
|
|
|
|
|
- if (scrollRef.current) {
|
|
|
|
|
- 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 (onScrollStateChange) {
|
|
|
|
|
- onScrollStateChange({
|
|
|
|
|
- isScrollable,
|
|
|
|
|
- isAtBottom,
|
|
|
|
|
- showScrollHint: shouldShowHint,
|
|
|
|
|
- scrollTop: element.scrollTop,
|
|
|
|
|
- scrollHeight: element.scrollHeight,
|
|
|
|
|
- clientHeight: element.clientHeight
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ 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, onScrollStateChange]);
|
|
|
|
|
|
|
+ }, [scrollThreshold]);
|
|
|
|
|
+
|
|
|
|
|
+ const debouncedCheckScrollable = useMemo(() =>
|
|
|
|
|
+ debounce(checkScrollable, debounceDelay),
|
|
|
|
|
+ [debounce, checkScrollable, debounceDelay]
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- // 处理滚动事件
|
|
|
|
|
const handleScroll = useCallback((e) => {
|
|
const handleScroll = useCallback((e) => {
|
|
|
- checkScrollable();
|
|
|
|
|
- if (onScroll) {
|
|
|
|
|
- onScroll(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, onScroll]);
|
|
|
|
|
|
|
+ }), [checkScrollable, scrollThreshold]);
|
|
|
|
|
|
|
|
- // 初始检查和内容变化时检查
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const timer = setTimeout(() => {
|
|
const timer = setTimeout(() => {
|
|
|
checkScrollable();
|
|
checkScrollable();
|
|
|
}, checkInterval);
|
|
}, checkInterval);
|
|
|
return () => clearTimeout(timer);
|
|
return () => clearTimeout(timer);
|
|
|
- }, [children, checkScrollable, checkInterval]);
|
|
|
|
|
|
|
+ }, [checkScrollable, checkInterval]);
|
|
|
|
|
|
|
|
- // 暴露检查方法给父组件
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (scrollRef.current) {
|
|
|
|
|
- scrollRef.current.checkScrollable = checkScrollable;
|
|
|
|
|
|
|
+ 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;
|
|
|
}
|
|
}
|
|
|
- }, [checkScrollable]);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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 (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
|
|
+ ref={containerRef}
|
|
|
className={`card-content-container ${className}`}
|
|
className={`card-content-container ${className}`}
|
|
|
{...props}
|
|
{...props}
|
|
|
>
|
|
>
|
|
|
<div
|
|
<div
|
|
|
ref={scrollRef}
|
|
ref={scrollRef}
|
|
|
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
|
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
|
|
- style={{ maxHeight }}
|
|
|
|
|
|
|
+ style={containerStyle}
|
|
|
onScroll={handleScroll}
|
|
onScroll={handleScroll}
|
|
|
>
|
|
>
|
|
|
{children}
|
|
{children}
|
|
|
</div>
|
|
</div>
|
|
|
<div
|
|
<div
|
|
|
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
|
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
|
|
- style={{ opacity: showScrollHint ? 1 : 0 }}
|
|
|
|
|
|
|
+ style={fadeIndicatorStyle}
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-ScrollableContainer.propTypes = {
|
|
|
|
|
- // 子组件内容
|
|
|
|
|
- children: PropTypes.node.isRequired,
|
|
|
|
|
-
|
|
|
|
|
- // 样式相关
|
|
|
|
|
- maxHeight: PropTypes.string,
|
|
|
|
|
- className: PropTypes.string,
|
|
|
|
|
- contentClassName: PropTypes.string,
|
|
|
|
|
- fadeIndicatorClassName: PropTypes.string,
|
|
|
|
|
-
|
|
|
|
|
- // 行为配置
|
|
|
|
|
- checkInterval: PropTypes.number,
|
|
|
|
|
- scrollThreshold: PropTypes.number,
|
|
|
|
|
-
|
|
|
|
|
- // 事件回调
|
|
|
|
|
- onScroll: PropTypes.func,
|
|
|
|
|
- onScrollStateChange: PropTypes.func,
|
|
|
|
|
-};
|
|
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+ScrollableContainer.displayName = 'ScrollableContainer';
|
|
|
|
|
|
|
|
export default ScrollableContainer;
|
|
export default ScrollableContainer;
|