v0.3.1: 添加编辑器搜索/替换功能,修复ESLint和lint-staged错误

- 新增 SearchReplace 组件:Ctrl+F 搜索、Ctrl+H 替换、实时高亮、大小写切换、导航、全部替换
- 修复 tabStore.test.ts 未使用变量的 ESLint 错误
- 修复 lint-staged tsc 命令不兼容 Milkdown .d.ts 的问题
- 版本号 0.3.0 → 0.3.1 (package.json + AboutDialog)
- tsc 零错误、78/78 测试通过、构建成功
This commit is contained in:
thzxx
2026-06-04 10:22:15 +08:00
parent b1803c6467
commit b34cbb5de3
7 changed files with 642 additions and 46 deletions
@@ -0,0 +1,375 @@
import React, { useState, useCallback, useRef, useEffect, memo } from 'react'
interface SearchReplaceProps {
/** 获取 Milkdown 编辑器容器 DOM(用于遍历文本节点) */
editorContainer: HTMLDivElement | null
/** 关闭面板 */
onClose: () => void
}
const HIGHLIGHT_CLASS = 'search-match-highlight'
const ACTIVE_HIGHLIGHT_CLASS = 'search-match-active'
/**
* 清除所有搜索高亮
*/
function clearHighlights(root: HTMLElement) {
const highlighted = root.querySelectorAll(`.${HIGHLIGHT_CLASS}, .${ACTIVE_HIGHLIGHT_CLASS}`)
highlighted.forEach(el => {
const parent = el.parentNode
if (!parent) return
while (el.firstChild) parent.insertBefore(el.firstChild, el)
parent.removeChild(el)
})
// 合并相邻文本节点
root.normalize()
}
/**
* 高亮所有匹配项
* 返回高亮后的元素列表(用于导航)
*/
function highlightMatches(root: HTMLElement, query: string, caseSensitive: boolean): HTMLElement[] {
clearHighlights(root)
if (!query) return []
const matchElements: HTMLElement[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
const normalizedQuery = caseSensitive ? query : query.toLowerCase()
const textNodes: Text[] = []
let node: Node | null
while ((node = walker.nextNode())) {
textNodes.push(node as Text)
}
// 从后往前处理,避免偏移量变化
for (let i = textNodes.length - 1; i >= 0; i--) {
const textNode = textNodes[i]
const text = textNode.textContent || ''
const searchText = caseSensitive ? text : text.toLowerCase()
// 收集该文本节点中的所有匹配位置
const positions: number[] = []
let offset = 0
let idx: number
while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
positions.push(idx)
offset = idx + 1
}
if (positions.length === 0) continue
// 从后往前拆分文本节点并插入高亮
const parent = textNode.parentNode
if (!parent) continue
for (let j = positions.length - 1; j >= 0; j--) {
const pos = positions[j]
const afterText = text.substring(pos + query.length)
const matchText = text.substring(pos, pos + query.length)
const beforeText = text.substring(0, pos)
// 移除原始文本节点
if (j === positions.length - 1) {
parent.removeChild(textNode)
}
// 插入后续文本
if (afterText) {
parent.insertBefore(document.createTextNode(afterText), parent.children[0] || null)
}
// 插入高亮标记
const mark = document.createElement('mark')
mark.className = HIGHLIGHT_CLASS
mark.textContent = matchText
mark.setAttribute('data-match-index', String(matchElements.length))
parent.insertBefore(mark, parent.children[0] || null)
matchElements.unshift(mark)
// 插入前面文本(仅第一个匹配时)
if (j === 0 && beforeText) {
parent.insertBefore(document.createTextNode(beforeText), parent.children[0] || null)
}
}
}
return matchElements
}
/**
* SearchReplace — 编辑器内搜索/替换面板
*
* 功能:
* - Ctrl+F 打开搜索,Ctrl+H 打开替换
* - 大小写敏感切换
* - Enter 下一个,Shift+Enter 上一个
* - 替换当前匹配 / 全部替换
* - ESC 关闭面板
*/
export const SearchReplace = memo(function SearchReplace({
editorContainer,
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 matchElementsRef = useRef<HTMLElement[]>([])
const panelRef = useRef<HTMLDivElement>(null)
// 执行搜索
const doSearch = useCallback((searchQuery: string, cs: boolean) => {
if (!editorContainer) return
if (!searchQuery.trim()) {
clearHighlights(editorContainer)
matchElementsRef.current = []
setMatchCount(0)
setCurrentMatch(-1)
return
}
const elements = highlightMatches(editorContainer, searchQuery, cs)
matchElementsRef.current = elements
setMatchCount(elements.length)
if (elements.length > 0) {
setCurrentMatch(0)
elements[0].classList.add(ACTIVE_HIGHLIGHT_CLASS)
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
} else {
setCurrentMatch(-1)
}
}, [editorContainer])
// 搜索框输入变化
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
doSearch(value, caseSensitive)
}, [doSearch, caseSensitive])
// 大小写切换
const toggleCaseSensitive = useCallback(() => {
const newCS = !caseSensitive
setCaseSensitive(newCS)
doSearch(query, newCS)
}, [caseSensitive, query, doSearch])
// 导航到下一个/上一个匹配
const navigateMatch = useCallback((direction: 1 | -1) => {
const elements = matchElementsRef.current
if (elements.length === 0) return
// 移除当前高亮
if (currentMatch >= 0 && currentMatch < elements.length) {
elements[currentMatch].classList.remove(ACTIVE_HIGHLIGHT_CLASS)
}
let next = currentMatch + direction
if (next >= elements.length) next = 0
if (next < 0) next = elements.length - 1
setCurrentMatch(next)
elements[next].classList.add(ACTIVE_HIGHLIGHT_CLASS)
elements[next].scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [currentMatch])
// 替换当前匹配
const replaceCurrent = useCallback(() => {
if (currentMatch < 0 || !editorContainer) return
const elements = matchElementsRef.current
const el = elements[currentMatch]
if (!el || !el.parentNode) return
// 替换文本
const textNode = document.createTextNode(replacement)
el.parentNode.replaceChild(textNode, el)
editorContainer.normalize()
// 重新搜索
doSearch(query, caseSensitive)
}, [currentMatch, replacement, editorContainer, query, caseSensitive, doSearch])
// 全部替换
const replaceAll = useCallback(() => {
if (!editorContainer || matchCount === 0) return
const elements = [...matchElementsRef.current]
elements.forEach(el => {
if (el.parentNode) {
el.parentNode.replaceChild(document.createTextNode(replacement), el)
}
})
editorContainer.normalize()
doSearch(query, caseSensitive)
}, [editorContainer, matchCount, replacement, query, caseSensitive, doSearch])
// 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
if (editorContainer) clearHighlights(editorContainer)
onClose()
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
navigateMatch(-1)
} else {
navigateMatch(1)
}
}
}, [onClose, navigateMatch, editorContainer])
// 全局 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 () => {
if (editorContainer) clearHighlights(editorContainer)
}
}, [editorContainer])
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={() => {
if (editorContainer) clearHighlights(editorContainer)
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'