Skip to content

Commit 191e3bf

Browse files
committed
fix(mdviewer): preserve newlines in code blocks and improve line sync
- Fix code block editing: normalize <br> → \n before Prism re-highlights to prevent newline collapse in contenteditable code blocks - Re-enable _annotateCodeBlockLines for per-line cursor sync in code blocks - Add _updateSourceLineAttrs to recompute data-source-line after edits using CM's actual text (via MDVIEWR_SOURCE_LINES message) for accuracy - Fix stale annotation detection: re-annotate when line numbers drift - Fix <br> paragraph cursor sync: count <br> before cursor in _getSourceLineFromElement for exact CM line within soft-break paragraphs - Move \n to end of line spans (not start) so blank lines are clickable
1 parent 4ac8328 commit 191e3bf

4 files changed

Lines changed: 171 additions & 11 deletions

File tree

src-mdviewer/src/bridge.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,9 @@ export function initBridge() {
282282
case "MDVIEWR_RERENDER_CONTENT":
283283
handleRerenderContent(data);
284284
break;
285+
case "MDVIEWR_SOURCE_LINES":
286+
emit("editor:source-lines", data.markdown);
287+
break;
285288
case "MDVIEWR_TOOLBAR_STATE":
286289
if (data.state) {
287290
emit("editor:selection-state", data.state);
@@ -925,10 +928,26 @@ function _restoreCursorPosition(contentEl, pos) {
925928
}
926929

927930
function _getSourceLineFromElement(el) {
931+
// Use the current selection to determine exact position within <br> paragraphs
932+
const sel = window.getSelection();
933+
const cursorNode = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null;
934+
928935
while (el && el !== document.body) {
929936
const attr = el.getAttribute && el.getAttribute("data-source-line");
930937
if (attr != null) {
931-
return parseInt(attr, 10);
938+
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++;
947+
}
948+
}
949+
}
950+
return line;
932951
}
933952
el = el.parentElement;
934953
}

src-mdviewer/src/components/editor.js

Lines changed: 102 additions & 3 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 } from "./viewer.js";
13+
import { highlightCode, renderAfterHTML, normalizeCodeLanguages, _annotateCodeBlockLines } 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]") : () => {};
@@ -1507,6 +1507,23 @@ export function convertToMarkdown(contentEl) {
15071507
const clone = contentEl.cloneNode(true);
15081508
clone.querySelectorAll(".code-copy-btn").forEach((btn) => btn.remove());
15091509
clone.querySelectorAll(".table-row-handles, .table-col-handles, .table-add-row-btn, .table-col-add-btn").forEach((el) => el.remove());
1510+
// Fix code blocks: replace <br> with \n and unwrap data-source-line spans.
1511+
// In contenteditable, Enter inside a span inserts <br> instead of \n.
1512+
// Turndown needs plain text with \n for correct fenced code block output.
1513+
clone.querySelectorAll("pre code").forEach((code) => {
1514+
code.querySelectorAll("br").forEach((br) => {
1515+
br.replaceWith("\n");
1516+
});
1517+
// Unwrap data-source-line spans (inline them into the code element)
1518+
code.querySelectorAll("span[data-source-line]").forEach((span) => {
1519+
while (span.firstChild) {
1520+
span.parentNode.insertBefore(span.firstChild, span);
1521+
}
1522+
span.remove();
1523+
});
1524+
// Also unwrap any Prism token spans — get plain text for Turndown
1525+
code.textContent = code.textContent;
1526+
});
15101527
// Unwrap <p> inside <li> — marked renders "loose" lists with <p> wrapping,
15111528
// but Turndown converts that to blank lines between items. Unwrapping makes tight lists.
15121529
clone.querySelectorAll("li > p").forEach((p) => {
@@ -1534,6 +1551,67 @@ export function convertToMarkdown(contentEl) {
15341551
let contentChangeTimer = null;
15351552
const CONTENT_CHANGE_DEBOUNCE = 300;
15361553

1554+
/**
1555+
* Re-compute data-source-line attributes on top-level block elements
1556+
* by walking the generated markdown and mapping line numbers back to DOM nodes.
1557+
* This keeps scroll sync working after edits in the viewer.
1558+
*/
1559+
function _updateSourceLineAttrs(contentEl, markdown) {
1560+
const mdLines = markdown.split("\n");
1561+
const children = contentEl.children;
1562+
let mdLineIdx = 0;
1563+
1564+
for (let i = 0; i < children.length; i++) {
1565+
const el = children[i];
1566+
// Skip UI elements (handles, overlays, etc.)
1567+
if (el.classList.contains("table-row-handles") ||
1568+
el.classList.contains("table-col-handles") ||
1569+
el.classList.contains("table-add-row-btn") ||
1570+
el.classList.contains("table-col-add-btn") ||
1571+
el.classList.contains("cursor-sync-highlight")) {
1572+
continue;
1573+
}
1574+
1575+
// Skip blank lines in markdown to find the next block
1576+
while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() === "") {
1577+
mdLineIdx++;
1578+
}
1579+
if (mdLineIdx >= mdLines.length) break;
1580+
1581+
// Assign line number (1-based)
1582+
el.setAttribute("data-source-line", String(mdLineIdx + 1));
1583+
1584+
// Advance past this element's markdown lines
1585+
const tag = el.tagName;
1586+
if (tag === "PRE" || (tag === "DIV" && el.classList.contains("table-wrapper"))) {
1587+
// Code blocks: find closing ``` or end of fenced block
1588+
// Tables: find end of table rows
1589+
const startLine = mdLineIdx;
1590+
mdLineIdx++;
1591+
if (tag === "PRE") {
1592+
// Skip to closing ```
1593+
while (mdLineIdx < mdLines.length && !mdLines[mdLineIdx].match(/^```\s*$/)) {
1594+
mdLineIdx++;
1595+
}
1596+
mdLineIdx++; // skip the closing ```
1597+
} else {
1598+
// Table: skip while lines start with |
1599+
while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].startsWith("|")) {
1600+
mdLineIdx++;
1601+
}
1602+
}
1603+
} else {
1604+
// Single-line or multi-line block: advance to next blank line.
1605+
// Paragraphs with <br> (soft line breaks) are a single block —
1606+
// the data-source-line on the <p> points to the block's start.
1607+
mdLineIdx++;
1608+
while (mdLineIdx < mdLines.length && mdLines[mdLineIdx].trim() !== "") {
1609+
mdLineIdx++;
1610+
}
1611+
}
1612+
}
1613+
}
1614+
15371615
function emitContentChange(contentEl) {
15381616
clearTimeout(contentChangeTimer);
15391617
contentChangeTimer = setTimeout(() => {
@@ -1549,6 +1627,16 @@ function getContentEl() {
15491627
export function initEditor() {
15501628
turndown = createTurndown();
15511629

1630+
// When CM sends back its actual text after an edit, use it to update
1631+
// data-source-line attributes. This is more accurate than using the
1632+
// markdown from convertToMarkdown, which may differ in formatting.
1633+
on("editor:source-lines", (cmMarkdown) => {
1634+
const content = getContentEl();
1635+
if (!content) return;
1636+
_updateSourceLineAttrs(content, cmMarkdown);
1637+
_annotateCodeBlockLines();
1638+
});
1639+
15521640
on("state:editMode", (editing) => {
15531641
const content = getContentEl();
15541642
if (!content) return;
@@ -1666,19 +1754,30 @@ function enterEditMode(content) {
16661754
? sel.anchorNode.closest("pre")
16671755
: sel.anchorNode.parentElement?.closest("pre");
16681756
if (pre && content.contains(pre)) {
1757+
// Debounced re-highlighting for code blocks.
1758+
// First normalize <br> → \n (contenteditable inserts <br>
1759+
// for Enter, but Prism reads textContent which ignores <br>).
1760+
// Then re-run Prism with cursor preservation.
16691761
clearTimeout(codeHighlightTimer);
16701762
codeHighlightTimer = setTimeout(() => {
16711763
const code = pre.querySelector("code");
16721764
if (!code) return;
1765+
// Step 1: normalize <br> → \n BEFORE anything else
1766+
code.querySelectorAll("br").forEach(br => br.replaceWith("\n"));
1767+
// Step 2: save cursor AFTER normalization
1768+
const off = getCursorOffset(content);
1769+
// Step 3: apply language class + Prism highlight
16731770
const lang = pre.getAttribute("data-language");
16741771
if (lang && !code.className.includes(`language-${lang}`)) {
16751772
code.className = `language-${lang}`;
16761773
}
16771774
if (code.className.includes("language-")) {
1678-
const off = getCursorOffset(content);
16791775
Prism.highlightElement(code);
1680-
restoreCursor(content, off);
16811776
}
1777+
// Step 4: re-annotate code block lines for scroll sync
1778+
_annotateCodeBlockLines();
1779+
// Step 5: restore cursor
1780+
restoreCursor(content, off);
16821781
}, 500);
16831782
}
16841783
}

src-mdviewer/src/components/viewer.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export function initViewer() {
7474

7575
const newContent = document.createElement("div");
7676
newContent.innerHTML = parseResult.html;
77+
7778
morphdom(content, newContent, { childrenOnly: true });
7879

7980
// Restore saved image nodes — find new <img> by src and swap
@@ -200,17 +201,34 @@ export function highlightCode() {
200201
* enabling per-line cursor sync for code blocks.
201202
* Must run AFTER Prism highlighting since Prism replaces innerHTML.
202203
*/
203-
function _annotateCodeBlockLines() {
204+
export function _annotateCodeBlockLines() {
204205
// Process all pre elements, not just those with data-source-line
205206
// (morphdom may strip the attr on first render)
206207
const pres = document.querySelectorAll("#viewer-content pre");
207208
pres.forEach((pre) => {
208209
const code = pre.querySelector("code");
209210
if (!code) return;
210-
// Already annotated?
211-
if (code.querySelector("span[data-source-line]")) return;
212211

213212
let preSourceLine = parseInt(pre.getAttribute("data-source-line"), 10);
213+
// If already annotated, check if the line numbers are still correct.
214+
// If pre has a data-source-line and annotations exist, compare the
215+
// expected first line with the actual first annotation.
216+
const existingSpan = code.querySelector("span[data-source-line]");
217+
if (existingSpan) {
218+
if (isNaN(preSourceLine)) return; // can't verify, keep existing
219+
const expectedFirst = String(preSourceLine + 1);
220+
if (existingSpan.getAttribute("data-source-line") === expectedFirst) {
221+
return; // annotations are up to date
222+
}
223+
// Stale annotations — unwrap them before re-annotating
224+
code.querySelectorAll("span[data-source-line]").forEach((span) => {
225+
while (span.firstChild) {
226+
span.parentNode.insertBefore(span.firstChild, span);
227+
}
228+
span.remove();
229+
});
230+
code.normalize(); // merge adjacent text nodes
231+
}
214232
if (isNaN(preSourceLine)) {
215233
// Fallback: find the nearest preceding sibling with data-source-line
216234
// and estimate this pre's line from it
@@ -243,12 +261,12 @@ function _annotateCodeBlockLines() {
243261
const parts = text.split("\n");
244262
for (let i = 0; i < parts.length; i++) {
245263
if (i > 0) {
246-
// Close current line span, start new one with the newline inside it
264+
// Close current line: append \n to END of current span
265+
currentLine.appendChild(document.createTextNode("\n"));
247266
fragment.appendChild(currentLine);
248267
lineIdx++;
249268
currentLine = document.createElement("span");
250269
currentLine.setAttribute("data-source-line", String(codeStartLine + lineIdx));
251-
currentLine.appendChild(document.createTextNode("\n"));
252270
}
253271
if (parts[i]) {
254272
currentLine.appendChild(document.createTextNode(parts[i]));

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,17 @@ define(function (require, exports, module) {
358358
return;
359359
}
360360

361+
const mdText = _doc.getText();
361362
iframeWindow.postMessage({
362363
type: "MDVIEWR_SWITCH_FILE",
363-
markdown: _doc.getText(),
364+
markdown: mdText,
364365
baseURL: _baseURL,
365366
filePath: _doc.file.fullPath
366367
}, "*");
368+
iframeWindow.postMessage({
369+
type: "MDVIEWR_SOURCE_LINES",
370+
markdown: mdText
371+
}, "*");
367372
}
368373

369374
function _sendContent() {
@@ -375,12 +380,19 @@ define(function (require, exports, module) {
375380
return;
376381
}
377382

383+
const mdText = _doc.getText();
378384
iframeWindow.postMessage({
379385
type: "MDVIEWR_SET_CONTENT",
380-
markdown: _doc.getText(),
386+
markdown: mdText,
381387
baseURL: _baseURL,
382388
filePath: _doc.file.fullPath
383389
}, "*");
390+
// Send source line mapping so the iframe can annotate sub-element
391+
// lines (e.g. <br> within paragraphs) on first load.
392+
iframeWindow.postMessage({
393+
type: "MDVIEWR_SOURCE_LINES",
394+
markdown: mdText
395+
}, "*");
384396
}
385397

386398
function _sendUpdate(changeOrigin) {
@@ -501,6 +513,18 @@ define(function (require, exports, module) {
501513

502514
_applyDiffToEditor(markdown);
503515

516+
// Send back the actual CM text so the iframe can compute accurate
517+
// data-source-line attributes. The markdown from convertToMarkdown
518+
// may differ slightly from CM's content (e.g. table formatting),
519+
// causing line number drift if used directly.
520+
const iframeWindow = _getIframeWindow();
521+
if (iframeWindow && _doc) {
522+
iframeWindow.postMessage({
523+
type: "MDVIEWR_SOURCE_LINES",
524+
markdown: _doc.getText()
525+
}, "*");
526+
}
527+
504528
// Push cursor position for undo/redo restore
505529
if (data.cursorPos) {
506530
_cursorUndoStack.push(data.cursorPos);

0 commit comments

Comments
 (0)