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:
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
*/
|
||||||
|
function scrollToPos(view: EditorView, pos: number) {
|
||||||
for (let j = positions.length - 1; j >= 0; j--) {
|
try {
|
||||||
const pos = positions[j]
|
const { node } = view.domAtPos(pos)
|
||||||
const afterText = text.substring(pos + query.length)
|
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
|
||||||
const matchText = text.substring(pos, pos + query.length)
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
const beforeText = text.substring(0, pos)
|
} catch {
|
||||||
|
// Position may be out of range
|
||||||
// 移除原始文本节点
|
|
||||||
if (j === positions.length - 1) {
|
|
||||||
parent.removeChild(textNode)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 插入后续文本
|
/**
|
||||||
if (afterText) {
|
* 更新 ProseMirror plugin state(触发 decorations 重绘)
|
||||||
parent.insertBefore(document.createTextNode(afterText), parent.children[0] || null)
|
*/
|
||||||
}
|
function dispatchSearchState(view: EditorView, state: SearchPluginState) {
|
||||||
|
const tr = view.state.tr.setMeta(searchPluginKey, state)
|
||||||
|
view.dispatch(tr)
|
||||||
|
}
|
||||||
|
|
||||||
// 插入高亮标记
|
/**
|
||||||
const mark = document.createElement('mark')
|
* 清除搜索高亮(dispatch 空状态)
|
||||||
mark.className = HIGHLIGHT_CLASS
|
*/
|
||||||
mark.textContent = matchText
|
function clearSearchDecorations(view: EditorView) {
|
||||||
mark.setAttribute('data-match-index', String(matchElements.length))
|
dispatchSearchState(view, { matches: [], currentIndex: -1, query: '' })
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user