Skip to content

Commit 0ef3462

Browse files
committed
fix(mdviewer): code block scroll jump, br line sync, lang-picker fixes
- Fix scroll jump when clicking in code blocks: remove data-source-line from <pre> in early-return path of _annotateCodeBlockLines - Fix scroll-to-top on click in edit mode: block fromScroll sync from CM to prevent feedback loop (click → CM scroll → scroll sync back → jump) - Add per-line highlight for <br> paragraphs: wrap specific line content in cursor-sync-highlight span instead of highlighting whole <p> - Track _lastHighlightTargetLine for accurate re-apply after re-renders - Fix _getSourceLineFromElement to count <br> before cursor for exact line - Lang-picker: reposition upward when dropdown clipped at bottom - Lang-picker: fix blur with transform:none when visible
1 parent 47dd18e commit 0ef3462

4 files changed

Lines changed: 123 additions & 15 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,11 @@ function handleScrollToLine(data) {
958958
const { line, fromScroll, tableCol } = data;
959959
if (line == null) return;
960960

961+
// In edit mode, ignore scroll-based sync from CM to prevent feedback
962+
// loops (click in viewer → CM scroll → scroll sync back → viewer jumps).
963+
// Only cursor-based sync (fromScroll=false) should reposition the viewer.
964+
if (fromScroll && getState().editMode) return;
965+
961966
const viewer = document.getElementById("viewer-content");
962967
if (!viewer) return;
963968

@@ -983,42 +988,104 @@ function handleScrollToLine(data) {
983988
}
984989
}
985990

991+
// For paragraphs with <br> (soft line breaks), find the specific visual
992+
// line within the paragraph by counting <br> elements. The target line
993+
// minus the paragraph's start line gives the <br> offset.
994+
let scrollTarget = bestEl;
995+
if (bestEl.tagName === "P" && bestLine < line) {
996+
const brOffset = line - bestLine;
997+
const brs = bestEl.querySelectorAll("br");
998+
if (brOffset > 0 && brOffset <= brs.length) {
999+
// Use the <br> element as scroll target — it's at the right
1000+
// vertical position for the specific line within the paragraph.
1001+
scrollTarget = brs[brOffset - 1];
1002+
}
1003+
}
1004+
9861005
const container = document.getElementById("app-viewer");
9871006
if (!container) return;
9881007
const containerRect = container.getBoundingClientRect();
989-
const elRect = bestEl.getBoundingClientRect();
1008+
const elRect = scrollTarget.getBoundingClientRect();
9901009

9911010
// Suppress viewer→CM scroll feedback for any CM-initiated scroll
9921011
_scrollFromCM = true;
9931012
if (fromScroll) {
9941013
// Sync scroll: always align to top, even if visible
995-
bestEl.scrollIntoView({ behavior: "instant", block: "start" });
1014+
scrollTarget.scrollIntoView({ behavior: "instant", block: "start" });
9961015
} else {
9971016
// Cursor-based scroll: only scroll if not visible, center it
9981017
const isVisible = elRect.top >= containerRect.top && elRect.bottom <= containerRect.bottom;
9991018
if (!isVisible) {
1000-
bestEl.scrollIntoView({ behavior: "instant", block: "center" });
1019+
scrollTarget.scrollIntoView({ behavior: "instant", block: "center" });
10011020
}
10021021
}
10031022
setTimeout(() => { _scrollFromCM = false; }, 200);
10041023

10051024
// Persistent highlight on the element corresponding to the CM cursor.
10061025
// Only show when CM has focus (not when viewer has focus).
1007-
const prev = viewer.querySelector(".cursor-sync-highlight");
1008-
if (prev) { prev.classList.remove("cursor-sync-highlight"); }
1009-
bestEl.classList.add("cursor-sync-highlight");
1026+
_removeCursorHighlight(viewer);
1027+
1028+
// For <br> paragraphs, wrap only the specific line's content in a
1029+
// highlight span instead of highlighting the whole <p>.
1030+
if (bestEl.tagName === "P" && bestEl.querySelector("br")) {
1031+
const brOffset = line - bestLine;
1032+
const brs = bestEl.querySelectorAll("br");
1033+
const span = document.createElement("span");
1034+
span.className = "cursor-sync-highlight cursor-sync-br-line";
1035+
if (brOffset === 0) {
1036+
// First line: wrap nodes before the first <br>
1037+
let node = bestEl.firstChild;
1038+
while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) {
1039+
const toMove = node;
1040+
node = node.nextSibling;
1041+
span.appendChild(toMove);
1042+
}
1043+
bestEl.insertBefore(span, bestEl.firstChild);
1044+
} else if (brOffset > 0 && brOffset <= brs.length) {
1045+
// Subsequent lines: wrap nodes after the target <br>
1046+
const targetBr = brs[brOffset - 1];
1047+
let next = targetBr.nextSibling;
1048+
while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) {
1049+
const toMove = next;
1050+
next = next.nextSibling;
1051+
span.appendChild(toMove);
1052+
}
1053+
targetBr.parentNode.insertBefore(span, targetBr.nextSibling);
1054+
} else {
1055+
bestEl.classList.add("cursor-sync-highlight");
1056+
}
1057+
} else {
1058+
bestEl.classList.add("cursor-sync-highlight");
1059+
}
10101060
_lastHighlightSourceLine = bestLine;
1061+
_lastHighlightTargetLine = line;
1062+
}
1063+
1064+
function _removeCursorHighlight(viewer) {
1065+
const prev = viewer.querySelector(".cursor-sync-highlight");
1066+
if (!prev) return;
1067+
// If highlight was a wrapper span for a <br> line, unwrap it
1068+
if (prev.classList.contains("cursor-sync-br-line")) {
1069+
while (prev.firstChild) {
1070+
prev.parentNode.insertBefore(prev.firstChild, prev);
1071+
}
1072+
prev.remove();
1073+
} else {
1074+
prev.classList.remove("cursor-sync-highlight");
1075+
}
10111076
}
10121077

10131078
// Track last highlighted source line so we can re-apply after re-renders
10141079
let _lastHighlightSourceLine = null;
1080+
let _lastHighlightTargetLine = null;
10151081

10161082
function _reapplyCursorSyncHighlight() {
10171083
if (_lastHighlightSourceLine == null) return;
10181084
const viewer = document.getElementById("viewer-content");
10191085
if (!viewer) return;
10201086
// Don't re-apply if viewer has focus (user is editing in viewer)
10211087
if (viewer.contains(document.activeElement)) return;
1088+
_removeCursorHighlight(viewer);
10221089
const elements = viewer.querySelectorAll("[data-source-line]");
10231090
let bestEl = null;
10241091
let bestLine = -1;
@@ -1029,9 +1096,36 @@ function _reapplyCursorSyncHighlight() {
10291096
bestEl = el;
10301097
}
10311098
}
1032-
if (bestEl) {
1033-
bestEl.classList.add("cursor-sync-highlight");
1099+
if (!bestEl) return;
1100+
const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine;
1101+
// Handle <br> paragraph sub-line highlighting
1102+
if (bestEl.tagName === "P" && bestEl.querySelector("br")) {
1103+
const brOffset = targetLine - bestLine;
1104+
const brs = bestEl.querySelectorAll("br");
1105+
const span = document.createElement("span");
1106+
span.className = "cursor-sync-highlight cursor-sync-br-line";
1107+
if (brOffset === 0) {
1108+
let node = bestEl.firstChild;
1109+
while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) {
1110+
const toMove = node;
1111+
node = node.nextSibling;
1112+
span.appendChild(toMove);
1113+
}
1114+
bestEl.insertBefore(span, bestEl.firstChild);
1115+
return;
1116+
} else if (brOffset > 0 && brOffset <= brs.length) {
1117+
const targetBr = brs[brOffset - 1];
1118+
let next = targetBr.nextSibling;
1119+
while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) {
1120+
const toMove = next;
1121+
next = next.nextSibling;
1122+
span.appendChild(toMove);
1123+
}
1124+
targetBr.parentNode.insertBefore(span, targetBr.nextSibling);
1125+
return;
1126+
}
10341127
}
1128+
bestEl.classList.add("cursor-sync-highlight");
10351129
}
10361130

10371131
// Re-apply cursor sync highlight after content re-renders (e.g. typing in CM)
@@ -1044,8 +1138,7 @@ on("file:rendered", () => {
10441138
document.addEventListener("focusin", (e) => {
10451139
const viewer = document.getElementById("viewer-content");
10461140
if (viewer && viewer.contains(e.target)) {
1047-
const prev = viewer.querySelector(".cursor-sync-highlight");
1048-
if (prev) { prev.classList.remove("cursor-sync-highlight"); }
1141+
_removeCursorHighlight(viewer);
10491142
_lastHighlightSourceLine = null;
10501143
}
10511144
});

src-mdviewer/src/components/lang-picker.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,17 @@ function openDropdown() {
215215
dropdownOpen = true;
216216
filterQuery = "";
217217
const dropdown = picker.querySelector(".lang-picker-dropdown");
218-
if (dropdown) dropdown.classList.add("open");
218+
if (dropdown) {
219+
dropdown.classList.add("open");
220+
// If picker + dropdown would be clipped below, move picker up
221+
requestAnimationFrame(() => {
222+
const pickerRect = picker.getBoundingClientRect();
223+
if (pickerRect.bottom > window.innerHeight - 4) {
224+
const overflow = pickerRect.bottom - window.innerHeight + 8;
225+
picker.style.top = (parseFloat(picker.style.top) - overflow) + "px";
226+
}
227+
});
228+
}
219229
updateSearchDisplay();
220230
populateList("");
221231
}
@@ -239,11 +249,12 @@ function show(preEl) {
239249

240250
// Position near top-left of <pre>
241251
const rect = preEl.getBoundingClientRect();
252+
const pickerH = picker.offsetHeight || 32;
242253
const pickerW = picker.offsetWidth || 180;
243254
let left = rect.left;
244-
let top = rect.top - (picker.offsetHeight || 32) - 6;
255+
let top = rect.top - pickerH - 6;
245256

246-
// If too close to top, show below the pre's top edge
257+
// If not enough space above, show below the pre's top edge
247258
if (top < 4) {
248259
top = rect.top + 4;
249260
}

src-mdviewer/src/components/viewer.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,11 @@ export function _annotateCodeBlockLines() {
218218
if (isNaN(preSourceLine)) return; // can't verify, keep existing
219219
const expectedFirst = String(preSourceLine + 1);
220220
if (existingSpan.getAttribute("data-source-line") === expectedFirst) {
221-
return; // annotations are up to date
221+
// Annotations are up to date — still remove data-source-line
222+
// from <pre> so clicks on empty space don't report the block
223+
// start line instead of the per-line annotation.
224+
pre.removeAttribute("data-source-line");
225+
return;
222226
}
223227
// Stale annotations — unwrap them before re-annotating
224228
code.querySelectorAll("span[data-source-line]").forEach((span) => {

src-mdviewer/src/styles/editor.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@
496496
.lang-picker.visible {
497497
opacity: 1;
498498
pointer-events: auto;
499-
transform: translateY(0);
499+
transform: none;
500500
}
501501

502502
.lang-picker-trigger {

0 commit comments

Comments
 (0)