diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx
index bd59e12..1c2621d 100644
--- a/src/renderer/components/Editor/Editor.tsx
+++ b/src/renderer/components/Editor/Editor.tsx
@@ -25,7 +25,8 @@ export function Editor({ darkMode }: EditorProps) {
getScrollTop,
setScrollTop,
getSelection,
- setSelection
+ setSelection,
+ getView
} = useMilkdown({
content: activeTab?.content ?? '',
onChange: useCallback((value: string) => {
@@ -85,17 +86,12 @@ export function Editor({ darkMode }: EditorProps) {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [action])
- // 获取编辑器容器 DOM(用于搜索高亮)
- const getEditorContainer = useCallback((): HTMLDivElement | null => {
- return containerRef.current
- }, [containerRef])
-
return (
{showSearch && (
setShowSearch(false)}
/>
)}
diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts
index e67ddb2..59dbc41 100644
--- a/src/renderer/components/Editor/useMilkdown.ts
+++ b/src/renderer/components/Editor/useMilkdown.ts
@@ -1,5 +1,11 @@
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 { commonmark } from '@milkdown/preset-commonmark'
import { gfm } from '@milkdown/preset-gfm'
@@ -8,6 +14,67 @@ import { listener, listenerCtx } from '@milkdown/plugin-listener'
import { indent } from '@milkdown/plugin-indent'
import { trailing } from '@milkdown/plugin-trailing'
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('search-highlight')
+
+function createSearchPlugin(): Plugin {
+ return new Plugin({
+ 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 {
content: string
@@ -36,6 +103,9 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
ctx.set(rootCtx, containerRef.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
const lm = ctx.get(listenerCtx)
lm.markdownUpdated((_ctx, markdown, prevMarkdown) => {
@@ -119,7 +189,7 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
if (!editor) return { from: 0, to: 0 }
try {
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) {
return { from: view.state.selection.from, to: view.state.selection.to }
}
@@ -139,6 +209,17 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
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)
const action = useCallback((fn: (editor: Editor) => void) => {
const editor = editorRef.current
@@ -154,6 +235,7 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
getScrollTop,
setScrollTop,
getSelection,
- setSelection
+ setSelection,
+ getView
}
}
diff --git a/src/renderer/components/SearchReplace/SearchReplace.tsx b/src/renderer/components/SearchReplace/SearchReplace.tsx
index 4b93d76..710a9cf 100644
--- a/src/renderer/components/SearchReplace/SearchReplace.tsx
+++ b/src/renderer/components/SearchReplace/SearchReplace.tsx
@@ -1,101 +1,67 @@
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 {
- /** 获取 Milkdown 编辑器容器 DOM(用于遍历文本节点) */
- editorContainer: HTMLDivElement | null
+ /** 获取 ProseMirror EditorView 实例 */
+ getView: () => EditorView | null
/** 关闭面板 */
onClose: () => void
}
-const HIGHLIGHT_CLASS = 'search-match-highlight'
-const ACTIVE_HIGHLIGHT_CLASS = 'search-match-active'
-
/**
- * 清除所有搜索高亮
+ * 在 ProseMirror 文档中查找所有匹配位置
*/
-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 findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): SearchMatch[] {
+ const matches: SearchMatch[] = []
+ if (!query) return matches
-/**
- * 高亮所有匹配项
- * 返回高亮后的元素列表(用于导航)
- */
-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 || ''
+ doc.descendants((node, pos) => {
+ if (!node.isText) return true
+ const text = node.text || ''
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)
+ matches.push({ from: pos + idx, to: pos + idx + query.length })
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)
- }
-
- // 插入高亮标记
- 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)
- }
- }
+/**
+ * 滚动到指定文档位置
+ */
+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
}
+}
- 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 关闭面板
*/
export const SearchReplace = memo(function SearchReplace({
- editorContainer,
+ getView,
onClose
}: SearchReplaceProps) {
const [query, setQuery] = useState('')
@@ -120,104 +86,147 @@ export const SearchReplace = memo(function SearchReplace({
const [currentMatch, setCurrentMatch] = useState(-1)
const searchInputRef = useRef(null)
- const matchElementsRef = useRef([])
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) => {
- if (!editorContainer) return
+ const view = getView()
+ if (!view) return
if (!searchQuery.trim()) {
- clearHighlights(editorContainer)
- matchElementsRef.current = []
+ clearSearchDecorations(view)
+ matchesRef.current = []
+ currentIndexRef.current = -1
setMatchCount(0)
setCurrentMatch(-1)
return
}
- const elements = highlightMatches(editorContainer, searchQuery, cs)
- matchElementsRef.current = elements
- setMatchCount(elements.length)
+ const matches = findMatches(view.state.doc, searchQuery, cs)
+ matchesRef.current = matches
+ const newIdx = matches.length > 0 ? 0 : -1
+ currentIndexRef.current = newIdx
- if (elements.length > 0) {
- setCurrentMatch(0)
- elements[0].classList.add(ACTIVE_HIGHLIGHT_CLASS)
- elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
- } else {
- setCurrentMatch(-1)
+ 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)
}
- }, [editorContainer])
+ }, [getView])
// 搜索框输入变化
const handleQueryChange = useCallback((e: React.ChangeEvent) => {
const value = e.target.value
setQuery(value)
- doSearch(value, caseSensitive)
- }, [doSearch, caseSensitive])
+ doSearch(value, caseSensitiveRef.current)
+ }, [doSearch])
// 大小写切换
const toggleCaseSensitive = useCallback(() => {
- const newCS = !caseSensitive
+ const newCS = !caseSensitiveRef.current
setCaseSensitive(newCS)
- doSearch(query, newCS)
- }, [caseSensitive, query, doSearch])
+ doSearch(queryRef.current, newCS)
+ }, [doSearch])
// 导航到下一个/上一个匹配
const navigateMatch = useCallback((direction: 1 | -1) => {
- const elements = matchElementsRef.current
- if (elements.length === 0) return
+ const view = getView()
+ if (!view) return
- // 移除当前高亮
- if (currentMatch >= 0 && currentMatch < elements.length) {
- elements[currentMatch].classList.remove(ACTIVE_HIGHLIGHT_CLASS)
- }
+ const matches = matchesRef.current
+ if (matches.length === 0) return
- let next = currentMatch + direction
- if (next >= elements.length) next = 0
- if (next < 0) next = elements.length - 1
+ let next = currentIndexRef.current + direction
+ if (next >= matches.length) next = 0
+ if (next < 0) next = matches.length - 1
+ currentIndexRef.current = next
setCurrentMatch(next)
- elements[next].classList.add(ACTIVE_HIGHLIGHT_CLASS)
- elements[next].scrollIntoView({ behavior: 'smooth', block: 'center' })
- }, [currentMatch])
+
+ // 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(() => {
- if (currentMatch < 0 || !editorContainer) return
+ const view = getView()
+ if (!view) return
- const elements = matchElementsRef.current
- const el = elements[currentMatch]
- if (!el || !el.parentNode) return
+ const matches = matchesRef.current
+ const idx = currentIndexRef.current
+ if (idx < 0 || idx >= matches.length) return
- // 替换文本
- const textNode = document.createTextNode(replacement)
- el.parentNode.replaceChild(textNode, el)
- editorContainer.normalize()
+ const match = matches[idx]
- // 重新搜索
- doSearch(query, caseSensitive)
- }, [currentMatch, replacement, editorContainer, query, caseSensitive, doSearch])
+ // 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(() => {
- if (!editorContainer || matchCount === 0) return
+ const view = getView()
+ if (!view) return
- const elements = [...matchElementsRef.current]
- elements.forEach(el => {
- if (el.parentNode) {
- el.parentNode.replaceChild(document.createTextNode(replacement), el)
- }
+ const matches = matchesRef.current
+ if (matches.length === 0) return
+
+ // 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)
+
+ // Re-search after replacement
+ requestAnimationFrame(() => {
+ doSearch(queryRef.current, caseSensitiveRef.current)
})
- editorContainer.normalize()
-
- doSearch(query, caseSensitive)
- }, [editorContainer, matchCount, replacement, query, caseSensitive, doSearch])
+ }, [getView, replacement, doSearch])
// 键盘事件
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
- if (editorContainer) clearHighlights(editorContainer)
+ const view = getView()
+ if (view) clearSearchDecorations(view)
onClose()
return
}
@@ -229,7 +238,7 @@ export const SearchReplace = memo(function SearchReplace({
navigateMatch(1)
}
}
- }, [onClose, navigateMatch, editorContainer])
+ }, [onClose, navigateMatch, getView])
// 全局 Ctrl+F / Ctrl+H 拦截
useEffect(() => {
@@ -258,12 +267,14 @@ export const SearchReplace = memo(function SearchReplace({
searchInputRef.current?.select()
}, [])
- // 清理高亮
+ // 清理高亮(组件卸载时)
useEffect(() => {
return () => {
- if (editorContainer) clearHighlights(editorContainer)
+ const view = getView()
+ if (view) clearSearchDecorations(view)
}
- }, [editorContainer])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
return (
{
- if (editorContainer) clearHighlights(editorContainer)
+ const view = getView()
+ if (view) clearSearchDecorations(view)
onClose()
}}
title="关闭 (Esc)"