Skip to content

fix(superdoc): restore find input focus after match navigation (SD-3045)#3240

Merged
luccas-harbour merged 3 commits into
mainfrom
tadeu/sd-3045-find-replace-focus-loss
May 14, 2026
Merged

fix(superdoc): restore find input focus after match navigation (SD-3045)#3240
luccas-harbour merged 3 commits into
mainfrom
tadeu/sd-3045-find-replace-focus-loss

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 11, 2026

Demo

CleanShot.2026-05-14.at.09.18.13.mp4

Summary

Pressing Enter in the built-in find/replace input used to advance to the next match once, then drop focus into the ProseMirror editor. Subsequent Enter keystrokes were swallowed by the editor (splitting paragraphs at the match position, invalidating the search session, and resetting the active match index back to zero), forcing the user to click back into the input between every navigation. The "alternating jump back to page 1" symptom in the ticket title is a downstream effect of the same focus steal.

Root cause: the Search extension's goToSearchResult calls editor.view.focus() so its newly-set selection is visible — by design when called programmatically — but FindReplaceSurface.vue's Enter handler did not restore focus afterwards.

Fix: re-focus the find input synchronously after goNext / goPrev returns. The handler already calls e.preventDefault(), so once focus stays on the input the editor never sees the keystroke.

The change is local to FindReplaceSurface.vue — the underlying Search command behaviour (used by the dev sidebar and the public SuperDoc.goToSearchResult API) is unchanged.

Test plan

  • New FindReplaceSurface.test.js covers: Enter keeps focus when goNext synchronously steals focus elsewhere; Shift+Enter / goPrev does the same; Enter's default is still prevented.
  • All 962 superdoc package tests pass.
  • Browser reproduction on the dev app: 5 consecutive Enters advanced 2/16 → 7/16 with focus retained, 3 Shift+Enters returned to 4/16.

The Search extension's goToSearchResult calls editor.view.focus() so the
new selection is visible. When the user pressed Enter in the built-in find
input, the synchronous focus steal blurred the input — subsequent Enter
keystrokes were swallowed by the ProseMirror editor, splitting paragraphs
at the match position, invalidating the search session, and resetting the
active match index. The user had to click back into the input between
every navigation.

Re-focus the find input synchronously after goNext / goPrev so repeated
Enter / Shift+Enter keeps advancing through matches and the editor never
receives the keystroke.
@tupizz tupizz requested a review from a team as a code owner May 11, 2026 23:01
@linear
Copy link
Copy Markdown

linear Bot commented May 11, 2026

SD-3045

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 11, 2026

Codecov Report

❌ Patch coverage is 86.79245% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...doc/src/components/surfaces/FindReplaceSurface.vue 86.79% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

@tupizz tupizz self-assigned this May 12, 2026
@luccas-harbour
Copy link
Copy Markdown
Contributor

luccas-harbour commented May 13, 2026

hey @tupizz!
I was testing this and noticed that if I press enter and the next match is not in the current page, it will not scroll down and show me the match. I also tested this against the main branch and I can see that this was still an issue there (so it's not a bug that was introduced by your code). I was just wondering if perhaps fixing that is simple enough that you could do it in this PR? Let me know if that's not the case!

Here's the test document I was using to test this:
finding1.docx

If you search for titlePg and press enter twice, you'll notice it doesn't scroll down to the next page.

@tupizz
Copy link
Copy Markdown
Contributor Author

tupizz commented May 13, 2026

@luccas-harbour nice suggestion
going to add this

…D-3045)

Pressing Enter or clicking next/prev on a search match that lives on a
different page often left the match just below the visible area, slightly
above the viewport, or didn't scroll at all. Three races were colluding:

1. The Vue surface lives in the document's normal flow, so any focus or
   .select() on its input or buttons triggers the browser's
   scroll-element-into-view behaviour and snaps the document back to the
   find bar — undoing the goNext scroll. Drop .select() (cmd+A still
   works), focus with preventScroll: true, and pin every scrollable
   ancestor's scrollTop across the focus call. Route the button clicks
   through the same goNext/goPrev → focusFindInput path so all three
   navigation entry points (Enter, Shift+Enter, button click) have the
   same focus-restore guarantee.

2. PresentationEditor.scrollToPosition runs after dispatch(setSelection),
   but the selectionUpdate emitted by that dispatch sets
   #shouldScrollSelectionIntoView = true. The next #updateSelection then
   calls #scrollActiveEndIntoView and snaps the caret to its
   minimum-visibility position (often 20px from the viewport edge),
   visibly displacing the match. Consume the flag inside
   scrollToPosition.

3. A later selectionUpdate (focus blur as the user moves focus back to
   the find input, or async PM events) re-sets the flag to true after we
   consumed it, and the RAF-deferred #updateSelection scrolls the caret
   again. Re-assert the scrollIntoView on the next animation frame so any
   such late displacement is corrected; the no-op case is cheap.

Tests cover all three: Enter / Shift+Enter / button-click focus restore
use { preventScroll: true } on the find input. Manual repro on Luccas's
finding1.docx fixture: 4 'titlePg' matches across 3 pages — every click
now lands with the match in the centre of the viewport.
@tupizz
Copy link
Copy Markdown
Contributor Author

tupizz commented May 14, 2026

Pushed ce29cbf5d addressing the cross-page scroll regression you flagged.

What it was

Three races in one keystroke. Pressing Enter (or clicking next/prev) on a match on another page fired:

  1. editor.view.focus()wrapOffscreenEditorFocus saves/restores window scroll
  2. dispatch(tr.setSelection().scrollIntoView()) → emits selectionUpdatehandleSelection sets #shouldScrollSelectionIntoView = true#updateSelection calls #scrollActiveEndIntoViewscrollScreenRectIntoView snaps caret to ~20px from viewport edge
  3. presentationEditor.scrollToPosition(from, { block: 'center' }) does the actual centring

Depending on whether (2)'s render flushed synchronously or via RAF, (3)'s scroll either won or got overwritten. The find bar lives in the document's normal flow, so any focus() on its input or buttons after we scrolled to a far page snapped the document back to where the find bar was — undoing (3) again. Result: sometimes the match was off-screen entirely, sometimes slightly above/below, never reliably centred.

What changed

PresentationEditor.tsscrollToPosition:

  • Consume #shouldScrollSelectionIntoView after scrolling so the next #updateSelection doesn't re-snap.
  • Re-assert scrollIntoView on the next animation frame to defend against a later selectionUpdate (focus blur, async PM event) that re-sets the flag before its RAF-deferred render fires. Cheap no-op when nothing tried to undo us.

FindReplaceSurface.vuefocusFindInput:

  • .focus({ preventScroll: true }) and drop .select() (it has no preventScroll equivalent and can itself scroll the input into view; Cmd/Ctrl+A still selects all if the user wants to retype).
  • Pin scrollTop of all scrollable ancestors across the focus call as a belt for browsers that ignore preventScroll on some focus paths.
  • Route the next/prev button clicks through handleGoNext / handleGoPrev so they also call focusFindInput — the original handler only restored focus from Enter, so button clicks left the button focused and the browser scrolled it (and therefore the find bar, and therefore the document) back into view.

Verification

  • 7 new/updated unit tests in FindReplaceSurface.test.js covering: Enter / Shift+Enter / next-button click / prev-button click all use preventScroll, default still prevented, focus retained after goNext steals focus.
  • All 1366 PresentationEditor tests pass.
  • Manual repro on your finding1.docx: 4 titlePg matches across 3 pages. Click sequence advances 1 → 2 (scroll 0, still on page 1) → 3 (scroll 694, page 2 centred) → 4 (scroll 728, page 3 centred) → 1 (scroll 0). Screenshot at match 3 shows the highlighted word visibly centred in viewport, find bar floating in place.

If you can sanity-check your own repro, that'd be great. If anything still drifts I'll keep iterating.

Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @tupizz, I noticed one more thing. I was testing with the document basic/advanced-text.docx from the test corpus and if you search for the word Oscar, you'll notice that some of the matches are not highlighted (I think matches 3, 5 and 7?). I know this is a bit more than what the ticket asked for so let me know if it's a quick fix.

… (SD-3045)

Search matches were invisible on runs whose source rPr carried a highlight
mark (e.g. `<w:highlight w:val="white"/>`). The DomPainter writes
`style.backgroundColor = run.highlight` inline on the same span the
DecorationBridge later tags with `.ProseMirror-search-match`, and inline
styles win over class selectors — so the find colour was painted over.

Add `!important` to both search-match background rules (the `.superdoc`
scope used in presentation mode and the `.sd-editor-scoped` scope used by
the hidden editor) so the transient find colour overrides any source-doc
highlight while a search session is live. The class is only present during
a search; clearing it restores the original highlight.

Repro: load `basic/advanced-text.docx` from the corpus, search for "Oscar".
5 of 8 matches sit in runs imported as `highlight: { color: '#FFFFFF' }`
and had no visible find highlight pre-fix. All 8 are now correctly coloured.

Regression test asserts !important is present in both CSS files plus a
JSDOM specificity sanity check showing the rule wins over an inline white.
@tupizz
Copy link
Copy Markdown
Contributor Author

tupizz commented May 14, 2026

Pushed 5562dc678 covering your Oscar-highlight finding.

Root cause

Reproduced on basic/advanced-text.docx from the corpus. The PM search finds all 8 Oscar matches correctly and the DecorationBridge applies .ProseMirror-search-match to all 8 painted spans. The bug is CSS specificity: the DomPainter writes style.backgroundColor = run.highlight inline on the same span. Inline styles win over class selectors, so on every match in a run whose source rPr carries <w:highlight w:val="white"/> (decoded as highlight: { color: '#FFFFFF' }), the find highlight was painted over by white.

Inventory of computed backgroundColor on the 8 spans before fix:

# Text Source highlight Visible bg pre-fix
1 "with Oscar Wilde" (active) rgba(255, 150, 0, 0.6) ✅
2 "Oscar Fingal" #FFFFFF rgb(255, 255, 255) ❌
3 "Oscar Wilde was born" #FFFFFF rgb(255, 255, 255) ❌
4 "Oscar Wilde Centre" (link) rgba(255, 213, 0, 0.4) ✅
5 "Oscar was two years younger" (p1) #FFFFFF rgb(255, 255, 255) ❌
6 "Willie and Oscar" (p1) #FFFFFF rgb(255, 255, 255) ❌
7 "Oscar was two years younger" (p2) #FFFFFF rgb(255, 255, 255) ❌
8 "Willie and Oscar" (p2) rgba(255, 213, 0, 0.4) ✅

Five of eight, not three — your annotation marked one I had counted (Oscar Fingal) but didn't name in your message.

Fix

Two CSS files, four lines:

  • packages/superdoc/src/assets/styles/elements/superdoc.css!important on .superdoc .ProseMirror-search-match and .superdoc .ProseMirror-active-search-match.
  • packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css!important on the .sd-editor-scoped mirror pair.

!important is the desired semantic: while a search session is live, the find colour must override any source-doc highlight. The class is only present during a search; clearing the session removes it and the original highlight is restored.

Why not Chrome's "0/20"

When you saw Oscar 0/20 from Chrome's Cmd+F vs SuperDoc's 1 of 8, that's not 12 missing matches — that's Chrome counting:

  • 8 visible DomPainter spans (the actual matches)
  • 8 hidden ProseMirror editable mirror spans (off-screen at x≈-9500; SuperDoc's editing state lives there)
  • 3 superdoc-sr-only link-description spans for the "Oscar Wilde Centre" link
  • 1 likely aria-label or hidden footnote ref

Chrome doesn't distinguish visible from off-screen and walks accessibility text. SuperDoc's 8 is the correct count of semantically unique occurrences in editor.state.doc.

Verification

Layer Result
Per-span getComputedStyle().backgroundColor post-fix all 8 match the token (rgba(255, 213, 0, 0.4) non-active, rgba(255, 150, 0, 0.6) active)
pnpm --filter super-editor test --run search 11 files, 190 tests passing
New search-match-precedence.test.js 6 tests — CSS source assertions + JSDOM specificity sanity (positive + negative case proving the bug existed without !important)
pnpm --filter superdoc test --run 967 passed; 1 pre-existing failure in collab-server.test.ts confirmed unrelated (fails on ce29cbf5d baseline too)
Browser repro 4 visible Oscars in viewport now all yellow; orange on active. No more white-on-white.

Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@luccas-harbour luccas-harbour merged commit 0a4b0b3 into main May 14, 2026
70 checks passed
@luccas-harbour luccas-harbour deleted the tadeu/sd-3045-find-replace-focus-loss branch May 14, 2026 17:09
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in @superdoc-dev/mcp v0.3.0-next.96

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in @superdoc-dev/react v1.2.0-next.140

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in vscode-ext v2.3.0-next.142

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in superdoc-cli v0.8.0-next.111

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in superdoc-sdk v1.8.0-next.94

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 14, 2026

🎉 This PR is included in superdoc v1.30.0-next.91

The release is available on GitHub release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants