@@ -46,6 +46,8 @@ define(function (require, exports, module) {
4646 let _cursorHandler = null ;
4747 let _focusHandler = null ;
4848 let _changeHandler = null ;
49+ let _scrollHandler = null ;
50+ let _scrollSyncFromIframe = false ; // prevents feedback loops
4951 let _onEditModeRequest = null ;
5052 let _onIframeReadyCallback = null ;
5153 let _cursorSyncEnabled = true ;
@@ -54,7 +56,7 @@ define(function (require, exports, module) {
5456 let _cursorRedoStack = [ ] ;
5557
5658 const DEBOUNCE_TO_IFRAME_MS = 150 ;
57- const SCROLL_SYNC_DEBOUNCE_MS = 100 ;
59+ const SCROLL_SYNC_DEBOUNCE_MS = 16 ;
5860 const SELECTION_SYNC_DEBOUNCE_MS = 200 ;
5961
6062 /**
@@ -144,7 +146,11 @@ define(function (require, exports, module) {
144146 break ;
145147 case "mdviewrScrollSync" :
146148 if ( _cursorSyncEnabled && data . sourceLine != null ) {
147- _scrollCMToLine ( data . sourceLine ) ;
149+ if ( data . fromScroll ) {
150+ _scrollCMToLineNoFeedback ( data . sourceLine ) ;
151+ } else {
152+ _scrollCMToLine ( data . sourceLine ) ;
153+ }
148154 }
149155 break ;
150156 case "mdviewrSelectionSync" :
@@ -227,6 +233,20 @@ define(function (require, exports, module) {
227233 cm . on ( "focus" , _focusHandler ) ;
228234 cm . off ( "change" , _changeHandler ) ;
229235 cm . on ( "change" , _changeHandler ) ;
236+ // Scroll sync: scroll CM → scroll iframe to matching source line (real-time)
237+ let _scrollRAF = null ;
238+ _scrollHandler = function ( ) {
239+ if ( _syncingFromIframe || _scrollSyncFromIframe || ! _cursorSyncEnabled || ! _iframeReady ) {
240+ return ;
241+ }
242+ if ( _scrollRAF ) { cancelAnimationFrame ( _scrollRAF ) ; }
243+ _scrollRAF = requestAnimationFrame ( function ( ) {
244+ _scrollRAF = null ;
245+ _syncScrollPositionToIframe ( ) ;
246+ } ) ;
247+ } ;
248+ cm . off ( "scroll" , _scrollHandler ) ;
249+ cm . on ( "scroll" , _scrollHandler ) ;
230250 }
231251
232252 // If iframe is already ready (reusing same iframe), switch file using cache
@@ -260,6 +280,9 @@ define(function (require, exports, module) {
260280 if ( _changeHandler ) {
261281 cm . off ( "change" , _changeHandler ) ;
262282 }
283+ if ( _scrollHandler ) {
284+ cm . off ( "scroll" , _scrollHandler ) ;
285+ }
263286 if ( _highlightLineHandle ) {
264287 cm . removeLineClass ( _highlightLineHandle , "background" , "cm-cursor-sync-highlight" ) ;
265288 _highlightLineHandle = null ;
@@ -289,6 +312,7 @@ define(function (require, exports, module) {
289312 _cursorHandler = null ;
290313 _focusHandler = null ;
291314 _changeHandler = null ;
315+ _scrollHandler = null ;
292316 }
293317
294318 /**
@@ -541,6 +565,50 @@ define(function (require, exports, module) {
541565 } , "*" ) ;
542566 }
543567
568+ /**
569+ * Scroll CM to a source line without triggering the CM scroll handler
570+ * (prevents viewer→CM→viewer feedback loop).
571+ */
572+ function _scrollCMToLineNoFeedback ( sourceLine ) {
573+ const cm = _getCM ( ) ;
574+ if ( ! cm ) { return ; }
575+ const cmLine = Math . max ( 0 , sourceLine - 1 ) ;
576+ if ( cmLine >= cm . lineCount ( ) ) { return ; }
577+
578+ _scrollSyncFromIframe = true ;
579+ // Always scroll to align the line at the top of the editor
580+ const lineTop = cm . charCoords ( { line : cmLine , ch : 0 } , "local" ) . top ;
581+ cm . scrollTo ( null , lineTop ) ;
582+ setTimeout ( function ( ) { _scrollSyncFromIframe = false ; } , 150 ) ;
583+ }
584+
585+ /**
586+ * Sync CM scroll position to iframe: find the first visible line in CM and
587+ * tell the iframe to scroll the corresponding element into view.
588+ */
589+ function _syncScrollPositionToIframe ( ) {
590+ if ( ! _active || ! _iframeReady ) {
591+ return ;
592+ }
593+ const iframeWindow = _getIframeWindow ( ) ;
594+ if ( ! iframeWindow ) {
595+ return ;
596+ }
597+ const cm = _getCM ( ) ;
598+ if ( ! cm ) {
599+ return ;
600+ }
601+ // Get the first visible line in the CM viewport
602+ const scrollInfo = cm . getScrollInfo ( ) ;
603+ const firstVisiblePos = cm . coordsChar ( { left : 0 , top : scrollInfo . top } , "local" ) ;
604+ const line = firstVisiblePos . line + 1 ; // 1-based source line
605+ iframeWindow . postMessage ( {
606+ type : "MDVIEWR_SCROLL_TO_LINE" ,
607+ line : line ,
608+ fromScroll : true // flag to prevent re-triggering CM scroll
609+ } , "*" ) ;
610+ }
611+
544612 /**
545613 * Parse the current CM line to determine the block type and formatting context,
546614 * then send it to the iframe so the toolbar can reflect CM cursor position.
0 commit comments