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)"