d0825c5ec0
对仅含静态类名或单一三元表达式的 className 改用普通字符串,保留有条件合并或 prop 覆盖场景下的 cn() 用法。 Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type RefObject,
|
|
} from 'react';
|
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
import { useRouter } from '@/providers/RouterProvider';
|
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
|
import { type FeatureConfig, FEATURES } from '@/config/features';
|
|
import { useStorageState } from '@/utils/useStorageState';
|
|
import { openExtensionPage } from '@/utils/chromeTabs';
|
|
import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants';
|
|
|
|
export interface UseTopBarReturn {
|
|
searchQuery: string;
|
|
searchResults: FeatureConfig[];
|
|
recentFeatures: FeatureConfig[];
|
|
selectedIndex: number;
|
|
isDashboard: boolean;
|
|
ThemeIcon: typeof Sun;
|
|
themeTitle: string;
|
|
showDropdown: boolean;
|
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
inputRef: RefObject<HTMLInputElement | null>;
|
|
handleSearchQueryChange: (value: string) => void;
|
|
handleSearchFocus: () => void;
|
|
handleSelectFeature: (feature: FeatureConfig) => void;
|
|
handleKeyDown: (e: ReactKeyboardEvent) => void;
|
|
cycleThemeMode: () => void;
|
|
handleOpenInTab: () => Promise<void>;
|
|
goHome: () => void;
|
|
clearSearch: () => void;
|
|
}
|
|
|
|
export function useTopBar(): UseTopBarReturn {
|
|
const { currentPage, goHome, navigateTo } = useRouter();
|
|
const { mode, setMode } = useThemeMode();
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [searchHistory, setSearchHistory] = useStorageState(
|
|
'app/searchHistory',
|
|
[],
|
|
isSearchHistory,
|
|
);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleOpenInTab = async () => {
|
|
await openExtensionPage('popup.html', { mode: 'tab' });
|
|
window.close();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setShowResults(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
setShowResults(true);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
|
}, []);
|
|
|
|
const searchResults = useMemo(() => {
|
|
const query = searchQuery.trim().toLowerCase();
|
|
if (!query) return [];
|
|
return FEATURES.filter((f) => {
|
|
if (f.key === 'dashboard') return false;
|
|
return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
|
|
});
|
|
}, [searchQuery]);
|
|
|
|
const recentFeatures = useMemo(() => {
|
|
if (searchQuery.trim()) return [];
|
|
return searchHistory
|
|
.slice(0, SEARCH_HISTORY_DISPLAY)
|
|
.map((key) => FEATURES.find((f) => f.key === key))
|
|
.filter((feature): feature is FeatureConfig => !!feature && feature.key !== 'dashboard');
|
|
}, [searchHistory, searchQuery]);
|
|
|
|
const saveToHistory = (featureKey: string) => {
|
|
setSearchHistory((prev) =>
|
|
[featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT),
|
|
);
|
|
};
|
|
|
|
const handleSelectFeature = (feature: FeatureConfig) => {
|
|
navigateTo(feature.key);
|
|
saveToHistory(feature.key);
|
|
setSearchQuery('');
|
|
setShowResults(false);
|
|
};
|
|
|
|
const cycleThemeMode = () => {
|
|
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
|
setMode(nextMap[mode]);
|
|
};
|
|
|
|
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
|
|
|
const themeTitle =
|
|
mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式';
|
|
|
|
const handleKeyDown = (e: ReactKeyboardEvent) => {
|
|
const isSearching = !!searchQuery.trim();
|
|
const items = isSearching ? searchResults : recentFeatures;
|
|
const totalItems = items.length;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
let feature: FeatureConfig | undefined;
|
|
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
|
feature = items[selectedIndex];
|
|
} else if (isSearching && searchResults.length > 0) {
|
|
feature = searchResults[0];
|
|
}
|
|
if (feature) handleSelectFeature(feature);
|
|
} else if (e.key === 'Escape') {
|
|
setShowResults(false);
|
|
inputRef.current?.blur();
|
|
}
|
|
};
|
|
|
|
const clearSearch = () => {
|
|
setSearchQuery('');
|
|
setSelectedIndex(-1);
|
|
};
|
|
|
|
const handleSearchQueryChange = (value: string) => {
|
|
setSearchQuery(value);
|
|
setShowResults(true);
|
|
setSelectedIndex(-1);
|
|
};
|
|
|
|
const handleSearchFocus = () => setShowResults(true);
|
|
|
|
const showDropdown = showResults && (!!searchQuery.trim() || recentFeatures.length > 0);
|
|
|
|
return {
|
|
searchQuery,
|
|
searchResults,
|
|
recentFeatures,
|
|
selectedIndex,
|
|
showDropdown,
|
|
isDashboard: currentPage === 'dashboard',
|
|
ThemeIcon,
|
|
themeTitle,
|
|
containerRef,
|
|
inputRef,
|
|
handleSearchQueryChange,
|
|
handleSearchFocus,
|
|
handleSelectFeature,
|
|
handleKeyDown,
|
|
cycleThemeMode,
|
|
handleOpenInTab,
|
|
goHome,
|
|
clearSearch,
|
|
};
|
|
}
|