From 7c5a7f287e788a445c5a1a9bf4b25c3f963ec7a4 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 20:01:10 -0300 Subject: [PATCH 1/3] fix(superdoc): restore find input focus after match navigation (SD-3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../surfaces/FindReplaceSurface.test.js | 142 ++++++++++++++++++ .../surfaces/FindReplaceSurface.vue | 5 + 2 files changed, 147 insertions(+) create mode 100644 packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js new file mode 100644 index 0000000000..d6928b5971 --- /dev/null +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js @@ -0,0 +1,142 @@ +// @ts-nocheck +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { nextTick, ref, computed } from 'vue'; +import FindReplaceSurface from './FindReplaceSurface.vue'; + +const DEFAULT_TEXTS = { + findPlaceholder: 'Find', + findAriaLabel: 'Find text', + replacePlaceholder: 'Replace', + replaceAriaLabel: 'Replace text', + noResultsLabel: 'No results', + previousMatchLabel: 'Previous', + previousMatchAriaLabel: 'Previous', + nextMatchLabel: 'Next', + nextMatchAriaLabel: 'Next', + closeLabel: 'Close', + closeAriaLabel: 'Close', + replaceLabel: 'Replace', + replaceAllLabel: 'All', + toggleReplaceLabel: 'Toggle replace', + toggleReplaceAriaLabel: 'Toggle replace', + matchCaseLabel: 'Aa', + matchCaseAriaLabel: 'Match case', + ignoreDiacriticsLabel: 'ä≡a', + ignoreDiacriticsAriaLabel: 'Ignore diacritics', +}; + +/** + * Build a ref-shaped findReplace handle that mirrors what useFindReplace provides. + * goNext / goPrev simulate the real Search extension behaviour of focusing the + * editor view (which is what causes SD-3045 in production) by moving focus to a + * detached element supplied via `stealFocusInto`. + */ +function createHandle(overrides = {}) { + const findQuery = ref('Lorem'); + const replaceText = ref(''); + const caseSensitive = ref(false); + const ignoreDiacritics = ref(false); + const showReplace = ref(false); + const matchCount = ref(16); + const activeMatchIndex = ref(0); + + return { + findQuery, + replaceText, + caseSensitive, + ignoreDiacritics, + showReplace, + matchCount, + activeMatchIndex, + matchLabel: computed(() => `${activeMatchIndex.value + 1} of ${matchCount.value}`), + hasMatches: computed(() => matchCount.value > 0), + replaceEnabled: true, + texts: { ...DEFAULT_TEXTS }, + goNext: vi.fn(() => overrides.stealFocusInto?.focus()), + goPrev: vi.fn(() => overrides.stealFocusInto?.focus()), + replaceCurrent: vi.fn(), + replaceAll: vi.fn(), + registerFocusFn: vi.fn(), + close: vi.fn(), + ...overrides.handle, + }; +} + +function mountSurface(handle) { + return mount(FindReplaceSurface, { + attachTo: document.body, + props: { + surfaceId: 'fr-1', + mode: 'floating', + request: {}, + resolve: vi.fn(), + close: vi.fn(), + findReplace: handle, + }, + }); +} + +describe('FindReplaceSurface — keyboard focus (SD-3045)', () => { + it('keeps focus on the find input after pressing Enter, even when goNext steals focus to another element', async () => { + // The editor view focus steal is the real-world cause of SD-3045 — simulate it + // with a detached div that goNext focuses synchronously, matching the Search + // extension's editor.view.focus() side effect. + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + expect(document.activeElement).toBe(input); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter' }); + await nextTick(); + + expect(handle.goNext).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('keeps focus on the find input after Shift+Enter (previous match)', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter', shiftKey: true }); + await nextTick(); + + expect(handle.goPrev).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('prevents the default Enter behaviour so the editor never receives the keystroke', () => { + const handle = createHandle(); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue index 96cc56f390..b6e3eca0b8 100644 --- a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue @@ -18,9 +18,14 @@ function handleFindKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); props.findReplace.goNext(); + // goNext synchronously focuses the ProseMirror view (search.js `goToSearchResult`). + // Restore focus here so repeated Enter keeps advancing through matches instead of + // dropping the keystrokes into the editor (SD-3045). + focusFindInput(); } else if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); props.findReplace.goPrev(); + focusFindInput(); } } From ce29cbf5d5dd5864be4544a876272bab68232f16 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 14 May 2026 09:12:33 -0300 Subject: [PATCH 2/3] fix(superdoc): scroll cross-page search matches into view reliably (SD-3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../presentation-editor/PresentationEditor.ts | 32 ++++- .../surfaces/FindReplaceSurface.test.js | 129 ++++++++++++++++++ .../surfaces/FindReplaceSurface.vue | 67 +++++++-- 3 files changed, 216 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 082e439855..fbab844b04 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -3533,7 +3533,37 @@ export class PresentationEditor extends EventEmitter { if (pageEl) { // Find the specific element containing this position for precise centering const targetEl = this.#findElementAtPosition(pageEl, clampedPos); - (targetEl ?? pageEl).scrollIntoView({ block, inline: 'nearest', behavior }); + const elToScroll = targetEl ?? pageEl; + elToScroll.scrollIntoView({ block, inline: 'nearest', behavior }); + // AIDEV-NOTE: SD-3045. Search nav (and any other caller of + // scrollToPosition) places the viewport intentionally — usually + // centring the match. The next #updateSelection that runs as part + // of the dispatched setSelection transaction would otherwise call + // #scrollActiveEndIntoView and re-scroll the caret to its minimal + // visible position (often the top of the viewport), undoing our + // centring. Consume the pending scroll-into-view request so that + // selection sync renders the caret overlay without moving the + // scroll back. Other selection updates (Shift+Arrow, typing) re-set + // this flag themselves before they need scroll, so this consume is + // safe. + this.#shouldScrollSelectionIntoView = false; + // Re-assert the scroll on the next animation frame. The flag we + // cleared above defends against handleSelection that has already + // run, but a *later* selectionUpdate (e.g. focus blur fired when + // the user moves focus back to the find input) re-sets the flag to + // true before the RAF-scheduled #updateSelection fires, and that + // pass scrolls the caret to its minimal-visibility position — + // visibly snapping the match out of view. Re-running scrollIntoView + // on the same element a frame later overrides that snap; the no-op + // case (no late scroll happened) just re-centres the same element + // and is cheap. + const win = this.#visibleHost.ownerDocument?.defaultView; + if (win) { + win.requestAnimationFrame(() => { + elToScroll.scrollIntoView({ block, inline: 'nearest', behavior }); + this.#shouldScrollSelectionIntoView = false; + }); + } return true; } } diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js index d6928b5971..c6791199c2 100644 --- a/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js @@ -139,4 +139,133 @@ describe('FindReplaceSurface — keyboard focus (SD-3045)', () => { expect(event.defaultPrevented).toBe(true); wrapper.unmount(); }); + + // SD-3045 follow-up (Luccas's PR review comment on #3240): pressing Enter + // when the next match is on a different page must not undo the + // PresentationEditor.scrollToPosition that goNext just performed. The + // surface is rendered in the normal document flow, so the browser's + // default "scroll input into view" behaviour on .focus() snaps the + // document back to wherever the find input is, hiding the new match. The + // fix is to restore focus with { preventScroll: true } so the document + // scroll stays where goNext placed it. + it('restores focus without scrolling the document back to the find input', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + + const focusSpy = vi.spyOn(input, 'focus'); + input.focus(); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter' }); + await nextTick(); + + // After Enter, the surface restores focus to the input. That focus call + // must pass preventScroll so the browser does not scroll the document + // back to the input — otherwise the goNext scroll is undone for any + // match on a different page. + const calls = focusSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const restoreCall = calls[calls.length - 1]; + expect(restoreCall[0]).toEqual(expect.objectContaining({ preventScroll: true })); + + focusSpy.mockRestore(); + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('uses preventScroll on Shift+Enter focus restore too', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + + const focusSpy = vi.spyOn(input, 'focus'); + input.focus(); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter', shiftKey: true }); + await nextTick(); + + const calls = focusSpy.mock.calls; + const restoreCall = calls[calls.length - 1]; + expect(restoreCall[0]).toEqual(expect.objectContaining({ preventScroll: true })); + + focusSpy.mockRestore(); + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + // SD-3045 holistic: clicking the next/prev buttons must also restore focus to + // the input — otherwise the button receives focus and the browser scrolls + // the document back to the (off-screen) find bar, undoing the goNext scroll + // exactly the same way pressing Enter without focus restore did. + it('restores focus to the find input after clicking the next-match button', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + const focusSpy = vi.spyOn(input, 'focus'); + + const buttons = wrapper.findAll('.sd-find-replace__btn--icon'); + // First two icon buttons are prev (▲) and next (▼); third is close. + const nextBtn = buttons[1]; + expect(nextBtn.exists()).toBe(true); + + await nextBtn.trigger('click'); + await nextTick(); + + expect(handle.goNext).toHaveBeenCalledTimes(1); + const focusCall = focusSpy.mock.calls.find((args) => args[0]?.preventScroll === true); + expect(focusCall).toBeDefined(); + + focusSpy.mockRestore(); + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('restores focus to the find input after clicking the previous-match button', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + const focusSpy = vi.spyOn(input, 'focus'); + + const buttons = wrapper.findAll('.sd-find-replace__btn--icon'); + const prevBtn = buttons[0]; + + await prevBtn.trigger('click'); + await nextTick(); + + expect(handle.goPrev).toHaveBeenCalledTimes(1); + const focusCall = focusSpy.mock.calls.find((args) => args[0]?.preventScroll === true); + expect(focusCall).toBeDefined(); + + focusSpy.mockRestore(); + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); }); diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue index b6e3eca0b8..53bcbbf5e0 100644 --- a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue @@ -17,25 +17,70 @@ const findInputRef = ref(null); function handleFindKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - props.findReplace.goNext(); - // goNext synchronously focuses the ProseMirror view (search.js `goToSearchResult`). - // Restore focus here so repeated Enter keeps advancing through matches instead of - // dropping the keystrokes into the editor (SD-3045). - focusFindInput(); + handleGoNext(); } else if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); - props.findReplace.goPrev(); - focusFindInput(); + handleGoPrev(); } } +// Single path for advancing a match. Both the Enter key and the next/prev +// buttons must route through here so the focus restore — which preserves the +// goNext / PresentationEditor.scrollToPosition we just performed — runs in +// every navigation case. Clicking a button without restoring focus lets the +// button keep focus, and the browser scrolls the (off-screen) find bar back +// into view, undoing the navigation scroll on cross-page matches. +function handleGoNext() { + props.findReplace.goNext(); + focusFindInput(); +} + +function handleGoPrev() { + props.findReplace.goPrev(); + focusFindInput(); +} + function handleClose() { props.findReplace.close('user-closed'); } +function collectScrollableAncestors(element) { + const result = []; + let cur = element?.parentElement ?? null; + while (cur) { + const style = cur.ownerDocument?.defaultView?.getComputedStyle?.(cur); + if (style) { + const overflow = `${style.overflowY} ${style.overflowX}`; + if (overflow.includes('auto') || overflow.includes('scroll')) { + result.push(cur); + } + } + cur = cur.parentElement; + } + const root = element?.ownerDocument?.scrollingElement; + if (root && !result.includes(root)) result.push(root); + return result; +} + function focusFindInput() { - findInputRef.value?.focus(); - findInputRef.value?.select(); + // The surface lives in the document's normal flow. After goNext, the + // PresentationEditor has scrolled the SuperDoc container to the active + // match — which can be on a different page. Anything that gives focus to a + // descendant of the (now off-screen) find bar can trigger the browser's + // "scroll element into view" behaviour and snap that scroll back, hiding + // the match. `preventScroll: true` covers `.focus()`; we additionally pin + // every scrollable ancestor's scroll position across the call as a belt + // for `.focus()` implementations or related side effects that ignore + // preventScroll (SD-3045 review — match on different page never appeared). + const input = findInputRef.value; + if (!input) return; + const scrollables = collectScrollableAncestors(input); + const saved = scrollables.map((el) => ({ el, top: el.scrollTop, left: el.scrollLeft })); + input.focus({ preventScroll: true }); + for (const { el, top, left } of saved) { + if (el.scrollTop !== top) el.scrollTop = top; + if (el.scrollLeft !== left) el.scrollLeft = left; + } } onMounted(() => { @@ -71,7 +116,7 @@ onMounted(() => { :disabled="!findReplace.hasMatches.value" :title="findReplace.texts.previousMatchLabel" :aria-label="findReplace.texts.previousMatchAriaLabel" - @click="findReplace.goPrev()" + @click="handleGoPrev()" > ▲ @@ -81,7 +126,7 @@ onMounted(() => { :disabled="!findReplace.hasMatches.value" :title="findReplace.texts.nextMatchLabel" :aria-label="findReplace.texts.nextMatchAriaLabel" - @click="findReplace.goNext()" + @click="handleGoNext()" > ▼ From 5562dc6784ccca6f58dde57dd79e9b3fb1683954 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 14 May 2026 13:34:08 -0300 Subject: [PATCH 3/3] fix(superdoc): make search highlight win over inline background-color (SD-3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search matches were invisible on runs whose source rPr carried a highlight mark (e.g. ``). 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. --- .../v1/assets/styles/elements/prosemirror.css | 8 +- .../elements/search-match-precedence.test.js | 102 ++++++++++++++++++ .../src/assets/styles/elements/superdoc.css | 13 ++- 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 packages/superdoc/src/assets/styles/elements/search-match-precedence.test.js diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css index 29b0e02c0c..090dbbbff2 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css @@ -432,11 +432,15 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html margin-top: -5px; } +/* AIDEV-NOTE: SD-3045. `!important` so the transient search highlight wins + * over any inline `style.backgroundColor` painted from a source-doc highlight + * mark on the same span. See packages/superdoc/src/assets/styles/elements/superdoc.css + * for the full rationale. */ .sd-editor-scoped .ProseMirror-search-match { - background-color: #ffff0054; + background-color: #ffff0054 !important; } .sd-editor-scoped .ProseMirror-active-search-match { - background-color: #ff6a0054; + background-color: #ff6a0054 !important; } .sd-editor-scoped .ProseMirror span.sd-custom-selection::selection { background: transparent; diff --git a/packages/superdoc/src/assets/styles/elements/search-match-precedence.test.js b/packages/superdoc/src/assets/styles/elements/search-match-precedence.test.js new file mode 100644 index 0000000000..d523338e94 --- /dev/null +++ b/packages/superdoc/src/assets/styles/elements/search-match-precedence.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// SD-3045 (cross-package CSS invariant). The DomPainter writes +// `style.backgroundColor = run.highlight` inline on the same span the search +// DecorationBridge tags with `.ProseMirror-search-match`. Without `!important`, +// the inline style wins and the search highlight is invisible on every run +// whose source rPr carries a highlight mark (e.g. ``). +// These tests guard the two CSS sites that paint the transient search colour. + +const repoRoot = join(__dirname, '..', '..', '..', '..', '..', '..'); + +const superdocCss = readFileSync( + join(repoRoot, 'packages', 'superdoc', 'src', 'assets', 'styles', 'elements', 'superdoc.css'), + 'utf8', +); + +const editorScopedCss = readFileSync( + join(repoRoot, 'packages', 'super-editor', 'src', 'editors', 'v1', 'assets', 'styles', 'elements', 'prosemirror.css'), + 'utf8', +); + +const extractRuleBody = (css, selector) => { + const idx = css.indexOf(selector); + if (idx === -1) return null; + const open = css.indexOf('{', idx); + const close = css.indexOf('}', open); + if (open === -1 || close === -1) return null; + return css.slice(open + 1, close); +}; + +describe('search-match CSS precedence (SD-3045)', () => { + describe('packages/superdoc/src/assets/styles/elements/superdoc.css', () => { + it('`.superdoc .ProseMirror-search-match` background uses !important', () => { + const body = extractRuleBody(superdocCss, '.superdoc .ProseMirror-search-match'); + expect(body, '.superdoc .ProseMirror-search-match rule must exist').not.toBeNull(); + expect(body).toMatch(/background\s*:[^;]*!important/); + }); + + it('`.superdoc .ProseMirror-active-search-match` background uses !important', () => { + const body = extractRuleBody(superdocCss, '.superdoc .ProseMirror-active-search-match'); + expect(body, '.superdoc .ProseMirror-active-search-match rule must exist').not.toBeNull(); + expect(body).toMatch(/background\s*:[^;]*!important/); + }); + }); + + describe('packages/super-editor/.../prosemirror.css', () => { + it('`.sd-editor-scoped .ProseMirror-search-match` background-color uses !important', () => { + const body = extractRuleBody(editorScopedCss, '.sd-editor-scoped .ProseMirror-search-match'); + expect(body, '.sd-editor-scoped .ProseMirror-search-match rule must exist').not.toBeNull(); + expect(body).toMatch(/background-color\s*:[^;]*!important/); + }); + + it('`.sd-editor-scoped .ProseMirror-active-search-match` background-color uses !important', () => { + const body = extractRuleBody(editorScopedCss, '.sd-editor-scoped .ProseMirror-active-search-match'); + expect(body, '.sd-editor-scoped .ProseMirror-active-search-match rule must exist').not.toBeNull(); + expect(body).toMatch(/background-color\s*:[^;]*!important/); + }); + }); + + describe('JSDOM specificity sanity check', () => { + it('class-level `background !important` beats inline `style="background-color: white"`', () => { + const styleEl = document.createElement('style'); + styleEl.textContent = `.search-test { background: rgba(255, 213, 0, 0.4) !important; }`; + document.head.appendChild(styleEl); + + const span = document.createElement('span'); + span.className = 'search-test'; + span.setAttribute('style', 'background-color: rgb(255, 255, 255);'); + document.body.appendChild(span); + + const bg = getComputedStyle(span).backgroundColor; + + styleEl.remove(); + span.remove(); + + expect(bg).toBe('rgba(255, 213, 0, 0.4)'); + }); + + it('without !important, inline `background-color: white` overrides class background', () => { + const styleEl = document.createElement('style'); + styleEl.textContent = `.search-test-noimp { background: rgba(255, 213, 0, 0.4); }`; + document.head.appendChild(styleEl); + + const span = document.createElement('span'); + span.className = 'search-test-noimp'; + span.setAttribute('style', 'background-color: rgb(255, 255, 255);'); + document.body.appendChild(span); + + const bg = getComputedStyle(span).backgroundColor; + + styleEl.remove(); + span.remove(); + + expect(bg).toBe('rgb(255, 255, 255)'); + }); + }); +}); diff --git a/packages/superdoc/src/assets/styles/elements/superdoc.css b/packages/superdoc/src/assets/styles/elements/superdoc.css index 1686029510..eff079537d 100644 --- a/packages/superdoc/src/assets/styles/elements/superdoc.css +++ b/packages/superdoc/src/assets/styles/elements/superdoc.css @@ -40,12 +40,21 @@ /* --- Search highlights (transient, used in both editor and presentation mode) --- */ +/* AIDEV-NOTE: SD-3045. The DomPainter writes `style.backgroundColor = run.highlight` + * inline on the same span the search DecorationBridge tags. Inline styles win over + * class selectors, so on every run whose source rPr carries a highlight mark + * (e.g. ``), the search highlight was invisible — + * the inline background painted over the class's transient yellow/orange. + * `!important` is the desired semantic here: while a search session is live, the + * find-match 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. */ .superdoc .ProseMirror-search-match { - background: var(--sd-ui-search-match-bg); + background: var(--sd-ui-search-match-bg) !important; } .superdoc .ProseMirror-active-search-match { - background: var(--sd-ui-search-match-active-bg); + background: var(--sd-ui-search-match-active-bg) !important; } /* Contained Mode - fixed-height container embedding with internal scrolling */