Skip to content

Commit 8c767ee

Browse files
committed
refactor(mdviewer): replace code block line annotations with \n counting
Remove _annotateCodeBlockLines entirely — per-line cursor sync in code blocks now uses \n counting at click time (same approach as <br> in paragraphs). This eliminates DOM mutation of code block content, avoids Prism re-highlight conflicts, and simplifies the codebase. - bridge.js: count \n before cursor in _getSourceLineFromElement for PRE - bridge.js: add positioned overlay div for per-line code block highlight - bridge.js: simplify _reapplyCursorSyncHighlight to reuse handleScrollToLine - bridge.js: temporary marker span for scroll-to-line in code blocks - editor.js: remove _annotateCodeBlockLines import and calls - viewer.js: delete _annotateCodeBlockLines function (~110 lines removed)
1 parent a1f49b7 commit 8c767ee

3 files changed

Lines changed: 153 additions & 189 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 147 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -936,14 +936,32 @@ function _getSourceLineFromElement(el) {
936936
const attr = el.getAttribute && el.getAttribute("data-source-line");
937937
if (attr != null) {
938938
let line = parseInt(attr, 10);
939-
// For paragraphs with <br> (soft line breaks), count how many
940-
// <br> elements precede the cursor to get the exact CM line.
941-
if (el.tagName === "P" && cursorNode && el.querySelector("br")) {
942-
const brs = el.querySelectorAll("br");
943-
for (const br of brs) {
944-
const pos = br.compareDocumentPosition(cursorNode);
945-
if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) {
946-
line++;
939+
if (cursorNode) {
940+
// For paragraphs with <br> (soft line breaks), count <br>
941+
// elements before the cursor for the exact CM line.
942+
if (el.tagName === "P" && el.querySelector("br")) {
943+
const brs = el.querySelectorAll("br");
944+
for (const br of brs) {
945+
const pos = br.compareDocumentPosition(cursorNode);
946+
if (pos & Node.DOCUMENT_POSITION_FOLLOWING || pos & Node.DOCUMENT_POSITION_CONTAINED_BY) {
947+
line++;
948+
}
949+
}
950+
}
951+
// For code blocks, count \n before cursor in textContent.
952+
// data-source-line on <pre> points to the ``` fence line,
953+
// so first code line = line + 1, each \n increments.
954+
if (el.tagName === "PRE") {
955+
const code = el.querySelector("code") || el;
956+
try {
957+
const range = document.createRange();
958+
range.setStart(code, 0);
959+
range.setEnd(sel.getRangeAt(0).startContainer, sel.getRangeAt(0).startOffset);
960+
const textBefore = range.toString();
961+
const newlines = (textBefore.match(/\n/g) || []).length;
962+
line += 1 + newlines; // +1 for the ``` fence line
963+
} catch (_e) {
964+
line += 1; // fallback: first code line
947965
}
948966
}
949967
}
@@ -993,17 +1011,62 @@ function handleScrollToLine(data) {
9931011
}
9941012
}
9951013

996-
// For paragraphs with <br> (soft line breaks), find the specific visual
997-
// line within the paragraph by counting <br> elements. The target line
998-
// minus the paragraph's start line gives the <br> offset.
1014+
// For multi-line blocks, find the specific visual line to scroll to.
9991015
let scrollTarget = bestEl;
1000-
if (bestEl.tagName === "P" && bestLine < line) {
1001-
const brOffset = line - bestLine;
1002-
const brs = bestEl.querySelectorAll("br");
1003-
if (brOffset > 0 && brOffset <= brs.length) {
1004-
// Use the <br> element as scroll target — it's at the right
1005-
// vertical position for the specific line within the paragraph.
1006-
scrollTarget = brs[brOffset - 1];
1016+
if (bestLine < line) {
1017+
if (bestEl.tagName === "P") {
1018+
// Paragraphs with <br>: use the <br> element as scroll target.
1019+
const brOffset = line - bestLine;
1020+
const brs = bestEl.querySelectorAll("br");
1021+
if (brOffset > 0 && brOffset <= brs.length) {
1022+
scrollTarget = brs[brOffset - 1];
1023+
}
1024+
} else if (bestEl.tagName === "PRE") {
1025+
// Code blocks: find the text node containing the target \n
1026+
// and create a temporary span to scroll to.
1027+
const codeLineOffset = line - bestLine - 1; // -1 for ``` fence
1028+
const code = bestEl.querySelector("code") || bestEl;
1029+
const text = code.textContent;
1030+
let nlCount = 0;
1031+
let charIdx = 0;
1032+
for (let i = 0; i < text.length; i++) {
1033+
if (text[i] === "\n") {
1034+
if (nlCount === codeLineOffset) {
1035+
charIdx = i + 1;
1036+
break;
1037+
}
1038+
nlCount++;
1039+
}
1040+
}
1041+
// Walk text nodes to find the one containing charIdx
1042+
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
1043+
let offset = 0;
1044+
let targetNode = null;
1045+
while (walker.nextNode()) {
1046+
const len = walker.currentNode.textContent.length;
1047+
if (offset + len >= charIdx) {
1048+
targetNode = walker.currentNode;
1049+
break;
1050+
}
1051+
offset += len;
1052+
}
1053+
if (targetNode) {
1054+
// Insert a temporary marker to scroll to, then remove it
1055+
const marker = document.createElement("span");
1056+
const splitAt = charIdx - offset;
1057+
if (splitAt > 0 && splitAt < targetNode.textContent.length) {
1058+
targetNode.splitText(splitAt);
1059+
targetNode.parentNode.insertBefore(marker, targetNode.nextSibling);
1060+
} else {
1061+
targetNode.parentNode.insertBefore(marker, targetNode);
1062+
}
1063+
scrollTarget = marker;
1064+
// Clean up after scroll
1065+
requestAnimationFrame(() => {
1066+
marker.remove();
1067+
code.normalize();
1068+
});
1069+
}
10071070
}
10081071
}
10091072

@@ -1059,6 +1122,63 @@ function handleScrollToLine(data) {
10591122
} else {
10601123
bestEl.classList.add("cursor-sync-highlight");
10611124
}
1125+
} else if (bestEl.tagName === "PRE" && bestLine < line) {
1126+
// Code blocks: use a positioned overlay at the target line's height.
1127+
// We can't wrap text without breaking Prism token spans.
1128+
const code = bestEl.querySelector("code") || bestEl;
1129+
const codeLineOffset = line - bestLine - 1; // -1 for ``` fence
1130+
// Find the character position of the target line
1131+
const text = code.textContent;
1132+
let charPos = 0;
1133+
let nlCount = 0;
1134+
for (let i = 0; i < text.length && nlCount < codeLineOffset; i++) {
1135+
if (text[i] === "\n") nlCount++;
1136+
charPos = i + 1;
1137+
}
1138+
// Create a range spanning the target line to get its rect
1139+
try {
1140+
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
1141+
let offset = 0;
1142+
let startNode = null, startOff = 0, endNode = null, endOff = 0;
1143+
while (walker.nextNode()) {
1144+
const node = walker.currentNode;
1145+
const len = node.textContent.length;
1146+
if (!startNode && offset + len >= charPos) {
1147+
startNode = node;
1148+
startOff = charPos - offset;
1149+
}
1150+
// Find end of this line (next \n or end of text)
1151+
const lineEnd = text.indexOf("\n", charPos);
1152+
const endPos = lineEnd === -1 ? text.length : lineEnd;
1153+
if (!endNode && offset + len >= endPos) {
1154+
endNode = node;
1155+
endOff = endPos - offset;
1156+
}
1157+
if (startNode && endNode) break;
1158+
offset += len;
1159+
}
1160+
if (startNode && endNode) {
1161+
const lineRange = document.createRange();
1162+
lineRange.setStart(startNode, startOff);
1163+
lineRange.setEnd(endNode, endOff);
1164+
const lineRect = lineRange.getClientRects()[0];
1165+
if (lineRect) {
1166+
const preRect = bestEl.getBoundingClientRect();
1167+
const overlay = document.createElement("div");
1168+
overlay.className = "cursor-sync-highlight cursor-sync-code-line";
1169+
overlay.style.position = "absolute";
1170+
overlay.style.left = "0";
1171+
overlay.style.right = "0";
1172+
overlay.style.top = (lineRect.top - preRect.top) + "px";
1173+
overlay.style.height = lineRect.height + "px";
1174+
overlay.style.pointerEvents = "none";
1175+
bestEl.style.position = "relative";
1176+
bestEl.appendChild(overlay);
1177+
}
1178+
}
1179+
} catch (_e) {
1180+
bestEl.classList.add("cursor-sync-highlight");
1181+
}
10621182
} else {
10631183
bestEl.classList.add("cursor-sync-highlight");
10641184
}
@@ -1069,6 +1189,11 @@ function handleScrollToLine(data) {
10691189
function _removeCursorHighlight(viewer) {
10701190
const prev = viewer.querySelector(".cursor-sync-highlight");
10711191
if (!prev) return;
1192+
// If highlight was a code block line overlay, just remove it
1193+
if (prev.classList.contains("cursor-sync-code-line")) {
1194+
prev.remove();
1195+
return;
1196+
}
10721197
// If highlight was a wrapper span for a <br> line, unwrap it
10731198
if (prev.classList.contains("cursor-sync-br-line")) {
10741199
while (prev.firstChild) {
@@ -1085,52 +1210,13 @@ let _lastHighlightSourceLine = null;
10851210
let _lastHighlightTargetLine = null;
10861211

10871212
function _reapplyCursorSyncHighlight() {
1088-
if (_lastHighlightSourceLine == null) return;
1213+
if (_lastHighlightTargetLine == null) return;
10891214
const viewer = document.getElementById("viewer-content");
10901215
if (!viewer) return;
1091-
// Don't re-apply if viewer has focus (user is editing in viewer)
10921216
if (viewer.contains(document.activeElement)) return;
1093-
_removeCursorHighlight(viewer);
1094-
const elements = viewer.querySelectorAll("[data-source-line]");
1095-
let bestEl = null;
1096-
let bestLine = -1;
1097-
for (const el of elements) {
1098-
const srcLine = parseInt(el.getAttribute("data-source-line"), 10);
1099-
if (srcLine <= _lastHighlightSourceLine && srcLine > bestLine) {
1100-
bestLine = srcLine;
1101-
bestEl = el;
1102-
}
1103-
}
1104-
if (!bestEl) return;
1105-
const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine;
1106-
// Handle <br> paragraph sub-line highlighting
1107-
if (bestEl.tagName === "P" && bestEl.querySelector("br")) {
1108-
const brOffset = targetLine - bestLine;
1109-
const brs = bestEl.querySelectorAll("br");
1110-
const span = document.createElement("span");
1111-
span.className = "cursor-sync-highlight cursor-sync-br-line";
1112-
if (brOffset === 0) {
1113-
let node = bestEl.firstChild;
1114-
while (node && !(node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR")) {
1115-
const toMove = node;
1116-
node = node.nextSibling;
1117-
span.appendChild(toMove);
1118-
}
1119-
bestEl.insertBefore(span, bestEl.firstChild);
1120-
return;
1121-
} else if (brOffset > 0 && brOffset <= brs.length) {
1122-
const targetBr = brs[brOffset - 1];
1123-
let next = targetBr.nextSibling;
1124-
while (next && !(next.nodeType === Node.ELEMENT_NODE && next.tagName === "BR")) {
1125-
const toMove = next;
1126-
next = next.nextSibling;
1127-
span.appendChild(toMove);
1128-
}
1129-
targetBr.parentNode.insertBefore(span, targetBr.nextSibling);
1130-
return;
1131-
}
1132-
}
1133-
bestEl.classList.add("cursor-sync-highlight");
1217+
// Re-use handleScrollToLine to apply the highlight (no scroll needed
1218+
// since the element is already in view after a re-render).
1219+
handleScrollToLine({ line: _lastHighlightTargetLine, fromScroll: false });
11341220
}
11351221

11361222
// Re-apply cursor sync highlight after content re-renders (e.g. typing in CM)

src-mdviewer/src/components/editor.js

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-men
1010
import { initLinkPopover, destroyLinkPopover } from "./link-popover.js";
1111
import { initImagePopover, destroyImagePopover } from "./image-popover.js";
1212
import { initLangPicker, destroyLangPicker, isLangPickerDropdownOpen } from "./lang-picker.js";
13-
import { highlightCode, renderAfterHTML, normalizeCodeLanguages, _annotateCodeBlockLines } from "./viewer.js";
13+
import { highlightCode, renderAfterHTML, normalizeCodeLanguages } from "./viewer.js";
1414
import { initMermaidEditor, destroyMermaidEditor, insertMermaidBlock, attachOverlays } from "./mermaid-editor.js";
1515

1616
const devLog = import.meta.env.DEV ? console.log.bind(console, "[editor]") : () => {};
@@ -1531,21 +1531,14 @@ export function convertToMarkdown(contentEl) {
15311531
const clone = contentEl.cloneNode(true);
15321532
clone.querySelectorAll(".code-copy-btn").forEach((btn) => btn.remove());
15331533
clone.querySelectorAll(".table-row-handles, .table-col-handles, .table-add-row-btn, .table-col-add-btn").forEach((el) => el.remove());
1534-
// Fix code blocks: replace <br> with \n and unwrap data-source-line spans.
1534+
// Fix code blocks: replace <br> with \n and flatten to plain text.
15351535
// In contenteditable, Enter inside a span inserts <br> instead of \n.
15361536
// Turndown needs plain text with \n for correct fenced code block output.
15371537
clone.querySelectorAll("pre code").forEach((code) => {
15381538
code.querySelectorAll("br").forEach((br) => {
15391539
br.replaceWith("\n");
15401540
});
1541-
// Unwrap data-source-line spans (inline them into the code element)
1542-
code.querySelectorAll("span[data-source-line]").forEach((span) => {
1543-
while (span.firstChild) {
1544-
span.parentNode.insertBefore(span.firstChild, span);
1545-
}
1546-
span.remove();
1547-
});
1548-
// Also unwrap any Prism token spans — get plain text for Turndown
1541+
// Unwrap Prism token spans — get plain text for Turndown
15491542
code.textContent = code.textContent;
15501543
});
15511544
// Unwrap <p> inside <li> — marked renders "loose" lists with <p> wrapping,
@@ -1658,7 +1651,6 @@ export function initEditor() {
16581651
const content = getContentEl();
16591652
if (!content) return;
16601653
_updateSourceLineAttrs(content, cmMarkdown);
1661-
_annotateCodeBlockLines();
16621654
});
16631655

16641656
on("state:editMode", (editing) => {
@@ -1798,9 +1790,7 @@ function enterEditMode(content) {
17981790
if (code.className.includes("language-")) {
17991791
Prism.highlightElement(code);
18001792
}
1801-
// Step 4: re-annotate code block lines for scroll sync
1802-
_annotateCodeBlockLines();
1803-
// Step 5: restore cursor
1793+
// Step 4: restore cursor
18041794
restoreCursor(content, off);
18051795
}, 500);
18061796
}

0 commit comments

Comments
 (0)