🔴 Bug修复 (6): - Sidebar反斜杠正则修复 (Windows路径规范化) - FileWatcher文件删除后自动恢复监听 - SearchReplace replaceAll防过期匹配位置 - openFolderDialog null结果守卫 - 保存异常时空路径保护 - Markdown绝对路径图片拼接修复 🟡 性能优化: - Editor切换标签时内容比较,相同跳过replaceAll 🔵 代码质量: - 创建共享常量模块 src/shared/constants.ts,消除双副本 🎯 新增功能: - 文档大纲 (Table of Contents) — 侧边栏标题导航 🔧 换行符统一: CRLF → LF
392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
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<HTMLInputElement>(null)
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Keep latest state in refs for use in callbacks without stale closures
|
|
const matchesRef = useRef<SearchMatch[]>([])
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div
|
|
ref={panelRef}
|
|
className="search-replace-panel"
|
|
role="search"
|
|
aria-label="搜索和替换"
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<div className="search-row">
|
|
<div className="search-input-group">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
className="search-input"
|
|
placeholder="搜索..."
|
|
value={query}
|
|
onChange={handleQueryChange}
|
|
aria-label="搜索文本"
|
|
/>
|
|
<span className="search-count" aria-live="polite">
|
|
{matchCount > 0 ? `${currentMatch + 1}/${matchCount}` : query ? '无匹配' : ''}
|
|
</span>
|
|
</div>
|
|
<button
|
|
className={`search-btn ${caseSensitive ? 'active' : ''}`}
|
|
onClick={toggleCaseSensitive}
|
|
title="区分大小写 (点击切换)"
|
|
aria-label="区分大小写"
|
|
aria-pressed={caseSensitive}
|
|
>
|
|
Aa
|
|
</button>
|
|
<button
|
|
className="search-btn"
|
|
onClick={() => navigateMatch(-1)}
|
|
title="上一个 (Shift+Enter)"
|
|
aria-label="上一个匹配"
|
|
disabled={matchCount === 0}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
className="search-btn"
|
|
onClick={() => navigateMatch(1)}
|
|
title="下一个 (Enter)"
|
|
aria-label="下一个匹配"
|
|
disabled={matchCount === 0}
|
|
>
|
|
↓
|
|
</button>
|
|
<button
|
|
className="search-btn"
|
|
onClick={() => setShowReplace(!showReplace)}
|
|
title="切换替换模式 (Ctrl+H)"
|
|
aria-label="替换模式"
|
|
aria-expanded={showReplace}
|
|
>
|
|
⇄
|
|
</button>
|
|
<button
|
|
className="search-btn search-close"
|
|
onClick={() => {
|
|
const view = getView()
|
|
if (view) clearSearchDecorations(view)
|
|
onClose()
|
|
}}
|
|
title="关闭 (Esc)"
|
|
aria-label="关闭搜索"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{showReplace && (
|
|
<div className="search-row">
|
|
<div className="search-input-group">
|
|
<input
|
|
type="text"
|
|
className="search-input"
|
|
placeholder="替换..."
|
|
value={replacement}
|
|
onChange={e => setReplacement(e.target.value)}
|
|
aria-label="替换文本"
|
|
/>
|
|
</div>
|
|
<button
|
|
className="search-btn replace-btn"
|
|
onClick={replaceCurrent}
|
|
title="替换当前"
|
|
aria-label="替换当前匹配"
|
|
disabled={currentMatch < 0}
|
|
>
|
|
替换
|
|
</button>
|
|
<button
|
|
className="search-btn replace-btn"
|
|
onClick={replaceAll}
|
|
title="全部替换"
|
|
aria-label="替换全部匹配"
|
|
disabled={matchCount === 0}
|
|
>
|
|
全部
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
SearchReplace.displayName = 'SearchReplace'
|