@@ -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 ) {
0 commit comments