diff --git a/src/components/providers/ProviderNav/ProviderNav.module.scss b/src/components/providers/ProviderNav/ProviderNav.module.scss index 8e8021c..310a857 100644 --- a/src/components/providers/ProviderNav/ProviderNav.module.scss +++ b/src/components/providers/ProviderNav/ProviderNav.module.scss @@ -10,6 +10,7 @@ } .navList { + position: relative; display: flex; flex-direction: column; gap: 8px; @@ -22,7 +23,33 @@ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); } +.indicator { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + opacity: 0; + border-radius: 10px; + background: rgba(59, 130, 246, 0.15); + box-shadow: inset 0 0 0 2px var(--primary-color); + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + width 220ms cubic-bezier(0.22, 1, 0.36, 1), + height 220ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 120ms ease; + will-change: transform, width, height; +} + +.indicatorVisible { + opacity: 1; +} + +.indicatorNoTransition { + transition: none; +} + .navItem { + position: relative; + z-index: 1; display: flex; align-items: center; justify-content: center; @@ -45,6 +72,13 @@ } } +.navItem.active { + &:hover { + background: transparent; + transform: none; + } +} + .icon { width: 28px; height: 28px; @@ -52,8 +86,9 @@ } .active { - background: rgba(59, 130, 246, 0.15); - box-shadow: inset 0 0 0 2px var(--primary-color); + // Active highlight is rendered by the sliding indicator. + background: transparent; + box-shadow: none; } // 暗色主题适配 @@ -70,7 +105,7 @@ } } - .active { + .indicator { background: rgba(59, 130, 246, 0.25); } } @@ -83,22 +118,29 @@ left: 50%; bottom: calc(12px + env(safe-area-inset-bottom)); transform: translateX(-50%); - width: min(520px, calc(100vw - 24px)); + width: fit-content; + max-width: calc(100vw - 24px); } .navList { + display: inline-flex; flex-direction: row; gap: 6px; padding: 8px 10px; border-radius: 999px; overflow-x: auto; scrollbar-width: none; + max-width: inherit; &::-webkit-scrollbar { display: none; } } + .indicator { + border-radius: 999px; + } + .navItem { width: 36px; height: 36px; @@ -111,3 +153,13 @@ height: 22px; } } + +@media (prefers-reduced-motion: reduce) { + .indicator { + transition: none; + } + + .navItem { + transition: background-color 0.2s ease; + } +} diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index c6d39f3..7c76ea1 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { useLocation } from 'react-router-dom'; import { useThemeStore } from '@/stores'; import iconGemini from '@/assets/icons/gemini.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg'; @@ -32,9 +33,36 @@ const HEADER_OFFSET = 24; type ScrollContainer = HTMLElement | (Window & typeof globalThis); export function ProviderNav() { + const location = useLocation(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [activeProvider, setActiveProvider] = useState(null); const contentScrollerRef = useRef(null); + const navListRef = useRef(null); + const itemRefs = useRef>({ + gemini: null, + codex: null, + claude: null, + vertex: null, + ampcode: null, + openai: null, + }); + const [indicatorRect, setIndicatorRect] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false); + const indicatorHasEnabledTransitionsRef = useRef(false); + + // Only show this quick-switch overlay on the AI Providers list page. + // Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`, + // so this component can stay mounted while the user is on an edit route. + const normalizedPathname = + location.pathname.length > 1 && location.pathname.endsWith('/') + ? location.pathname.slice(0, -1) + : location.pathname; + const shouldShow = normalizedPathname === '/ai-providers'; const getHeaderHeight = useCallback(() => { const header = document.querySelector('.main-header') as HTMLElement | null; @@ -96,6 +124,7 @@ export function ProviderNav() { }, [getHeaderHeight, getScrollContainer]); useEffect(() => { + if (!shouldShow) return; const contentScroller = getContentScroller(); // Listen to both: desktop scroll happens on `.content`; mobile uses `window`. @@ -108,7 +137,35 @@ export function ProviderNav() { window.removeEventListener('resize', handleScroll); contentScroller?.removeEventListener('scroll', handleScroll); }; - }, [getContentScroller, handleScroll]); + }, [getContentScroller, handleScroll, shouldShow]); + + const updateIndicator = useCallback((providerId: ProviderId | null) => { + if (!providerId) { + setIndicatorRect(null); + return; + } + + const itemEl = itemRefs.current[providerId]; + if (!itemEl) return; + + setIndicatorRect({ + x: itemEl.offsetLeft, + y: itemEl.offsetTop, + width: itemEl.offsetWidth, + height: itemEl.offsetHeight, + }); + + // Avoid animating from an initial (0,0) state on first paint. + if (!indicatorHasEnabledTransitionsRef.current) { + indicatorHasEnabledTransitionsRef.current = true; + requestAnimationFrame(() => setIndicatorTransitionsEnabled(true)); + } + }, []); + + useLayoutEffect(() => { + if (!shouldShow) return; + updateIndicator(activeProvider); + }, [activeProvider, shouldShow, updateIndicator]); const scrollToProvider = (providerId: ProviderId) => { const container = getScrollContainer(); @@ -116,6 +173,7 @@ export function ProviderNav() { if (!element || !container) return; setActiveProvider(providerId); + updateIndicator(providerId); // Mobile: scroll the document (header is fixed, so offset by header height). if (!(container instanceof HTMLElement)) { @@ -133,18 +191,50 @@ export function ProviderNav() { container.scrollTo({ top: scrollTop, behavior: 'smooth' }); }; + useEffect(() => { + if (!shouldShow) return; + const handleResize = () => updateIndicator(activeProvider); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [activeProvider, shouldShow, updateIndicator]); + const navContent = (
-
+
+
{PROVIDERS.map((provider) => { const isActive = activeProvider === provider.id; return (