Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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. `<w:highlight w:val="white"/>`).
// 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)');
});
});
});
13 changes: 11 additions & 2 deletions packages/superdoc/src/assets/styles/elements/superdoc.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<w:highlight w:val="white"/>`), 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 */
Expand Down
Loading
Loading