ScrollableContainer.jsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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. (
  33. {
  34. children,
  35. maxHeight = '24rem',
  36. className = '',
  37. contentClassName = '',
  38. fadeIndicatorClassName = '',
  39. checkInterval = 100,
  40. scrollThreshold = 5,
  41. debounceDelay = 16, // ~60fps
  42. onScroll,
  43. onScrollStateChange,
  44. ...props
  45. },
  46. ref,
  47. ) => {
  48. const scrollRef = useRef(null);
  49. const containerRef = useRef(null);
  50. const debounceTimerRef = useRef(null);
  51. const resizeObserverRef = useRef(null);
  52. const onScrollStateChangeRef = useRef(onScrollStateChange);
  53. const onScrollRef = useRef(onScroll);
  54. const [showScrollHint, setShowScrollHint] = useState(false);
  55. useEffect(() => {
  56. onScrollStateChangeRef.current = onScrollStateChange;
  57. }, [onScrollStateChange]);
  58. useEffect(() => {
  59. onScrollRef.current = onScroll;
  60. }, [onScroll]);
  61. const debounce = useCallback((func, delay) => {
  62. return (...args) => {
  63. if (debounceTimerRef.current) {
  64. clearTimeout(debounceTimerRef.current);
  65. }
  66. debounceTimerRef.current = setTimeout(() => func(...args), delay);
  67. };
  68. }, []);
  69. const checkScrollable = useCallback(() => {
  70. if (!scrollRef.current) return;
  71. const element = scrollRef.current;
  72. const isScrollable = element.scrollHeight > element.clientHeight;
  73. const isAtBottom =
  74. element.scrollTop + element.clientHeight >=
  75. element.scrollHeight - scrollThreshold;
  76. const shouldShowHint = isScrollable && !isAtBottom;
  77. setShowScrollHint(shouldShowHint);
  78. if (onScrollStateChangeRef.current) {
  79. onScrollStateChangeRef.current({
  80. isScrollable,
  81. isAtBottom,
  82. showScrollHint: shouldShowHint,
  83. scrollTop: element.scrollTop,
  84. scrollHeight: element.scrollHeight,
  85. clientHeight: element.clientHeight,
  86. });
  87. }
  88. }, [scrollThreshold]);
  89. const debouncedCheckScrollable = useMemo(
  90. () => debounce(checkScrollable, debounceDelay),
  91. [debounce, checkScrollable, debounceDelay],
  92. );
  93. const handleScroll = useCallback(
  94. (e) => {
  95. debouncedCheckScrollable();
  96. if (onScrollRef.current) {
  97. onScrollRef.current(e);
  98. }
  99. },
  100. [debouncedCheckScrollable],
  101. );
  102. useImperativeHandle(
  103. ref,
  104. () => ({
  105. checkScrollable: () => {
  106. checkScrollable();
  107. },
  108. scrollToTop: () => {
  109. if (scrollRef.current) {
  110. scrollRef.current.scrollTop = 0;
  111. }
  112. },
  113. scrollToBottom: () => {
  114. if (scrollRef.current) {
  115. scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  116. }
  117. },
  118. getScrollInfo: () => {
  119. if (!scrollRef.current) return null;
  120. const element = scrollRef.current;
  121. return {
  122. scrollTop: element.scrollTop,
  123. scrollHeight: element.scrollHeight,
  124. clientHeight: element.clientHeight,
  125. isScrollable: element.scrollHeight > element.clientHeight,
  126. isAtBottom:
  127. element.scrollTop + element.clientHeight >=
  128. element.scrollHeight - scrollThreshold,
  129. };
  130. },
  131. }),
  132. [checkScrollable, scrollThreshold],
  133. );
  134. useEffect(() => {
  135. const timer = setTimeout(() => {
  136. checkScrollable();
  137. }, checkInterval);
  138. return () => clearTimeout(timer);
  139. }, [checkScrollable, checkInterval]);
  140. useEffect(() => {
  141. if (!scrollRef.current) return;
  142. if (typeof ResizeObserver === 'undefined') {
  143. if (typeof MutationObserver !== 'undefined') {
  144. const observer = new MutationObserver(() => {
  145. debouncedCheckScrollable();
  146. });
  147. observer.observe(scrollRef.current, {
  148. childList: true,
  149. subtree: true,
  150. attributes: true,
  151. characterData: true,
  152. });
  153. return () => observer.disconnect();
  154. }
  155. return;
  156. }
  157. resizeObserverRef.current = new ResizeObserver((entries) => {
  158. for (const entry of entries) {
  159. debouncedCheckScrollable();
  160. }
  161. });
  162. resizeObserverRef.current.observe(scrollRef.current);
  163. return () => {
  164. if (resizeObserverRef.current) {
  165. resizeObserverRef.current.disconnect();
  166. }
  167. };
  168. }, [debouncedCheckScrollable]);
  169. useEffect(() => {
  170. return () => {
  171. if (debounceTimerRef.current) {
  172. clearTimeout(debounceTimerRef.current);
  173. }
  174. };
  175. }, []);
  176. const containerStyle = useMemo(
  177. () => ({
  178. maxHeight,
  179. }),
  180. [maxHeight],
  181. );
  182. const fadeIndicatorStyle = useMemo(
  183. () => ({
  184. opacity: showScrollHint ? 1 : 0,
  185. }),
  186. [showScrollHint],
  187. );
  188. return (
  189. <div
  190. ref={containerRef}
  191. className={`card-content-container ${className}`}
  192. {...props}
  193. >
  194. <div
  195. ref={scrollRef}
  196. className={`overflow-y-auto card-content-scroll ${contentClassName}`}
  197. style={containerStyle}
  198. onScroll={handleScroll}
  199. >
  200. {children}
  201. </div>
  202. <div
  203. className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
  204. style={fadeIndicatorStyle}
  205. />
  206. </div>
  207. );
  208. },
  209. );
  210. ScrollableContainer.displayName = 'ScrollableContainer';
  211. export default ScrollableContainer;