ScrollableContainer.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, {
  16. useRef,
  17. useState,
  18. useEffect,
  19. useCallback,
  20. useMemo,
  21. useImperativeHandle,
  22. forwardRef
  23. } from 'react';
  24. /**
  25. * ScrollableContainer 可滚动容器组件
  26. *
  27. * 提供自动检测滚动状态和显示渐变指示器的功能
  28. * 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
  29. *
  30. */
  31. const ScrollableContainer = forwardRef(({
  32. children,
  33. maxHeight = '24rem',
  34. className = '',
  35. contentClassName = 'p-2',
  36. fadeIndicatorClassName = '',
  37. checkInterval = 100,
  38. scrollThreshold = 5,
  39. debounceDelay = 16, // ~60fps
  40. onScroll,
  41. onScrollStateChange,
  42. ...props
  43. }, ref) => {
  44. const scrollRef = useRef(null);
  45. const containerRef = useRef(null);
  46. const debounceTimerRef = useRef(null);
  47. const resizeObserverRef = useRef(null);
  48. const onScrollStateChangeRef = useRef(onScrollStateChange);
  49. const onScrollRef = useRef(onScroll);
  50. const [showScrollHint, setShowScrollHint] = useState(false);
  51. useEffect(() => {
  52. onScrollStateChangeRef.current = onScrollStateChange;
  53. }, [onScrollStateChange]);
  54. useEffect(() => {
  55. onScrollRef.current = onScroll;
  56. }, [onScroll]);
  57. const debounce = useCallback((func, delay) => {
  58. return (...args) => {
  59. if (debounceTimerRef.current) {
  60. clearTimeout(debounceTimerRef.current);
  61. }
  62. debounceTimerRef.current = setTimeout(() => func(...args), delay);
  63. };
  64. }, []);
  65. const checkScrollable = useCallback(() => {
  66. if (!scrollRef.current) return;
  67. const element = scrollRef.current;
  68. const isScrollable = element.scrollHeight > element.clientHeight;
  69. const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold;
  70. const shouldShowHint = isScrollable && !isAtBottom;
  71. setShowScrollHint(shouldShowHint);
  72. if (onScrollStateChangeRef.current) {
  73. onScrollStateChangeRef.current({
  74. isScrollable,
  75. isAtBottom,
  76. showScrollHint: shouldShowHint,
  77. scrollTop: element.scrollTop,
  78. scrollHeight: element.scrollHeight,
  79. clientHeight: element.clientHeight
  80. });
  81. }
  82. }, [scrollThreshold]);
  83. const debouncedCheckScrollable = useMemo(() =>
  84. debounce(checkScrollable, debounceDelay),
  85. [debounce, checkScrollable, debounceDelay]
  86. );
  87. const handleScroll = useCallback((e) => {
  88. debouncedCheckScrollable();
  89. if (onScrollRef.current) {
  90. onScrollRef.current(e);
  91. }
  92. }, [debouncedCheckScrollable]);
  93. useImperativeHandle(ref, () => ({
  94. checkScrollable: () => {
  95. checkScrollable();
  96. },
  97. scrollToTop: () => {
  98. if (scrollRef.current) {
  99. scrollRef.current.scrollTop = 0;
  100. }
  101. },
  102. scrollToBottom: () => {
  103. if (scrollRef.current) {
  104. scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  105. }
  106. },
  107. getScrollInfo: () => {
  108. if (!scrollRef.current) return null;
  109. const element = scrollRef.current;
  110. return {
  111. scrollTop: element.scrollTop,
  112. scrollHeight: element.scrollHeight,
  113. clientHeight: element.clientHeight,
  114. isScrollable: element.scrollHeight > element.clientHeight,
  115. isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
  116. };
  117. }
  118. }), [checkScrollable, scrollThreshold]);
  119. useEffect(() => {
  120. const timer = setTimeout(() => {
  121. checkScrollable();
  122. }, checkInterval);
  123. return () => clearTimeout(timer);
  124. }, [checkScrollable, checkInterval]);
  125. useEffect(() => {
  126. if (!scrollRef.current) return;
  127. if (typeof ResizeObserver === 'undefined') {
  128. if (typeof MutationObserver !== 'undefined') {
  129. const observer = new MutationObserver(() => {
  130. debouncedCheckScrollable();
  131. });
  132. observer.observe(scrollRef.current, {
  133. childList: true,
  134. subtree: true,
  135. attributes: true,
  136. characterData: true
  137. });
  138. return () => observer.disconnect();
  139. }
  140. return;
  141. }
  142. resizeObserverRef.current = new ResizeObserver((entries) => {
  143. for (const entry of entries) {
  144. debouncedCheckScrollable();
  145. }
  146. });
  147. resizeObserverRef.current.observe(scrollRef.current);
  148. return () => {
  149. if (resizeObserverRef.current) {
  150. resizeObserverRef.current.disconnect();
  151. }
  152. };
  153. }, [debouncedCheckScrollable]);
  154. useEffect(() => {
  155. return () => {
  156. if (debounceTimerRef.current) {
  157. clearTimeout(debounceTimerRef.current);
  158. }
  159. };
  160. }, []);
  161. const containerStyle = useMemo(() => ({
  162. maxHeight
  163. }), [maxHeight]);
  164. const fadeIndicatorStyle = useMemo(() => ({
  165. opacity: showScrollHint ? 1 : 0
  166. }), [showScrollHint]);
  167. return (
  168. <div
  169. ref={containerRef}
  170. className={`card-content-container ${className}`}
  171. {...props}
  172. >
  173. <div
  174. ref={scrollRef}
  175. className={`overflow-y-auto card-content-scroll ${contentClassName}`}
  176. style={containerStyle}
  177. onScroll={handleScroll}
  178. >
  179. {children}
  180. </div>
  181. <div
  182. className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
  183. style={fadeIndicatorStyle}
  184. />
  185. </div>
  186. );
  187. });
  188. ScrollableContainer.displayName = 'ScrollableContainer';
  189. export default ScrollableContainer;