From 06d9d6aed8e8bbadde3802cb2b782b7bb63b8494 Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 5 Nov 2026 15:30:32 +0100 Subject: [PATCH] fix: scroll to the current search match when the search term changes --- .../TextEditorSearchAndReplacePanel.vue | 11 ++- .../TextEditorSearchAndReplacePanel.spec.ts | 80 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue b/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue index ac576079664..233b74c37f1 100644 --- a/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue +++ b/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue @@ -134,7 +134,7 @@ import { } from 'vue' import { useGettext } from 'vue3-gettext' import type { Editor } from '@tiptap/core' -import { debounce } from 'lodash-es' +import { debounce, isEqual, pick } from 'lodash-es' import { OcButton, OcHiddenAnnouncer, @@ -217,6 +217,8 @@ const scrollToCurrentResult = () => { } } +let previousSearchOptions = {} + const updateResultState = () => { const storage = editor.storage.findAndReplace const results = storage?.results || [] @@ -224,13 +226,16 @@ const updateResultState = () => { const newCount = results.length const newPosition = currentIndex === null || currentIndex < 0 ? 0 : currentIndex + 1 - // Only scroll if the position actually changed + // a new search or option can point to another match at the same position, document edits must not scroll const positionChanged = newPosition !== unref(currentResultPosition) + const searchOptions = pick(storage, ['searchTerm', 'caseSensitive', 'useRegex', 'wholeWord']) + const searchChanged = !isEqual(searchOptions, previousSearchOptions) + previousSearchOptions = searchOptions resultCount.value = newCount currentResultPosition.value = newPosition - if (positionChanged && newPosition > 0) { + if ((positionChanged || searchChanged) && newPosition > 0) { scrollToCurrentResult() } } diff --git a/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts b/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts index f0f782618db..57ec35149ec 100644 --- a/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts +++ b/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts @@ -171,6 +171,86 @@ describe('TextEditorSearchAndReplacePanel', () => { expect(wrapper.find('[data-testid="editor-search-result-counter"]').text()).toBe('2 / 2') }) + describe('scrolling to the current result', () => { + function mountPanel() { + const { editor, triggerTransaction } = createEditorMock() + const scrollIntoView = vi.fn() + editor.view = { + domAtPos: vi.fn(() => { + const node = document.createElement('span') + node.scrollIntoView = scrollIntoView + return { node } + }) + } + mount(TextEditorSearchAndReplacePanel, { + props: { + editor, + closeMenu: vi.fn(), + ...createSearchProps(), + searchSearchTerm: 'needle' + }, + global: { + plugins: [...defaultPlugins()], + stubs: { OcIcon: true } + } + }) + function setSearch( + searchTerm: string, + results: Array<{ from: number; to: number }>, + currentIndex = 0 + ) { + editor.storage.findAndReplace.searchTerm = searchTerm + editor.storage.findAndReplace.results = results + editor.storage.findAndReplace.currentIndex = results.length ? currentIndex : null + triggerTransaction() + } + return { editor, scrollIntoView, setSearch } + } + + it('scrolls to the new current result when the search term changes but the position stays the same', () => { + const { editor, scrollIntoView, setSearch } = mountPanel() + setSearch('last', [{ from: 500, to: 504 }]) + expect(editor.view.domAtPos).toHaveBeenLastCalledWith(500) + expect(scrollIntoView).toHaveBeenCalledTimes(1) + + setSearch('first', [ + { from: 1, to: 6 }, + { from: 100, to: 105 } + ]) + expect(editor.view.domAtPos).toHaveBeenLastCalledWith(1) + expect(scrollIntoView).toHaveBeenCalledTimes(2) + }) + + it('scrolls to the current result when a search option changes', () => { + const { editor, scrollIntoView, setSearch } = mountPanel() + setSearch('needle', [ + { from: 1, to: 7 }, + { from: 100, to: 106 } + ]) + expect(scrollIntoView).toHaveBeenCalledTimes(1) + + editor.storage.findAndReplace.caseSensitive = true + setSearch('needle', [{ from: 100, to: 106 }]) + expect(editor.view.domAtPos).toHaveBeenLastCalledWith(100) + expect(scrollIntoView).toHaveBeenCalledTimes(2) + }) + + it('does not scroll when the document changes but the current result stays the same', () => { + const { scrollIntoView, setSearch } = mountPanel() + setSearch('needle', [ + { from: 10, to: 16 }, + { from: 100, to: 106 } + ]) + expect(scrollIntoView).toHaveBeenCalledTimes(1) + + setSearch('needle', [ + { from: 12, to: 18 }, + { from: 102, to: 108 } + ]) + expect(scrollIntoView).toHaveBeenCalledTimes(1) + }) + }) + describe('result announcement', () => { beforeEach(() => { vi.useFakeTimers()