mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 18:50:49 +08:00
Merge branch 'router-for-me:main' into main
This commit is contained in:
@@ -29,6 +29,18 @@
|
||||
|
||||
&--stacked {
|
||||
display: none;
|
||||
|
||||
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
||||
// Older stacked layers remain `display: none` for performance.
|
||||
&.page-transition__layer--stacked-keep {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +48,7 @@
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--animating &__layer:not(.page-transition__layer--exit) {
|
||||
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,20 +83,28 @@ export function PageTransition({
|
||||
};
|
||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||
const toIndex = resolveOrderIndex(location.pathname);
|
||||
const nextDirection: TransitionDirection =
|
||||
const nextVariant: TransitionVariant = getTransitionVariant
|
||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||
: 'vertical';
|
||||
|
||||
let nextDirection: TransitionDirection =
|
||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||
? 'forward'
|
||||
: toIndex > fromIndex
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
||||
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
||||
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
||||
nextDirection = 'backward';
|
||||
}
|
||||
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
transitionVariantRef.current = getTransitionVariant
|
||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||
: 'vertical';
|
||||
transitionVariantRef.current = nextVariant;
|
||||
|
||||
const shouldSkipExitLayer = (() => {
|
||||
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
|
||||
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||
const normalizeSegments = (pathname: string) =>
|
||||
pathname
|
||||
.split('/')
|
||||
@@ -178,6 +186,7 @@ export function PageTransition({
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
resolveScrollContainer,
|
||||
layers,
|
||||
]);
|
||||
|
||||
// Run GSAP animation when animating starts
|
||||
@@ -322,16 +331,30 @@ export function PageTransition({
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
{layers.map((layer) => (
|
||||
{(() => {
|
||||
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
||||
const keepStackedIndex = layers
|
||||
.slice(0, resolvedCurrentIndex)
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.reverse()
|
||||
.find(({ layer }) => layer.status === 'stacked')?.index;
|
||||
|
||||
return layers.map((layer, index) => {
|
||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||
return (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
@@ -342,7 +365,9 @@ export function PageTransition({
|
||||
>
|
||||
{render(layer.location)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,9 +75,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕隐藏悬浮导航
|
||||
// 小屏幕改为底部横向浮层
|
||||
@media (max-width: 1200px) {
|
||||
.navContainer {
|
||||
display: none;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: 50%;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.navList {
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,25 +29,47 @@ const PROVIDERS: ProviderNavItem[] = [
|
||||
];
|
||||
|
||||
const HEADER_OFFSET = 24;
|
||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||
|
||||
export function ProviderNav() {
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const getHeaderHeight = useCallback(() => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
if (header) return header.getBoundingClientRect().height;
|
||||
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
||||
const value = Number.parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}, []);
|
||||
|
||||
const getContentScroller = useCallback(() => {
|
||||
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
||||
return contentScrollerRef.current;
|
||||
}
|
||||
|
||||
const getScrollContainer = useCallback(() => {
|
||||
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||
const container = document.querySelector('.content') as HTMLElement | null;
|
||||
scrollContainerRef.current = container;
|
||||
contentScrollerRef.current = container;
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile) return window;
|
||||
return getContentScroller() ?? window;
|
||||
}, [getContentScroller]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const activationLine = containerRect.top + HEADER_OFFSET + 1;
|
||||
const isElementScroller = container instanceof HTMLElement;
|
||||
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
||||
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
||||
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
|
||||
let currentActive: ProviderId | null = null;
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
@@ -71,31 +93,44 @@ export function ProviderNav() {
|
||||
}
|
||||
|
||||
setActiveProvider(currentActive);
|
||||
}, [getScrollContainer]);
|
||||
}, [getHeaderHeight, getScrollContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
const contentScroller = getContentScroller();
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
handleScroll();
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll, getScrollContainer]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [getContentScroller, handleScroll]);
|
||||
|
||||
const scrollToProvider = (providerId: ProviderId) => {
|
||||
const container = getScrollContainer();
|
||||
const element = document.getElementById(`provider-${providerId}`);
|
||||
if (!element || !container) return;
|
||||
|
||||
setActiveProvider(providerId);
|
||||
|
||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
const headerHeight = getHeaderHeight();
|
||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||
|
||||
setActiveProvider(providerId);
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const navContent = (
|
||||
|
||||
Reference in New Issue
Block a user