import React, { useState, useCallback, useRef, useEffect, memo } from 'react' import type { EditorView } from '@milkdown/prose/view' import { TextSelection } from '@milkdown/prose/state' import type { Node as ProseMirrorNode } from '@milkdown/prose/model' import { searchPluginKey, type SearchMatch, type SearchPluginState } from '../Editor/useMilkdown' interface SearchReplaceProps { /** 获取 ProseMirror EditorView 实例 */ getView: () => EditorView | null /** 关闭面板 */ onClose: () => void } /** * 在 ProseMirror 文档中查找所有匹配位置 */ function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): SearchMatch[] { const matches: SearchMatch[] = [] if (!query) return matches const normalizedQuery = caseSensitive ? query : query.toLowerCase() doc.descendants((node, pos) => { if (!node.isText) return true const text = node.text || '' const searchText = caseSensitive ? text : text.toLowerCase() let offset = 0 let idx: number while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) { matches.push({ from: pos + idx, to: pos + idx + query.length }) offset = idx + 1 } return true }) return matches } /** * 滚动到指定文档位置 */ function scrollToPos(view: EditorView, pos: number) { try { const { node } = view.domAtPos(pos) const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement) el?.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch { // Position may be out of range } } /** * 更新 ProseMirror plugin state(触发 decorations 重绘) */ function dispatchSearchState(view: EditorView, state: SearchPluginState) { const tr = view.state.tr.setMeta(searchPluginKey, state) view.dispatch(tr) } /** * 清除搜索高亮(dispatch 空状态) */ function clearSearchDecorations(view: EditorView) { dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' }) } /** * SearchReplace — 编辑器内搜索/替换面板 * * 功能: * - Ctrl+F 打开搜索,Ctrl+H 打开替换 * - 大小写敏感切换 * - Enter 下一个,Shift+Enter 上一个 * - 替换当前匹配 / 全部替换 * - ESC 关闭面板 */ export const SearchReplace = memo(function SearchReplace({ getView, onClose }: SearchReplaceProps) { const [query, setQuery] = useState('') const [replacement, setReplacement] = useState('') const [showReplace, setShowReplace] = useState(false) const [caseSensitive, setCaseSensitive] = useState(false) const [matchCount, setMatchCount] = useState(0) const [currentMatch, setCurrentMatch] = useState(-1) const searchInputRef = useRef(null) const panelRef = useRef(null) // Keep latest state in refs for use in callbacks without stale closures const matchesRef = useRef([]) const currentIndexRef = useRef(-1) const caseSensitiveRef = useRef(false) const queryRef = useRef('') // Sync refs caseSensitiveRef.current = caseSensitive queryRef.current = query // 执行搜索 const doSearch = useCallback((searchQuery: string, cs: boolean) => { const view = getView() if (!view) return if (!searchQuery.trim()) { clearSearchDecorations(view) matchesRef.current = [] currentIndexRef.current = -1 setMatchCount(0) setCurrentMatch(-1) return } const matches = findMatches(view.state.doc, searchQuery, cs) matchesRef.current = matches const newIdx = matches.length > 0 ? 0 : -1 currentIndexRef.current = newIdx setMatchCount(matches.length) setCurrentMatch(newIdx) // Dispatch to ProseMirror plugin to render decorations dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery }) // Scroll to first match if (matches.length > 0) { scrollToPos(view, matches[0].from) } }, [getView]) // 搜索框输入变化 const handleQueryChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value setQuery(value) doSearch(value, caseSensitiveRef.current) }, [doSearch]) // 大小写切换 const toggleCaseSensitive = useCallback(() => { const newCS = !caseSensitiveRef.current setCaseSensitive(newCS) doSearch(queryRef.current, newCS) }, [doSearch]) // 导航到下一个/上一个匹配 const navigateMatch = useCallback((direction: 1 | -1) => { const view = getView() if (!view) return const matches = matchesRef.current if (matches.length === 0) return let next = currentIndexRef.current + direction if (next >= matches.length) next = 0 if (next < 0) next = matches.length - 1 currentIndexRef.current = next setCurrentMatch(next) // Update plugin state to move the active highlight dispatchSearchState(view, { matches, currentIndex: next, query: queryRef.current }) // Scroll to match and select it const match = matches[next] try { const tr = view.state.tr.setSelection( TextSelection.create(view.state.doc, match.from, match.to) ) view.dispatch(tr) } catch { // Selection range might be invalid } scrollToPos(view, match.from) }, [getView]) // 替换当前匹配 const replaceCurrent = useCallback(() => { const view = getView() if (!view) return const matches = matchesRef.current const idx = currentIndexRef.current if (idx < 0 || idx >= matches.length) return const match = matches[idx] // Replace text in ProseMirror document const tr = view.state.tr.insertText(replacement, match.from, match.to) view.dispatch(tr) // Re-search after replacement (document changed) // Use requestAnimationFrame to let ProseMirror process the transaction requestAnimationFrame(() => { doSearch(queryRef.current, caseSensitiveRef.current) }) }, [getView, replacement, doSearch]) // 全部替换 const replaceAll = useCallback(() => { const view = getView() if (!view) return // 重新搜索以获取匹配的最新位置 const freshMatches = findMatches(view.state.doc, queryRef.current, caseSensitiveRef.current) if (freshMatches.length === 0) return // 从后往前替换以保持位置正确 const tr = view.state.tr for (let i = freshMatches.length - 1; i >= 0; i--) { tr.insertText(replacement, freshMatches[i].from, freshMatches[i].to) } view.dispatch(tr) // 更新ref matchesRef.current = [] // 替换后重新搜索 requestAnimationFrame(() => { doSearch(queryRef.current, caseSensitiveRef.current) }) }, [getView, replacement, doSearch]) // 键盘事件 const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() const view = getView() if (view) clearSearchDecorations(view) onClose() return } if (e.key === 'Enter') { e.preventDefault() if (e.shiftKey) { navigateMatch(-1) } else { navigateMatch(1) } } }, [onClose, navigateMatch, getView]) // 全局 Ctrl+F / Ctrl+H 拦截 useEffect(() => { const handleGlobalKey = (e: KeyboardEvent) => { const isCtrl = e.ctrlKey || e.metaKey if (isCtrl && e.key === 'f') { e.preventDefault() setShowReplace(false) searchInputRef.current?.focus() searchInputRef.current?.select() } if (isCtrl && e.key === 'h') { e.preventDefault() setShowReplace(true) searchInputRef.current?.focus() searchInputRef.current?.select() } } document.addEventListener('keydown', handleGlobalKey) return () => document.removeEventListener('keydown', handleGlobalKey) }, []) // 打开时聚焦搜索框 useEffect(() => { searchInputRef.current?.focus() searchInputRef.current?.select() }, []) // 清理高亮(组件卸载时) useEffect(() => { return () => { const view = getView() if (view) clearSearchDecorations(view) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return (
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
{showReplace && (
setReplacement(e.target.value)} aria-label="替换文本" />
)}
) }) SearchReplace.displayName = 'SearchReplace'