Skip to content

Commit 633849a

Browse files
authored
Merge pull request #3572 from superdoc-dev/caio/sd-3315-find-replace-scroll
fix(search): don't re-center visible matches on find navigation (SD-3315)
2 parents 1c70b9b + 0add889 commit 633849a

4 files changed

Lines changed: 273 additions & 12 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,16 @@ export class PresentationEditor extends EventEmitter {
481481
* this unset so they don't fight the user's scroll position.
482482
*/
483483
#shouldScrollSelectionIntoView = false;
484+
/**
485+
* SD-3315: while a search-owned scrollToPosition({ suppressSelectionSyncScroll: true }) is in
486+
* flight (set before its sync scroll, cleared in its RAF re-assert), selection-sync must NOT
487+
* scroll the viewport. Find navigation owns the scroll for that window; the spurious
488+
* selectionUpdate fired by the find-input focus restore (which reverts the editor selection to
489+
* its pre-search caret) would otherwise yank the viewport to that stale caret, producing a
490+
* jump/flash on every navigation. The selection overlay still renders during the window; only
491+
* #scrollActiveEndIntoView is skipped.
492+
*/
493+
#suppressSelectionScrollUntilRaf = false;
484494
/** PM position for transient drag/drop insertion preview, rendered even while editor focus is elsewhere. */
485495
#dragDropIndicatorPos: number | null = null;
486496
#epochMapper = new EpochPositionMapper();
@@ -3501,6 +3511,26 @@ export class PresentationEditor extends EventEmitter {
35013511
return null;
35023512
}
35033513

3514+
/**
3515+
* Whether an element is fully within the vertical bounds of the active scroll container.
3516+
* Used by scrollToPosition's `ifNeeded` mode (SD-3315) to avoid moving the viewport for a
3517+
* target that is already visible. Measures with getBoundingClientRect because inline match
3518+
* spans report clientHeight 0. Vertical-only: search navigation is a block-axis concern.
3519+
*/
3520+
#isElementFullyVisibleInScrollContainer(el: Element): boolean {
3521+
const rect = el.getBoundingClientRect();
3522+
const viewport =
3523+
this.#scrollContainer instanceof Window
3524+
? { top: 0, bottom: this.#scrollContainer.innerHeight }
3525+
: this.#scrollContainer instanceof Element
3526+
? this.#scrollContainer.getBoundingClientRect()
3527+
: this.#visibleHost?.ownerDocument?.defaultView
3528+
? { top: 0, bottom: this.#visibleHost.ownerDocument.defaultView.innerHeight }
3529+
: null;
3530+
if (!viewport) return false;
3531+
return rect.top >= viewport.top && rect.bottom <= viewport.bottom;
3532+
}
3533+
35043534
/**
35053535
* Scroll the visible host so a given document position is brought into view.
35063536
*
@@ -3512,11 +3542,22 @@ export class PresentationEditor extends EventEmitter {
35123542
* @param options - Scrolling options
35133543
* @param options.block - Alignment within the viewport ('start' | 'center' | 'end' | 'nearest')
35143544
* @param options.behavior - Scroll behavior ('auto' | 'smooth')
3545+
* @param options.ifNeeded - When true, skip movement if the target is already fully visible
3546+
* (downgrades to 'nearest'); off-screen targets still use `block`. Used by search navigation.
3547+
* @param options.suppressSelectionSyncScroll - When true, selection-sync auto-scroll is
3548+
* suppressed until this scroll's RAF re-assert runs, so it cannot fight this intentional
3549+
* scroll. Used by search navigation, whose find-input focus restore otherwise scrolls the
3550+
* viewport to a reverted/stale caret.
35153551
* @returns True if the position could be mapped and scrolling was applied
35163552
*/
35173553
scrollToPosition(
35183554
pos: number,
3519-
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3555+
options: {
3556+
block?: 'start' | 'center' | 'end' | 'nearest';
3557+
behavior?: ScrollBehavior;
3558+
ifNeeded?: boolean;
3559+
suppressSelectionSyncScroll?: boolean;
3560+
} = {},
35203561
): boolean {
35213562
// Cancel any pending focus-scroll RAF so this intentional scroll is not undone
35223563
// by the wrapOffscreenEditorFocus safety net (e.g. search navigation after focus).
@@ -3534,7 +3575,9 @@ export class PresentationEditor extends EventEmitter {
35343575
const clampedPos = Math.max(0, Math.min(pos, doc.content.size));
35353576

35363577
const behavior = options.behavior ?? 'auto';
3537-
const block = options.block ?? 'center';
3578+
// SD-3315: the caller's requested landing. In ifNeeded mode an already-visible match
3579+
// downgrades this to 'nearest' (computed per-target below) so it does not re-center.
3580+
const requestedBlock = options.block ?? 'center';
35383581

35393582
// Use a DOM marker + scrollIntoView so the browser finds the correct scroll container
35403583
// (window, parent overflow container, etc.) without us guessing.
@@ -3561,6 +3604,21 @@ export class PresentationEditor extends EventEmitter {
35613604
// Find the specific element containing this position for precise centering
35623605
const targetEl = this.#findElementAtPosition(pageEl, clampedPos);
35633606
const elToScroll = targetEl ?? pageEl;
3607+
3608+
// SD-3315: "scroll only if needed" mode for search navigation. When the caller
3609+
// opts in and we resolved the precise target element (the match span, not the
3610+
// page-div fallback), and that element is already fully inside the scroll
3611+
// container, downgrade the scroll to 'nearest' — a no-op for a fully-visible
3612+
// element — so next/previous does not re-center an already-visible match (the
3613+
// ~50px jump). We deliberately do NOT early-return: the scrollIntoView + RAF
3614+
// re-assert below also override the hidden editor's selection-sync scroll
3615+
// (dispatched .scrollIntoView()), which otherwise jumps the viewport to the
3616+
// hidden editor's geometry. A null targetEl (page fallback) or an off-screen /
3617+
// partially-clipped match keeps the requested block (center).
3618+
const block =
3619+
options.ifNeeded && targetEl && this.#isElementFullyVisibleInScrollContainer(targetEl)
3620+
? 'nearest'
3621+
: requestedBlock;
35643622
elToScroll.scrollIntoView({ block, inline: 'nearest', behavior });
35653623
// AIDEV-NOTE: SD-3045. Search nav (and any other caller of
35663624
// scrollToPosition) places the viewport intentionally — usually
@@ -3586,9 +3644,16 @@ export class PresentationEditor extends EventEmitter {
35863644
// and is cheap.
35873645
const win = this.#visibleHost.ownerDocument?.defaultView;
35883646
if (win) {
3647+
// SD-3315: own the scroll until the RAF re-assert. The find-input focus restore fires
3648+
// a selectionUpdate that reverts the editor selection and would selection-sync-scroll
3649+
// the viewport to that stale caret before this RAF runs. Suppress that here and
3650+
// release after re-asserting, so normal selection scroll resumes next frame. Paired
3651+
// with the RAF below (set inside `if (win)` so it is always cleared).
3652+
if (options.suppressSelectionSyncScroll) this.#suppressSelectionScrollUntilRaf = true;
35893653
win.requestAnimationFrame(() => {
35903654
elToScroll.scrollIntoView({ block, inline: 'nearest', behavior });
35913655
this.#shouldScrollSelectionIntoView = false;
3656+
this.#suppressSelectionScrollUntilRaf = false;
35923657
});
35933658
}
35943659
return true;
@@ -3764,11 +3829,19 @@ export class PresentationEditor extends EventEmitter {
37643829
* @param options - Scrolling options
37653830
* @param options.block - Alignment within the viewport ('start' | 'center' | 'end' | 'nearest')
37663831
* @param options.behavior - Scroll behavior ('auto' | 'smooth')
3832+
* @param options.ifNeeded - When true, skip movement if the target is already fully visible
3833+
* (downgrades to 'nearest'); off-screen targets still use `block`. Used by search navigation.
3834+
* @param options.suppressSelectionSyncScroll - Forwarded to scrollToPosition; see there.
37673835
* @returns Promise resolving to true if scrolling succeeded, false otherwise
37683836
*/
37693837
async scrollToPositionAsync(
37703838
pos: number,
3771-
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3839+
options: {
3840+
block?: 'start' | 'center' | 'end' | 'nearest';
3841+
behavior?: ScrollBehavior;
3842+
ifNeeded?: boolean;
3843+
suppressSelectionSyncScroll?: boolean;
3844+
} = {},
37723845
): Promise<boolean> {
37733846
// Fast path: try sync scroll first (works if page already mounted)
37743847
if (this.scrollToPosition(pos, options)) {
@@ -3811,8 +3884,12 @@ export class PresentationEditor extends EventEmitter {
38113884
return false;
38123885
}
38133886

3814-
// Retry now that page is mounted
3815-
return this.scrollToPosition(pos, options);
3887+
// Retry now that page is mounted. Reaching this path means the target was on an unmounted
3888+
// (off-screen) page at call time, and #scrollPageIntoView above only scrolled the page into
3889+
// view — not the specific match, which can now sit at a viewport edge. Force ifNeeded:false so
3890+
// the match centers, instead of letting the now-edge-visible match downgrade to 'nearest' and
3891+
// skip centering (SD-3315 review). suppressSelectionSyncScroll is preserved via the spread.
3892+
return this.scrollToPosition(pos, { ...options, ifNeeded: false });
38163893
}
38173894

38183895
/**
@@ -3916,7 +3993,9 @@ export class PresentationEditor extends EventEmitter {
39163993
async focusContentControl(
39173994
entityId: string,
39183995
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3919-
): Promise<{ success: true } | { success: false; reason: 'not-ready' | 'invalid-id' | 'not-found' | 'not-reachable' }> {
3996+
): Promise<
3997+
{ success: true } | { success: false; reason: 'not-ready' | 'invalid-id' | 'not-found' | 'not-reachable' }
3998+
> {
39203999
const editor = this.#editor;
39214000
if (!editor) return { success: false, reason: 'not-ready' };
39224001
if (typeof entityId !== 'string' || entityId.length === 0) return { success: false, reason: 'invalid-id' };
@@ -7331,6 +7410,12 @@ export class PresentationEditor extends EventEmitter {
73317410
* page into view to trigger mount; the next selection update handles precise scroll.
73327411
*/
73337412
#scrollActiveEndIntoView(pageIndex: number): void {
7413+
// SD-3315: a search-owned scroll is in flight (find next/previous). Do not let selection-sync
7414+
// scroll the viewport to the (reverted/stale) caret — the search scroll and its RAF re-assert
7415+
// own positioning for this window. The selection overlay still renders in #updateSelection;
7416+
// only this scroll is skipped. Cleared on the search scroll's RAF, so normal keyboard/pointer
7417+
// selection scroll resumes the next frame.
7418+
if (this.#suppressSelectionScrollUntilRaf) return;
73347419
// Check if the target page is mounted before trusting rendered element positions.
73357420
const pageIsMounted = !!this.#painterHost.querySelector(`[data-page-index="${pageIndex}"]`);
73367421
if (!pageIsMounted) {

packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,51 @@ describe('PresentationEditor - scrollToPosition', () => {
649649
expect(result).toBe(true);
650650
});
651651

652+
it('centers a virtualized match after mount even with ifNeeded (SD-3315)', async () => {
653+
// Reaching the async/mount path means the target was off-screen at call time, so it should
654+
// center once mounted. The post-mount retry forces ifNeeded:false; without that, a match
655+
// that ends up edge-visible after the page scrolls into view would downgrade to 'nearest'
656+
// and skip centering. Pin innerHeight so the span counts as "visible" (where the old
657+
// forwarded ifNeeded:true would have downgraded).
658+
const originalInnerHeight = window.innerHeight;
659+
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
660+
try {
661+
editor = new PresentationEditor({ element: container, documentId: 'test-doc' });
662+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
663+
await new Promise((resolve) => setTimeout(resolve, 50));
664+
665+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
666+
const span = document.createElement('span');
667+
span.dataset.pmStart = '140';
668+
span.dataset.pmEnd = '160';
669+
span.scrollIntoView = vi.fn();
670+
span.getBoundingClientRect = vi.fn(
671+
() => ({ top: 100, bottom: 120, left: 0, right: 0, width: 0, height: 20 }) as DOMRect,
672+
);
673+
// Mount page index 1 (covers pos 150) after the async call starts.
674+
setTimeout(() => {
675+
const mockPage = document.createElement('div');
676+
mockPage.setAttribute('data-page-index', '1');
677+
mockPage.scrollIntoView = vi.fn();
678+
mockPage.appendChild(span);
679+
pagesHost.appendChild(mockPage);
680+
}, 100);
681+
682+
const result = await editor.scrollToPositionAsync(150, {
683+
block: 'center',
684+
ifNeeded: true,
685+
suppressSelectionSyncScroll: true,
686+
});
687+
688+
expect(result).toBe(true);
689+
// Post-mount retry must center the now-visible match, not downgrade to 'nearest'.
690+
expect(span.scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest', behavior: 'auto' });
691+
expect(span.scrollIntoView).not.toHaveBeenCalledWith(expect.objectContaining({ block: 'nearest' }));
692+
} finally {
693+
Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, configurable: true });
694+
}
695+
});
696+
652697
it('should return false and warn when page fails to mount within timeout', async () => {
653698
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
654699

@@ -672,4 +717,107 @@ describe('PresentationEditor - scrollToPosition', () => {
672717
consoleWarnSpy.mockRestore();
673718
});
674719
});
720+
721+
// SD-3315: "scroll only if needed" mode for search navigation. When the precise match
722+
// element is already fully visible, scrolling is downgraded to 'nearest' (a no-op for a
723+
// fully-visible element) so next/previous does not re-center an already-visible match.
724+
// Off-screen matches keep the requested 'center'. The visibility check measures the match
725+
// element against the scroll container; with no scrollable ancestor the container is the
726+
// window, so we pin innerHeight for determinism.
727+
describe('scrollToPosition — ifNeeded (SD-3315)', () => {
728+
let originalInnerHeight: number;
729+
730+
beforeEach(() => {
731+
originalInnerHeight = window.innerHeight;
732+
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
733+
});
734+
735+
afterEach(() => {
736+
Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, configurable: true });
737+
});
738+
739+
/** Build a mounted page (index 0) with a match span at pos 50 whose rect we control. */
740+
async function setupMatchSpan(rect: { top: number; bottom: number }) {
741+
editor = new PresentationEditor({ element: container, documentId: 'test-doc' });
742+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
743+
await new Promise((resolve) => setTimeout(resolve, 50));
744+
745+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
746+
const mockPage = document.createElement('div');
747+
mockPage.setAttribute('data-page-index', '0');
748+
mockPage.scrollIntoView = vi.fn();
749+
750+
const span = document.createElement('span');
751+
span.dataset.pmStart = '45';
752+
span.dataset.pmEnd = '55';
753+
span.scrollIntoView = vi.fn();
754+
span.getBoundingClientRect = vi.fn(
755+
() =>
756+
({
757+
top: rect.top,
758+
bottom: rect.bottom,
759+
left: 0,
760+
right: 0,
761+
width: 0,
762+
height: rect.bottom - rect.top,
763+
}) as DOMRect,
764+
);
765+
mockPage.appendChild(span);
766+
pagesHost.appendChild(mockPage);
767+
return { span, mockPage };
768+
}
769+
770+
it('downgrades to nearest (no re-center) when the match is already fully visible', async () => {
771+
const { span } = await setupMatchSpan({ top: 100, bottom: 120 }); // inside 0..800
772+
773+
const result = editor.scrollToPosition(50, { block: 'center', ifNeeded: true });
774+
775+
expect(result).toBe(true);
776+
expect(span.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
777+
expect(span.scrollIntoView).not.toHaveBeenCalledWith(expect.objectContaining({ block: 'center' }));
778+
});
779+
780+
it('centers when the match is off-screen, even in ifNeeded mode', async () => {
781+
const { span } = await setupMatchSpan({ top: 1000, bottom: 1020 }); // below 800 → off-screen
782+
783+
const result = editor.scrollToPosition(50, { block: 'center', ifNeeded: true });
784+
785+
expect(result).toBe(true);
786+
expect(span.scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest', behavior: 'auto' });
787+
});
788+
789+
it('centers when the match is partially clipped at the bottom edge', async () => {
790+
const { span } = await setupMatchSpan({ top: 790, bottom: 815 }); // top in view, bottom past 800
791+
792+
editor.scrollToPosition(50, { block: 'center', ifNeeded: true });
793+
794+
expect(span.scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest', behavior: 'auto' });
795+
});
796+
797+
it('does not change default behavior: a fully visible match still centers without ifNeeded', async () => {
798+
const { span } = await setupMatchSpan({ top: 100, bottom: 120 });
799+
800+
editor.scrollToPosition(50, { block: 'center' });
801+
802+
expect(span.scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest', behavior: 'auto' });
803+
});
804+
805+
it('centers via the page fallback (no precise match element) even in ifNeeded mode', async () => {
806+
editor = new PresentationEditor({ element: container, documentId: 'test-doc' });
807+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
808+
await new Promise((resolve) => setTimeout(resolve, 50));
809+
810+
const pagesHost = container.querySelector('.presentation-editor__pages') as HTMLElement;
811+
const mockPage = document.createElement('div');
812+
mockPage.setAttribute('data-page-index', '0');
813+
mockPage.scrollIntoView = vi.fn();
814+
// No span with pm data → #findElementAtPosition returns null → page fallback, not measurable.
815+
pagesHost.appendChild(mockPage);
816+
817+
const result = editor.scrollToPosition(50, { block: 'center', ifNeeded: true });
818+
819+
expect(result).toBe(true);
820+
expect(mockPage.scrollIntoView).toHaveBeenCalledWith({ block: 'center', inline: 'nearest', behavior: 'auto' });
821+
});
822+
});
675823
});

packages/super-editor/src/editors/v1/extensions/search/search.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,13 +577,22 @@ export const Search = Extension.create({
577577
if (dispatch) dispatch(tr);
578578

579579
const presentationEditor = editor.presentationEditor;
580+
// SD-3315: per-match navigation uses "scroll only if needed" — an already-visible
581+
// match must not be re-centered (the ~50px jump). When the match is off-screen or
582+
// partially clipped, scrollToPosition falls back to its normal block:'center' landing.
583+
// suppressSelectionSyncScroll makes search nav own the scroll across the find-input
584+
// focus-restore cycle: that focus blur fires a selectionUpdate which reverts the editor
585+
// selection to its pre-search caret, and selection-sync would otherwise scroll the
586+
// viewport to that stale caret — a jump/flash on every navigation. goToFirstMatch keeps
587+
// plain centering for the initial jump.
588+
const scrollOpts = { block: 'center', ifNeeded: true, suppressSelectionSyncScroll: true };
580589
// Try sync scroll first — returns true when the page is mounted and in body mode.
581-
const scrolled = presentationEditor?.scrollToPosition?.(from, { block: 'center' }) ?? false;
590+
const scrolled = presentationEditor?.scrollToPosition?.(from, scrollOpts) ?? false;
582591

583592
if (!scrolled) {
584593
// Async version handles virtualized (un-mounted) pages; fire-and-forget
585594
// because it will scroll once the target page mounts.
586-
Promise.resolve(presentationEditor?.scrollToPositionAsync?.(from, { block: 'center' })).catch(() => {});
595+
Promise.resolve(presentationEditor?.scrollToPositionAsync?.(from, scrollOpts)).catch(() => {});
587596

588597
// DOM fallback for non-presentation contexts or when presentation
589598
// scroll cannot run (e.g. header/footer mode, no layout).

0 commit comments

Comments
 (0)