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.
This commit is contained in:
Jinjing 2026-04-20 17:25:04 -07:00 committed by GitHub
parent 288322cac4
commit 889dbb23cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 43 additions and 26 deletions

View file

@ -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 {

View file

@ -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

View file

@ -12,11 +12,13 @@ import {
export function useRichMarkdownSearch({
editor,
isMac,
rootRef
rootRef,
scrollContainerRef
}: {
editor: Editor | null
isMac: boolean
rootRef: RefObject<HTMLDivElement | null>
scrollContainerRef: RefObject<HTMLDivElement | null>
}) {
const searchInputRef = useRef<HTMLInputElement | null>(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 => {