Skip to content

Commit 401235e

Browse files
committed
feat(mdviewer): add per-line source mapping inside code blocks
Add post-Prism line annotation that wraps each line in highlighted code blocks with a span containing data-source-line. Clicking a specific line in a large code block now maps to the exact source line in CM instead of the block start. Remove data-source-line from <pre> after annotation so clicking empty lines doesn't scroll to block top.
1 parent b861204 commit 401235e

1 file changed

Lines changed: 74 additions & 0 deletions

File tree

src-mdviewer/src/components/viewer.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,80 @@ export function highlightCode() {
182182
blocks.forEach((block) => {
183183
Prism.highlightElement(block);
184184
});
185+
186+
// After Prism highlighting, add per-line data-source-line spans inside code blocks
187+
_annotateCodeBlockLines();
188+
}
189+
190+
/**
191+
* Wrap each line in highlighted code blocks with a span that has data-source-line,
192+
* enabling per-line cursor sync for code blocks.
193+
* Must run AFTER Prism highlighting since Prism replaces innerHTML.
194+
*/
195+
function _annotateCodeBlockLines() {
196+
const pres = document.querySelectorAll("#viewer-content pre[data-source-line]");
197+
pres.forEach((pre) => {
198+
const code = pre.querySelector("code");
199+
if (!code) return;
200+
const preSourceLine = parseInt(pre.getAttribute("data-source-line"), 10);
201+
if (isNaN(preSourceLine)) return;
202+
// Code content starts after the ``` line
203+
const codeStartLine = preSourceLine + 1;
204+
205+
// Split the code's child nodes by newlines and wrap each line
206+
const fragment = document.createDocumentFragment();
207+
let currentLine = document.createElement("span");
208+
currentLine.setAttribute("data-source-line", String(codeStartLine));
209+
let lineIdx = 0;
210+
211+
function processNode(node) {
212+
if (node.nodeType === Node.TEXT_NODE) {
213+
const text = node.textContent;
214+
const parts = text.split("\n");
215+
for (let i = 0; i < parts.length; i++) {
216+
if (i > 0) {
217+
// Close current line span, start new one with the newline inside it
218+
fragment.appendChild(currentLine);
219+
lineIdx++;
220+
currentLine = document.createElement("span");
221+
currentLine.setAttribute("data-source-line", String(codeStartLine + lineIdx));
222+
currentLine.appendChild(document.createTextNode("\n"));
223+
}
224+
if (parts[i]) {
225+
currentLine.appendChild(document.createTextNode(parts[i]));
226+
}
227+
}
228+
} else if (node.nodeType === Node.ELEMENT_NODE) {
229+
// Check if this element contains newlines
230+
const text = node.textContent;
231+
if (!text.includes("\n")) {
232+
// No newlines — append the whole element to current line
233+
currentLine.appendChild(node.cloneNode(true));
234+
} else {
235+
// Element spans multiple lines — process children
236+
for (const child of Array.from(node.childNodes)) {
237+
processNode(child);
238+
}
239+
}
240+
}
241+
}
242+
243+
const children = Array.from(code.childNodes);
244+
for (const child of children) {
245+
processNode(child);
246+
}
247+
// Append the last line
248+
if (currentLine.childNodes.length > 0) {
249+
fragment.appendChild(currentLine);
250+
}
251+
252+
code.innerHTML = "";
253+
code.appendChild(fragment);
254+
255+
// Remove data-source-line from <pre> so clicking empty areas inside the
256+
// code block doesn't fall through to the block's start line
257+
pre.removeAttribute("data-source-line");
258+
});
185259
}
186260

187261
function addCopyButtons() {

0 commit comments

Comments
 (0)