Skip to content

Commit 5234f68

Browse files
committed
feat(mdviewer): add bidirectional synchronized scrolling
Scroll CM and md viewer in sync: scrolling one side scrolls the other to the matching source line position. Uses requestAnimationFrame for smooth real-time sync. Always aligns to matching position (not just when off-screen). Feedback loop prevention via flags with short timeouts. Respects cursor sync toggle. Viewer→CM scroll sends first visible data-source-line element. CM→viewer scroll sends first visible CM line.
1 parent e86e97f commit 5234f68

2 files changed

Lines changed: 114 additions & 6 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { broadcastSelectionStateSync } from "./components/editor.js";
1313
let _syncId = 0;
1414
let _lastReceivedSyncId = -1;
1515
let _suppressContentChange = false;
16+
let _scrollFromCM = false;
1617
let _baseURL = "";
1718
let _cursorPosBeforeEdit = null; // cursor position before current edit batch
1819
let _cursorPosDirty = false; // true after content changes, reset when emitted
@@ -340,6 +341,37 @@ export function initBridge() {
340341
}
341342
}, true);
342343

344+
// Scroll sync: when viewer scrolls, send first visible source line to CM
345+
let _viewerScrollRAF = null;
346+
const appViewer = document.getElementById("app-viewer");
347+
if (appViewer) {
348+
appViewer.addEventListener("scroll", () => {
349+
if (_scrollFromCM) return;
350+
if (_viewerScrollRAF) { cancelAnimationFrame(_viewerScrollRAF); }
351+
_viewerScrollRAF = requestAnimationFrame(() => {
352+
_viewerScrollRAF = null;
353+
const viewer = document.getElementById("viewer-content");
354+
if (!viewer) return;
355+
const viewerRect = appViewer.getBoundingClientRect();
356+
const elements = viewer.querySelectorAll("[data-source-line]");
357+
let bestEl = null;
358+
let bestDist = Infinity;
359+
for (const el of elements) {
360+
const rect = el.getBoundingClientRect();
361+
const dist = Math.abs(rect.top - viewerRect.top);
362+
if (dist < bestDist) {
363+
bestDist = dist;
364+
bestEl = el;
365+
}
366+
}
367+
if (bestEl) {
368+
const sourceLine = parseInt(bestEl.getAttribute("data-source-line"), 10);
369+
sendToParent("mdviewrScrollSync", { sourceLine, fromScroll: true });
370+
}
371+
});
372+
});
373+
}
374+
343375
// Listen for selection changes to sync selection back to CM
344376
// Also track cursor position for undo/redo restore
345377
document.addEventListener("selectionchange", () => {
@@ -808,7 +840,7 @@ function _getSourceLineFromElement(el) {
808840
}
809841

810842
function handleScrollToLine(data) {
811-
const { line } = data;
843+
const { line, fromScroll } = data;
812844
if (line == null) return;
813845

814846
const viewer = document.getElementById("viewer-content");
@@ -832,9 +864,17 @@ function handleScrollToLine(data) {
832864
const containerRect = container.getBoundingClientRect();
833865
const elRect = bestEl.getBoundingClientRect();
834866

835-
const isVisible = elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
836-
if (!isVisible) {
837-
bestEl.scrollIntoView({ behavior: "instant", block: "center" });
867+
if (fromScroll) {
868+
// Sync scroll: always align to top, even if visible
869+
_scrollFromCM = true;
870+
bestEl.scrollIntoView({ behavior: "instant", block: "start" });
871+
setTimeout(() => { _scrollFromCM = false; }, 200);
872+
} else {
873+
// Cursor-based scroll: only scroll if not visible, center it
874+
const isVisible = elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
875+
if (!isVisible) {
876+
bestEl.scrollIntoView({ behavior: "instant", block: "center" });
877+
}
838878
}
839879

840880
// Persistent highlight on the element corresponding to the CM cursor.

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

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)