@@ -958,6 +958,11 @@ function handleScrollToLine(data) {
958958 const { line, fromScroll, tableCol } = data ;
959959 if ( line == null ) return ;
960960
961+ // In edit mode, ignore scroll-based sync from CM to prevent feedback
962+ // loops (click in viewer → CM scroll → scroll sync back → viewer jumps).
963+ // Only cursor-based sync (fromScroll=false) should reposition the viewer.
964+ if ( fromScroll && getState ( ) . editMode ) return ;
965+
961966 const viewer = document . getElementById ( "viewer-content" ) ;
962967 if ( ! viewer ) return ;
963968
@@ -983,42 +988,104 @@ function handleScrollToLine(data) {
983988 }
984989 }
985990
991+ // For paragraphs with <br> (soft line breaks), find the specific visual
992+ // line within the paragraph by counting <br> elements. The target line
993+ // minus the paragraph's start line gives the <br> offset.
994+ let scrollTarget = bestEl ;
995+ if ( bestEl . tagName === "P" && bestLine < line ) {
996+ const brOffset = line - bestLine ;
997+ const brs = bestEl . querySelectorAll ( "br" ) ;
998+ if ( brOffset > 0 && brOffset <= brs . length ) {
999+ // Use the <br> element as scroll target — it's at the right
1000+ // vertical position for the specific line within the paragraph.
1001+ scrollTarget = brs [ brOffset - 1 ] ;
1002+ }
1003+ }
1004+
9861005 const container = document . getElementById ( "app-viewer" ) ;
9871006 if ( ! container ) return ;
9881007 const containerRect = container . getBoundingClientRect ( ) ;
989- const elRect = bestEl . getBoundingClientRect ( ) ;
1008+ const elRect = scrollTarget . getBoundingClientRect ( ) ;
9901009
9911010 // Suppress viewer→CM scroll feedback for any CM-initiated scroll
9921011 _scrollFromCM = true ;
9931012 if ( fromScroll ) {
9941013 // Sync scroll: always align to top, even if visible
995- bestEl . scrollIntoView ( { behavior : "instant" , block : "start" } ) ;
1014+ scrollTarget . scrollIntoView ( { behavior : "instant" , block : "start" } ) ;
9961015 } else {
9971016 // Cursor-based scroll: only scroll if not visible, center it
9981017 const isVisible = elRect . top >= containerRect . top && elRect . bottom <= containerRect . bottom ;
9991018 if ( ! isVisible ) {
1000- bestEl . scrollIntoView ( { behavior : "instant" , block : "center" } ) ;
1019+ scrollTarget . scrollIntoView ( { behavior : "instant" , block : "center" } ) ;
10011020 }
10021021 }
10031022 setTimeout ( ( ) => { _scrollFromCM = false ; } , 200 ) ;
10041023
10051024 // Persistent highlight on the element corresponding to the CM cursor.
10061025 // Only show when CM has focus (not when viewer has focus).
1007- const prev = viewer . querySelector ( ".cursor-sync-highlight" ) ;
1008- if ( prev ) { prev . classList . remove ( "cursor-sync-highlight" ) ; }
1009- bestEl . classList . add ( "cursor-sync-highlight" ) ;
1026+ _removeCursorHighlight ( viewer ) ;
1027+
1028+ // For <br> paragraphs, wrap only the specific line's content in a
1029+ // highlight span instead of highlighting the whole <p>.
1030+ if ( bestEl . tagName === "P" && bestEl . querySelector ( "br" ) ) {
1031+ const brOffset = line - bestLine ;
1032+ const brs = bestEl . querySelectorAll ( "br" ) ;
1033+ const span = document . createElement ( "span" ) ;
1034+ span . className = "cursor-sync-highlight cursor-sync-br-line" ;
1035+ if ( brOffset === 0 ) {
1036+ // First line: wrap nodes before the first <br>
1037+ let node = bestEl . firstChild ;
1038+ while ( node && ! ( node . nodeType === Node . ELEMENT_NODE && node . tagName === "BR" ) ) {
1039+ const toMove = node ;
1040+ node = node . nextSibling ;
1041+ span . appendChild ( toMove ) ;
1042+ }
1043+ bestEl . insertBefore ( span , bestEl . firstChild ) ;
1044+ } else if ( brOffset > 0 && brOffset <= brs . length ) {
1045+ // Subsequent lines: wrap nodes after the target <br>
1046+ const targetBr = brs [ brOffset - 1 ] ;
1047+ let next = targetBr . nextSibling ;
1048+ while ( next && ! ( next . nodeType === Node . ELEMENT_NODE && next . tagName === "BR" ) ) {
1049+ const toMove = next ;
1050+ next = next . nextSibling ;
1051+ span . appendChild ( toMove ) ;
1052+ }
1053+ targetBr . parentNode . insertBefore ( span , targetBr . nextSibling ) ;
1054+ } else {
1055+ bestEl . classList . add ( "cursor-sync-highlight" ) ;
1056+ }
1057+ } else {
1058+ bestEl . classList . add ( "cursor-sync-highlight" ) ;
1059+ }
10101060 _lastHighlightSourceLine = bestLine ;
1061+ _lastHighlightTargetLine = line ;
1062+ }
1063+
1064+ function _removeCursorHighlight ( viewer ) {
1065+ const prev = viewer . querySelector ( ".cursor-sync-highlight" ) ;
1066+ if ( ! prev ) return ;
1067+ // If highlight was a wrapper span for a <br> line, unwrap it
1068+ if ( prev . classList . contains ( "cursor-sync-br-line" ) ) {
1069+ while ( prev . firstChild ) {
1070+ prev . parentNode . insertBefore ( prev . firstChild , prev ) ;
1071+ }
1072+ prev . remove ( ) ;
1073+ } else {
1074+ prev . classList . remove ( "cursor-sync-highlight" ) ;
1075+ }
10111076}
10121077
10131078// Track last highlighted source line so we can re-apply after re-renders
10141079let _lastHighlightSourceLine = null ;
1080+ let _lastHighlightTargetLine = null ;
10151081
10161082function _reapplyCursorSyncHighlight ( ) {
10171083 if ( _lastHighlightSourceLine == null ) return ;
10181084 const viewer = document . getElementById ( "viewer-content" ) ;
10191085 if ( ! viewer ) return ;
10201086 // Don't re-apply if viewer has focus (user is editing in viewer)
10211087 if ( viewer . contains ( document . activeElement ) ) return ;
1088+ _removeCursorHighlight ( viewer ) ;
10221089 const elements = viewer . querySelectorAll ( "[data-source-line]" ) ;
10231090 let bestEl = null ;
10241091 let bestLine = - 1 ;
@@ -1029,9 +1096,36 @@ function _reapplyCursorSyncHighlight() {
10291096 bestEl = el ;
10301097 }
10311098 }
1032- if ( bestEl ) {
1033- bestEl . classList . add ( "cursor-sync-highlight" ) ;
1099+ if ( ! bestEl ) return ;
1100+ const targetLine = _lastHighlightTargetLine || _lastHighlightSourceLine ;
1101+ // Handle <br> paragraph sub-line highlighting
1102+ if ( bestEl . tagName === "P" && bestEl . querySelector ( "br" ) ) {
1103+ const brOffset = targetLine - bestLine ;
1104+ const brs = bestEl . querySelectorAll ( "br" ) ;
1105+ const span = document . createElement ( "span" ) ;
1106+ span . className = "cursor-sync-highlight cursor-sync-br-line" ;
1107+ if ( brOffset === 0 ) {
1108+ let node = bestEl . firstChild ;
1109+ while ( node && ! ( node . nodeType === Node . ELEMENT_NODE && node . tagName === "BR" ) ) {
1110+ const toMove = node ;
1111+ node = node . nextSibling ;
1112+ span . appendChild ( toMove ) ;
1113+ }
1114+ bestEl . insertBefore ( span , bestEl . firstChild ) ;
1115+ return ;
1116+ } else if ( brOffset > 0 && brOffset <= brs . length ) {
1117+ const targetBr = brs [ brOffset - 1 ] ;
1118+ let next = targetBr . nextSibling ;
1119+ while ( next && ! ( next . nodeType === Node . ELEMENT_NODE && next . tagName === "BR" ) ) {
1120+ const toMove = next ;
1121+ next = next . nextSibling ;
1122+ span . appendChild ( toMove ) ;
1123+ }
1124+ targetBr . parentNode . insertBefore ( span , targetBr . nextSibling ) ;
1125+ return ;
1126+ }
10341127 }
1128+ bestEl . classList . add ( "cursor-sync-highlight" ) ;
10351129}
10361130
10371131// Re-apply cursor sync highlight after content re-renders (e.g. typing in CM)
@@ -1044,8 +1138,7 @@ on("file:rendered", () => {
10441138document . addEventListener ( "focusin" , ( e ) => {
10451139 const viewer = document . getElementById ( "viewer-content" ) ;
10461140 if ( viewer && viewer . contains ( e . target ) ) {
1047- const prev = viewer . querySelector ( ".cursor-sync-highlight" ) ;
1048- if ( prev ) { prev . classList . remove ( "cursor-sync-highlight" ) ; }
1141+ _removeCursorHighlight ( viewer ) ;
10491142 _lastHighlightSourceLine = null ;
10501143 }
10511144} ) ;
0 commit comments