mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-29 08:28:38 +00:00
♻️ refactor(web): migrate React modules from .js to .jsx and align entrypoint
- Rename React components/pages/utilities that contain JSX to `.jsx` across `web/src` - Update import paths and re-exports to match new `.jsx` extensions - Fix Vite entry by switching `web/index.html` from `/src/index.js` to `/src/index.jsx` - Verified remaining `.js` files are plain JS (hooks/helpers/constants) and do not require JSX - No runtime behavior changes; extension and reference alignment only Context: Resolves the Vite pre-transform error caused by the stale `/src/index.js` entry after migrating to `.jsx`.
This commit is contained in:
220
web/src/components/common/ui/ScrollableContainer.jsx
Normal file
220
web/src/components/common/ui/ScrollableContainer.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
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 = 'p-2',
|
||||
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;
|
||||
Reference in New Issue
Block a user