@@ -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) {
10691189function _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;
10851210let _lastHighlightTargetLine = null ;
10861211
10871212function _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)
0 commit comments