Skip to content

Commit 338c211

Browse files
committed
fix(live-preview): suppress md viewer ↔ CM scroll feedback loops
Selecting/clicking/editing in the md viewer caused the iframe to scroll itself to a stale line, and undo/redo plus backspace caused visible scroll jumps. Root cause was missing suppression on both sides of the sync bridge: - The iframe side: viewer-initiated events that can cause CM to scroll (selection sync, cursor-line sync, click-to-focus) didn't mark the iframe as the scroll origin, so CM's resulting scroll-handler echo came back through MDVIEWR_SCROLL_TO_LINE and re-scrolled the iframe. Centralize the flag in sendToParent for all such events, and drop the editMode-only requirement from the guard so design mode is covered too. - The CM side: cm.replaceRange (in _applyDiffToEditor), cm.scrollTo (in _scrollCMToLine), and cm.undo()/cm.redo() can each trigger an async CM scroll that echoes back to the iframe. Wrap each in _scrollSyncFromIframe = true with a 200ms clear so CM's scroll handler ignores them.
1 parent 3a46cea commit 338c211

2 files changed

Lines changed: 41 additions & 3 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ let _lastReceivedSyncId = -1;
1515
let _suppressContentChange = false;
1616
let _scrollFromCM = false;
1717
let _scrollFromViewer = false;
18+
let _scrollFromViewerTimer = null;
1819
let _suppressScrollToLine = false;
1920
let _baseURL = "";
2021
let _cursorPosBeforeEdit = null; // cursor position before current edit batch
@@ -513,6 +514,11 @@ export function initBridge() {
513514
}
514515
if (bestEl) {
515516
const sourceLine = parseInt(bestEl.getAttribute("data-source-line"), 10);
517+
// Mark the viewer as the scroll origin so CM's echo back
518+
// through handleScrollToLine is suppressed.
519+
_scrollFromViewer = true;
520+
if (_scrollFromViewerTimer) clearTimeout(_scrollFromViewerTimer);
521+
_scrollFromViewerTimer = setTimeout(() => { _scrollFromViewer = false; }, 400);
516522
sendToParent("mdviewrScrollSync", { sourceLine, fromScroll: true });
517523
}
518524
});
@@ -1057,9 +1063,9 @@ function handleScrollToLine(data) {
10571063
// Suppress during file switch — doc cache restores the correct scroll
10581064
if (_suppressScrollToLine) return;
10591065

1060-
// In edit mode, ignore scroll-based sync that originated from the viewer
1061-
// itself (feedback loop: viewer click → CM scroll → scroll sync back).
1062-
if (fromScroll && getState().editMode && _scrollFromViewer) return;
1066+
// Ignore scroll-based sync that originated from the viewer itself
1067+
// (feedback loop: viewer scroll/click → CM scroll → scroll sync back).
1068+
if (fromScroll && _scrollFromViewer) return;
10631069

10641070
const viewer = document.getElementById("viewer-content");
10651071
if (!viewer) return;
@@ -1401,8 +1407,23 @@ function _sendSelectionToParent() {
14011407
}, 200);
14021408
}
14031409

1410+
// Events the iframe initiates that can cause CM to scroll. We mark the iframe
1411+
// as the scroll origin so CM's resulting scroll-handler echo (forwarded back as
1412+
// MDVIEWR_SCROLL_TO_LINE) gets suppressed by the guard in handleScrollToLine.
1413+
const _viewerInitiatedScrollEvents = new Set([
1414+
"mdviewrScrollSync",
1415+
"mdviewrCursorLine",
1416+
"mdviewrSelectionSync",
1417+
"embeddedIframeFocusEditor"
1418+
]);
1419+
14041420
function sendToParent(eventName, payload) {
14051421
if (!window.parent || window.parent === window) return;
1422+
if (_viewerInitiatedScrollEvents.has(eventName)) {
1423+
_scrollFromViewer = true;
1424+
if (_scrollFromViewerTimer) clearTimeout(_scrollFromViewerTimer);
1425+
_scrollFromViewerTimer = setTimeout(() => { _scrollFromViewer = false; }, 400);
1426+
}
14061427
window.parent.postMessage({
14071428
type: "MDVIEWR_EVENT",
14081429
eventName,

src/extensionsIntegrated/Phoenix-live-preview/MarkdownSync.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,8 +578,14 @@ define(function (require, exports, module) {
578578
const replacement = newText.substring(prefixLen, newSuffix);
579579

580580
_syncingFromIframe = true;
581+
// Suppress CM's scroll-handler echo for a brief window: replaceRange
582+
// can trigger an asynchronous CM scroll (e.g. when content shrinks or
583+
// cursor is auto-scrolled into view) which would otherwise feed back to
584+
// the iframe and snap it to a stale "first visible line".
585+
_scrollSyncFromIframe = true;
581586
cm.replaceRange(replacement, fromPos, toPos, "+mdviewr");
582587
_syncingFromIframe = false;
588+
setTimeout(function () { _scrollSyncFromIframe = false; }, 200);
583589
}
584590

585591
function _onIframeContentChanged(data) {
@@ -648,7 +654,12 @@ define(function (require, exports, module) {
648654
_cursorRedoStack.push(pos);
649655
_pendingCursorPos = pos;
650656
}
657+
// cm.undo() can move the cursor and scroll it into view, which
658+
// triggers CM's scroll handler and echoes back to the iframe as
659+
// a fromScroll sync — causing visible scroll jumps. Suppress.
660+
_scrollSyncFromIframe = true;
651661
cm.undo();
662+
setTimeout(function () { _scrollSyncFromIframe = false; }, 200);
652663
}
653664
}
654665

@@ -663,7 +674,9 @@ define(function (require, exports, module) {
663674
_cursorUndoStack.push(pos);
664675
_pendingCursorPos = pos;
665676
}
677+
_scrollSyncFromIframe = true;
666678
cm.redo();
679+
setTimeout(function () { _scrollSyncFromIframe = false; }, 200);
667680
}
668681
}
669682

@@ -862,7 +875,11 @@ define(function (require, exports, module) {
862875

863876
if (lineTop < viewTop || lineBottom > viewBottom) {
864877
const targetScrollTop = lineTop - (scrollInfo.clientHeight / 2);
878+
// Suppress CM's scroll-handler echo while we scroll programmatically
879+
// — otherwise the echo loops back to the iframe and re-scrolls it.
880+
_scrollSyncFromIframe = true;
865881
cm.scrollTo(null, targetScrollTop);
882+
setTimeout(function () { _scrollSyncFromIframe = false; }, 200);
866883
}
867884

868885
// Brief flash on the CM line to show cursor sync feedback

0 commit comments

Comments
 (0)