133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
// 源码模式的文档内搜索 —— 从旧 pages/ConfigPage.tsx 逐字提取。
|
|
// 手写 indexOf 扫描 + 光标定位(大小写不敏感、回绕),不依赖 @codemirror/search 面板。
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|
|
|
export function useSourceSearch() {
|
|
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({
|
|
current: 0,
|
|
total: 0,
|
|
});
|
|
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
|
|
|
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
|
|
if (!query || !editorRef.current?.view) return;
|
|
|
|
const view = editorRef.current.view;
|
|
const doc = view.state.doc.toString();
|
|
const matches: number[] = [];
|
|
const lowerQuery = query.toLowerCase();
|
|
const lowerDoc = doc.toLowerCase();
|
|
|
|
let pos = 0;
|
|
while (pos < lowerDoc.length) {
|
|
const index = lowerDoc.indexOf(lowerQuery, pos);
|
|
if (index === -1) break;
|
|
matches.push(index);
|
|
pos = index + 1;
|
|
}
|
|
|
|
if (matches.length === 0) {
|
|
setSearchResults({ current: 0, total: 0 });
|
|
return;
|
|
}
|
|
|
|
// Find current match based on cursor position
|
|
const selection = view.state.selection.main;
|
|
const cursorPos = direction === 'prev' ? selection.from : selection.to;
|
|
let currentIndex = 0;
|
|
|
|
if (direction === 'next') {
|
|
// Find next match after cursor
|
|
for (let i = 0; i < matches.length; i++) {
|
|
if (matches[i] > cursorPos) {
|
|
currentIndex = i;
|
|
break;
|
|
}
|
|
// If no match after cursor, wrap to first
|
|
if (i === matches.length - 1) {
|
|
currentIndex = 0;
|
|
}
|
|
}
|
|
} else {
|
|
// Find previous match before cursor
|
|
for (let i = matches.length - 1; i >= 0; i--) {
|
|
if (matches[i] < cursorPos) {
|
|
currentIndex = i;
|
|
break;
|
|
}
|
|
// If no match before cursor, wrap to last
|
|
if (i === 0) {
|
|
currentIndex = matches.length - 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const matchPos = matches[currentIndex];
|
|
setSearchResults({ current: currentIndex + 1, total: matches.length });
|
|
|
|
// Scroll to and select the match
|
|
view.dispatch({
|
|
selection: { anchor: matchPos, head: matchPos + query.length },
|
|
scrollIntoView: true,
|
|
});
|
|
view.focus();
|
|
}, []);
|
|
|
|
const handleSearchChange = useCallback((value: string) => {
|
|
setSearchQuery(value);
|
|
// Do not auto-search on each keystroke. Clear previous results when query changes.
|
|
if (!value) {
|
|
setSearchResults({ current: 0, total: 0 });
|
|
setLastSearchedQuery('');
|
|
} else {
|
|
setSearchResults({ current: 0, total: 0 });
|
|
}
|
|
}, []);
|
|
|
|
const executeSearch = useCallback(
|
|
(direction: 'next' | 'prev' = 'next') => {
|
|
if (!searchQuery) return;
|
|
setLastSearchedQuery(searchQuery);
|
|
performSearch(searchQuery, direction);
|
|
},
|
|
[searchQuery, performSearch]
|
|
);
|
|
|
|
const handleSearchKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
executeSearch(e.shiftKey ? 'prev' : 'next');
|
|
}
|
|
},
|
|
[executeSearch]
|
|
);
|
|
|
|
const handlePrevMatch = useCallback(() => {
|
|
if (!lastSearchedQuery) return;
|
|
performSearch(lastSearchedQuery, 'prev');
|
|
}, [lastSearchedQuery, performSearch]);
|
|
|
|
const handleNextMatch = useCallback(() => {
|
|
if (!lastSearchedQuery) return;
|
|
performSearch(lastSearchedQuery, 'next');
|
|
}, [lastSearchedQuery, performSearch]);
|
|
|
|
return {
|
|
editorRef,
|
|
searchQuery,
|
|
searchResults,
|
|
lastSearchedQuery,
|
|
handleSearchChange,
|
|
executeSearch,
|
|
handleSearchKeyDown,
|
|
handlePrevMatch,
|
|
handleNextMatch,
|
|
};
|
|
}
|
|
|
|
export type UseSourceSearchResult = ReturnType<typeof useSourceSearch>;
|