@@ -10,7 +10,7 @@ import { initSlashMenu, destroySlashMenu, isSlashMenuVisible } from "./slash-men
1010import { initLinkPopover , destroyLinkPopover } from "./link-popover.js" ;
1111import { initImagePopover , destroyImagePopover } from "./image-popover.js" ;
1212import { initLangPicker , destroyLangPicker , isLangPickerDropdownOpen } from "./lang-picker.js" ;
13- import { highlightCode , renderAfterHTML , normalizeCodeLanguages } from "./viewer.js" ;
13+ import { highlightCode , renderAfterHTML , normalizeCodeLanguages , _annotateCodeBlockLines } from "./viewer.js" ;
1414import { initMermaidEditor , destroyMermaidEditor , insertMermaidBlock , attachOverlays } from "./mermaid-editor.js" ;
1515
1616const 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) {
15341551let contentChangeTimer = null ;
15351552const 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+
15371615function emitContentChange ( contentEl ) {
15381616 clearTimeout ( contentChangeTimer ) ;
15391617 contentChangeTimer = setTimeout ( ( ) => {
@@ -1549,6 +1627,16 @@ function getContentEl() {
15491627export 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 }
0 commit comments