From 889dbb23cbd1283ed9c5e604e88a27f371165805 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:25:04 -0700 Subject: [PATCH] fix: Cmd+F search in rich markdown editor now scrolls to active match (#876) Merge decoration update and selection into a single ProseMirror transaction so scrollIntoView is not lost. After dispatch, manually scroll the outer flex container using coordsAtPos since tr.scrollIntoView cannot reach the non-overflowing editor wrapper. Also change active match highlight from orange to blue for better contrast. --- .../src/assets/rich-markdown-editor.css | 2 +- .../components/editor/RichMarkdownEditor.tsx | 4 +- .../editor/useRichMarkdownSearch.ts | 63 ++++++++++++------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 4d494da6..61168136 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -121,7 +121,7 @@ } .rich-markdown-search-match[data-active='true'] { - background: color-mix(in srgb, #fb923c 60%, transparent); + background: color-mix(in srgb, #38bdf8 50%, transparent); } .rich-markdown-editor { diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index d909c82d..068f0b39 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this component co-locates the rich markdown editor surface, toolbar, search, and slash menu so tightly coupled editor state stays in one place. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' @@ -365,7 +366,8 @@ export default function RichMarkdownEditor({ } = useRichMarkdownSearch({ editor, isMac, - rootRef + rootRef, + scrollContainerRef }) useEffect(() => { openSearchRef.current = openSearch diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts index 8add06e2..196cd13b 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts @@ -12,11 +12,13 @@ import { export function useRichMarkdownSearch({ editor, isMac, - rootRef + rootRef, + scrollContainerRef }: { editor: Editor | null isMac: boolean rootRef: RefObject + scrollContainerRef: RefObject }) { const searchInputRef = useRef(null) const [isSearchOpen, setIsSearchOpen] = useState(false) @@ -64,8 +66,12 @@ export function useRichMarkdownSearch({ return } + // Why: rawActiveMatchIndex starts at -1 before the user navigates, but the + // derived activeMatchIndex is already 0 (first match shown). Using 0 as the + // base when raw is -1 ensures the first Enter press advances to match 1 + // instead of computing (-1+1)%N = 0 and leaving the effect unchanged. setRawActiveMatchIndex((currentIndex) => { - const baseIndex = currentIndex >= 0 ? currentIndex : direction === 1 ? -1 : 0 + const baseIndex = Math.max(currentIndex, 0) return (baseIndex + direction + matchCount) % matchCount }) }, @@ -117,31 +123,40 @@ export function useRichMarkdownSearch({ } const query = isSearchOpen ? searchQuery : '' - editor.view.dispatch( - editor.state.tr.setMeta(richMarkdownSearchPluginKey, { - activeIndex: activeMatchIndex, - query - }) - ) - if (!query || activeMatchIndex < 0) { - return - } - - const activeMatch = matches[activeMatchIndex] - if (!activeMatch) { - return - } - - // Why: rich-mode find should navigate within the editor model instead of - // the rendered DOM so highlight positions stay correct while the user edits. - // Updating the ProseMirror selection keeps scroll-to-match aligned with the - // actual markdown document rather than the transient browser layout. + // Why: combining decoration meta and selection+scrollIntoView into one + // transaction avoids a split-dispatch where the first dispatch updates + // editor.state and the second dispatch's scrollIntoView can be lost + // when ProseMirror coalesces view updates. const tr = editor.state.tr - tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) - tr.scrollIntoView() + tr.setMeta(richMarkdownSearchPluginKey, { + activeIndex: activeMatchIndex, + query + }) + + const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null + if (activeMatch) { + tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) + } + editor.view.dispatch(tr) - }, [activeMatchIndex, editor, isSearchOpen, matches, searchQuery]) + + // Why: ProseMirror's tr.scrollIntoView() delegates to the view's + // scrollDOMIntoView which may fail to reach the outer flex scroll container + // (the editor element itself has min-height: 100% and no overflow). + // Reading coordsAtPos *after* the dispatch and manually scrolling the + // container mirrors the approach used by MarkdownPreview search. + if (activeMatch) { + const container = scrollContainerRef.current + if (container) { + const coords = editor.view.coordsAtPos(activeMatch.from) + const containerRect = container.getBoundingClientRect() + const relativeTop = coords.top - containerRect.top + const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2 + container.scrollTo({ top: targetScroll, behavior: 'instant' }) + } + } + }, [activeMatchIndex, editor, isSearchOpen, matches, scrollContainerRef, searchQuery]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => {