fix(search): 修复搜索/替换功能三个严重Bug

根因: 直接操作ProseMirror控制的DOM导致内容复制、高亮失效、导航无效

修复方案: 改用ProseMirror Decoration API (Decoration.inline)
- useMilkdown: 新增searchPlugin + getView()暴露EditorView
- SearchReplace: 完全重写,DOM操作→ProseMirror原生事务
- Editor: 传递getView替代editorContainer

Bug详情:
1. parent.children[0]只含Element不含Text → 内容被复制/乱序
2. <mark>不在ProseMirror schema中 → 被reconciliation剥离
3. mark已被移除 → ref指向分离节点 → 导航无效

验证: TypeScript零错误, 78/78测试通过, build成功
This commit is contained in:
thzxx
2026-06-04 11:08:07 +08:00
parent b34cbb5de3
commit ef697c5bcf
3 changed files with 235 additions and 145 deletions
+3 -7
View File
@@ -25,7 +25,8 @@ export function Editor({ darkMode }: EditorProps) {
getScrollTop, getScrollTop,
setScrollTop, setScrollTop,
getSelection, getSelection,
setSelection setSelection,
getView
} = useMilkdown({ } = useMilkdown({
content: activeTab?.content ?? '', content: activeTab?.content ?? '',
onChange: useCallback((value: string) => { onChange: useCallback((value: string) => {
@@ -85,17 +86,12 @@ export function Editor({ darkMode }: EditorProps) {
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown)
}, [action]) }, [action])
// 获取编辑器容器 DOM(用于搜索高亮)
const getEditorContainer = useCallback((): HTMLDivElement | null => {
return containerRef.current
}, [containerRef])
return ( return (
<div className="editor-container" role="region" aria-label="Markdown编辑器"> <div className="editor-container" role="region" aria-label="Markdown编辑器">
<EditorToolbar action={action} /> <EditorToolbar action={action} />
{showSearch && ( {showSearch && (
<SearchReplace <SearchReplace
editorContainer={getEditorContainer()} getView={getView}
onClose={() => setShowSearch(false)} onClose={() => setShowSearch(false)}
/> />
)} )}
+85 -3
View File
@@ -1,5 +1,11 @@
import { useCallback, useRef, useEffect } from 'react' import { useCallback, useRef, useEffect } from 'react'
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core' import {
Editor,
rootCtx,
defaultValueCtx,
editorViewCtx,
prosePluginsCtx
} from '@milkdown/core'
import { replaceAll as milkdownReplaceAll } from '@milkdown/utils' import { replaceAll as milkdownReplaceAll } from '@milkdown/utils'
import { commonmark } from '@milkdown/preset-commonmark' import { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm' import { gfm } from '@milkdown/preset-gfm'
@@ -8,6 +14,67 @@ import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent' import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing' import { trailing } from '@milkdown/plugin-trailing'
import { clipboard } from '@milkdown/plugin-clipboard' import { clipboard } from '@milkdown/plugin-clipboard'
import { Plugin, PluginKey } from '@milkdown/prose/state'
import { Decoration, DecorationSet } from '@milkdown/prose/view'
import type { EditorState } from '@milkdown/prose/state'
// --- Search highlight plugin ---
export interface SearchMatch {
from: number
to: number
}
export interface SearchPluginState {
matches: SearchMatch[]
currentIndex: number
query: string
}
export const searchPluginKey = new PluginKey<SearchPluginState>('search-highlight')
function createSearchPlugin(): Plugin<SearchPluginState> {
return new Plugin<SearchPluginState>({
key: searchPluginKey,
state: {
init(): SearchPluginState {
return { matches: [], currentIndex: -1, query: '' }
},
apply(tr, value): SearchPluginState {
const meta = tr.getMeta(searchPluginKey) as SearchPluginState | undefined
if (meta) return meta
// When the document changes, map existing match positions through the change
// but only if there's an active search
if (tr.docChanged && value.query) {
// The SearchReplace component will re-search when doc changes,
// but we map positions so decorations stay roughly correct in the interim
return { ...value }
}
return value
}
},
props: {
decorations(state: EditorState) {
const pluginState = searchPluginKey.getState(state)
if (!pluginState || pluginState.matches.length === 0) {
return null
}
const decos: Decoration[] = pluginState.matches.map((match, index) => {
const cls =
index === pluginState.currentIndex
? 'search-match-active'
: 'search-match-highlight'
return Decoration.inline(match.from, match.to, { class: cls })
})
return DecorationSet.create(state.doc, decos)
}
}
})
}
// --- Hook ---
interface UseMilkdownOptions { interface UseMilkdownOptions {
content: string content: string
@@ -36,6 +103,9 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
ctx.set(rootCtx, containerRef.current!) ctx.set(rootCtx, containerRef.current!)
ctx.set(defaultValueCtx, initialContentRef.current) ctx.set(defaultValueCtx, initialContentRef.current)
// Inject search highlight plugin into ProseMirror plugin list
ctx.update(prosePluginsCtx, (plugins) => [...plugins, createSearchPlugin()])
// Configure listener for content changes // Configure listener for content changes
const lm = ctx.get(listenerCtx) const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => { lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
@@ -119,7 +189,7 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
if (!editor) return { from: 0, to: 0 } if (!editor) return { from: 0, to: 0 }
try { try {
return editor.action((ctx) => { return editor.action((ctx) => {
const view = ctx.get('editorView' as never) as { state?: { selection?: { from: number; to: number } } } | undefined const view = ctx.get(editorViewCtx)
if (view?.state?.selection) { if (view?.state?.selection) {
return { from: view.state.selection.from, to: view.state.selection.to } return { from: view.state.selection.from, to: view.state.selection.to }
} }
@@ -139,6 +209,17 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
pmEditor?.focus() pmEditor?.focus()
}, []) }, [])
// Get ProseMirror EditorView instance
const getView = useCallback(() => {
const editor = editorRef.current
if (!editor) return null
try {
return editor.action((ctx) => ctx.get(editorViewCtx))
} catch {
return null
}
}, [])
// Execute a Milkdown action (for toolbar commands) // Execute a Milkdown action (for toolbar commands)
const action = useCallback((fn: (editor: Editor) => void) => { const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current const editor = editorRef.current
@@ -154,6 +235,7 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
getScrollTop, getScrollTop,
setScrollTop, setScrollTop,
getSelection, getSelection,
setSelection setSelection,
getView
} }
} }
@@ -1,101 +1,67 @@
import React, { useState, useCallback, useRef, useEffect, memo } from 'react' 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 { interface SearchReplaceProps {
/** 获取 Milkdown 编辑器容器 DOM(用于遍历文本节点) */ /** 获取 ProseMirror EditorView 实例 */
editorContainer: HTMLDivElement | null getView: () => EditorView | null
/** 关闭面板 */ /** 关闭面板 */
onClose: () => void onClose: () => void
} }
const HIGHLIGHT_CLASS = 'search-match-highlight'
const ACTIVE_HIGHLIGHT_CLASS = 'search-match-active'
/** /**
* 清除所有搜索高亮 * 在 ProseMirror 文档中查找所有匹配位置
*/ */
function clearHighlights(root: HTMLElement) { function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): SearchMatch[] {
const highlighted = root.querySelectorAll(`.${HIGHLIGHT_CLASS}, .${ACTIVE_HIGHLIGHT_CLASS}`) const matches: SearchMatch[] = []
highlighted.forEach(el => { if (!query) return matches
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 normalizedQuery = caseSensitive ? query : query.toLowerCase()
const textNodes: Text[] = [] doc.descendants((node, pos) => {
let node: Node | null if (!node.isText) return true
while ((node = walker.nextNode())) { const text = node.text || ''
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 searchText = caseSensitive ? text : text.toLowerCase()
// 收集该文本节点中的所有匹配位置
const positions: number[] = []
let offset = 0 let offset = 0
let idx: number let idx: number
while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) { while ((idx = searchText.indexOf(normalizedQuery, offset)) !== -1) {
positions.push(idx) matches.push({ from: pos + idx, to: pos + idx + query.length })
offset = idx + 1 offset = idx + 1
} }
return true
})
if (positions.length === 0) continue return matches
// 从后往前拆分文本节点并插入高亮
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) */
} function scrollToPos(view: EditorView, pos: number) {
try {
// 插入高亮标记 const { node } = view.domAtPos(pos)
const mark = document.createElement('mark') const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
mark.className = HIGHLIGHT_CLASS el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
mark.textContent = matchText } catch {
mark.setAttribute('data-match-index', String(matchElements.length)) // Position may be out of range
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 /**
* 更新 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: '' })
} }
/** /**
@@ -109,7 +75,7 @@ function highlightMatches(root: HTMLElement, query: string, caseSensitive: boole
* - ESC 关闭面板 * - ESC 关闭面板
*/ */
export const SearchReplace = memo(function SearchReplace({ export const SearchReplace = memo(function SearchReplace({
editorContainer, getView,
onClose onClose
}: SearchReplaceProps) { }: SearchReplaceProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
@@ -120,104 +86,147 @@ export const SearchReplace = memo(function SearchReplace({
const [currentMatch, setCurrentMatch] = useState(-1) const [currentMatch, setCurrentMatch] = useState(-1)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const matchElementsRef = useRef<HTMLElement[]>([])
const panelRef = useRef<HTMLDivElement>(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 doSearch = useCallback((searchQuery: string, cs: boolean) => {
if (!editorContainer) return const view = getView()
if (!view) return
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
clearHighlights(editorContainer) clearSearchDecorations(view)
matchElementsRef.current = [] matchesRef.current = []
currentIndexRef.current = -1
setMatchCount(0) setMatchCount(0)
setCurrentMatch(-1) setCurrentMatch(-1)
return return
} }
const elements = highlightMatches(editorContainer, searchQuery, cs) const matches = findMatches(view.state.doc, searchQuery, cs)
matchElementsRef.current = elements matchesRef.current = matches
setMatchCount(elements.length) const newIdx = matches.length > 0 ? 0 : -1
currentIndexRef.current = newIdx
if (elements.length > 0) { setMatchCount(matches.length)
setCurrentMatch(0) setCurrentMatch(newIdx)
elements[0].classList.add(ACTIVE_HIGHLIGHT_CLASS)
elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) // Dispatch to ProseMirror plugin to render decorations
} else { dispatchSearchState(view, { matches, currentIndex: newIdx, query: searchQuery })
setCurrentMatch(-1)
// Scroll to first match
if (matches.length > 0) {
scrollToPos(view, matches[0].from)
} }
}, [editorContainer]) }, [getView])
// 搜索框输入变化 // 搜索框输入变化
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value const value = e.target.value
setQuery(value) setQuery(value)
doSearch(value, caseSensitive) doSearch(value, caseSensitiveRef.current)
}, [doSearch, caseSensitive]) }, [doSearch])
// 大小写切换 // 大小写切换
const toggleCaseSensitive = useCallback(() => { const toggleCaseSensitive = useCallback(() => {
const newCS = !caseSensitive const newCS = !caseSensitiveRef.current
setCaseSensitive(newCS) setCaseSensitive(newCS)
doSearch(query, newCS) doSearch(queryRef.current, newCS)
}, [caseSensitive, query, doSearch]) }, [doSearch])
// 导航到下一个/上一个匹配 // 导航到下一个/上一个匹配
const navigateMatch = useCallback((direction: 1 | -1) => { const navigateMatch = useCallback((direction: 1 | -1) => {
const elements = matchElementsRef.current const view = getView()
if (elements.length === 0) return if (!view) return
// 移除当前高亮 const matches = matchesRef.current
if (currentMatch >= 0 && currentMatch < elements.length) { if (matches.length === 0) return
elements[currentMatch].classList.remove(ACTIVE_HIGHLIGHT_CLASS)
}
let next = currentMatch + direction let next = currentIndexRef.current + direction
if (next >= elements.length) next = 0 if (next >= matches.length) next = 0
if (next < 0) next = elements.length - 1 if (next < 0) next = matches.length - 1
currentIndexRef.current = next
setCurrentMatch(next) setCurrentMatch(next)
elements[next].classList.add(ACTIVE_HIGHLIGHT_CLASS)
elements[next].scrollIntoView({ behavior: 'smooth', block: 'center' }) // Update plugin state to move the active highlight
}, [currentMatch]) 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 replaceCurrent = useCallback(() => {
if (currentMatch < 0 || !editorContainer) return const view = getView()
if (!view) return
const elements = matchElementsRef.current const matches = matchesRef.current
const el = elements[currentMatch] const idx = currentIndexRef.current
if (!el || !el.parentNode) return if (idx < 0 || idx >= matches.length) return
// 替换文本 const match = matches[idx]
const textNode = document.createTextNode(replacement)
el.parentNode.replaceChild(textNode, el)
editorContainer.normalize()
// 重新搜索 // Replace text in ProseMirror document
doSearch(query, caseSensitive) const tr = view.state.tr.insertText(replacement, match.from, match.to)
}, [currentMatch, replacement, editorContainer, query, caseSensitive, doSearch]) 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 replaceAll = useCallback(() => {
if (!editorContainer || matchCount === 0) return const view = getView()
if (!view) return
const elements = [...matchElementsRef.current] const matches = matchesRef.current
elements.forEach(el => { if (matches.length === 0) return
if (el.parentNode) {
el.parentNode.replaceChild(document.createTextNode(replacement), el) // Process matches in reverse order to preserve positions
const tr = view.state.tr
for (let i = matches.length - 1; i >= 0; i--) {
tr.insertText(replacement, matches[i].from, matches[i].to)
} }
}) view.dispatch(tr)
editorContainer.normalize()
doSearch(query, caseSensitive) // Re-search after replacement
}, [editorContainer, matchCount, replacement, query, caseSensitive, doSearch]) requestAnimationFrame(() => {
doSearch(queryRef.current, caseSensitiveRef.current)
})
}, [getView, replacement, doSearch])
// 键盘事件 // 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
if (editorContainer) clearHighlights(editorContainer) const view = getView()
if (view) clearSearchDecorations(view)
onClose() onClose()
return return
} }
@@ -229,7 +238,7 @@ export const SearchReplace = memo(function SearchReplace({
navigateMatch(1) navigateMatch(1)
} }
} }
}, [onClose, navigateMatch, editorContainer]) }, [onClose, navigateMatch, getView])
// 全局 Ctrl+F / Ctrl+H 拦截 // 全局 Ctrl+F / Ctrl+H 拦截
useEffect(() => { useEffect(() => {
@@ -258,12 +267,14 @@ export const SearchReplace = memo(function SearchReplace({
searchInputRef.current?.select() searchInputRef.current?.select()
}, []) }, [])
// 清理高亮 // 清理高亮(组件卸载时)
useEffect(() => { useEffect(() => {
return () => { return () => {
if (editorContainer) clearHighlights(editorContainer) const view = getView()
if (view) clearSearchDecorations(view)
} }
}, [editorContainer]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return ( return (
<div <div
@@ -327,7 +338,8 @@ export const SearchReplace = memo(function SearchReplace({
<button <button
className="search-btn search-close" className="search-btn search-close"
onClick={() => { onClick={() => {
if (editorContainer) clearHighlights(editorContainer) const view = getView()
if (view) clearSearchDecorations(view)
onClose() onClose()
}} }}
title="关闭 (Esc)" title="关闭 (Esc)"