@@ -653,29 +653,68 @@ export class SynPmEditor extends LitElement {
653653 if ( child . isText && child . text ) {
654654 const pmPos = pmStart + pos ;
655655
656- // For each character in the text, map to markdown position
656+ // Check for link mark to skip link syntax
657+ const linkMark = child . marks . find ( m => m . type . name === 'link' ) ;
658+ if ( linkMark ) {
659+ // Skip opening bracket [
660+ while ( markdownIndex < markdown . length && markdown [ markdownIndex ] === '[' ) {
661+ markdownIndex += 1 ;
662+ }
663+ }
664+
665+ // Determine how many formatting characters to skip based on marks
666+ let openingChars = 0 ;
667+ const hasStrong = child . marks . some ( m => m . type . name === 'strong' ) ;
668+ const hasEm = child . marks . some ( m => m . type . name === 'em' ) ;
669+ const hasCode = child . marks . some ( m => m . type . name === 'code' ) ;
670+
671+ if ( hasCode ) {
672+ openingChars += 1 ; // `
673+ }
674+ if ( hasStrong && hasEm ) {
675+ openingChars += 3 ; // ***
676+ } else if ( hasStrong ) {
677+ openingChars += 2 ; // **
678+ } else if ( hasEm ) {
679+ openingChars += 1 ; // *
680+ }
681+
682+ // Skip opening formatting characters
683+ markdownIndex += openingChars ;
684+
685+ // Map each content character
657686 for ( let i = 0 ; i < child . text . length ; i += 1 ) {
658687 const charPmPos = pmPos + i ;
659688 const char = child . text [ i ] ;
660689
661- // Skip markdown syntax characters (**, *, `)
662- while ( markdownIndex < markdown . length &&
663- ( markdown [ markdownIndex ] === '*' || markdown [ markdownIndex ] === '`' ) ) {
664- markdownIndex += 1 ;
665- }
666-
667690 // Find this character in the markdown
668691 if ( markdownIndex < markdown . length && markdown [ markdownIndex ] === char ) {
669692 this . pmToMarkdownMap . set ( charPmPos , markdownIndex ) ;
670693 this . markdownToPmMap . set ( markdownIndex , charPmPos ) ;
671694 markdownIndex += 1 ;
695+ } else {
696+ // Character mismatch - position mapping is broken, stop mapping this node
697+ console . warn ( 'Position mapping mismatch at PM pos' , charPmPos , 'expected' , char , 'found' , markdown [ markdownIndex ] ) ;
698+ break ;
672699 }
673700 }
674701
675- // Skip closing markdown syntax characters
676- while ( markdownIndex < markdown . length &&
677- ( markdown [ markdownIndex ] === '*' || markdown [ markdownIndex ] === '`' ) ) {
678- markdownIndex += 1 ;
702+ // Skip closing formatting characters
703+ markdownIndex += openingChars ;
704+
705+ // Skip closing link syntax if present
706+ if ( linkMark ) {
707+ // Skip ](url)
708+ while ( markdownIndex < markdown . length &&
709+ ( markdown [ markdownIndex ] === ']' || markdown [ markdownIndex ] === '(' ||
710+ markdown [ markdownIndex ] === ')' ||
711+ ( markdownIndex > 0 && markdown [ markdownIndex - 1 ] === ']' && markdown [ markdownIndex ] !== '(' ) ) ) {
712+ if ( markdown [ markdownIndex ] === ')' ) {
713+ markdownIndex += 1 ;
714+ break ;
715+ }
716+ markdownIndex += 1 ;
717+ }
679718 }
680719 }
681720 return true ;
@@ -698,7 +737,10 @@ export class SynPmEditor extends LitElement {
698737
699738 // Sync selection changes
700739 const selectionChanged = transactions . some ( tr => tr . selectionSet ) ;
701- if ( selectionChanged || docChanged ) {
740+ const isRemote = transactions . some ( tr => tr . getMeta ( 'remote' ) ) ;
741+
742+ // Only broadcast cursor position if it's a user-initiated change, not a remote adjustment
743+ if ( ( selectionChanged || docChanged ) && ! isRemote ) {
702744 const { from, to } = newState . selection ;
703745 self . onSelectionChanged ( [ { from, to } ] ) ;
704746 }
@@ -784,30 +826,34 @@ export class SynPmEditor extends LitElement {
784826 // console.log('Updating editor from Syn - text changed');
785827 this . isUpdatingFromSyn = true ;
786828
787- // IMPORTANT: Capture both anchor and head to preserve selections
829+ // Capture current cursor position in OLD document
788830 const currentSelection = this . view . state . selection ;
789831 const currentPmAnchor = currentSelection . anchor ;
790832 const currentPmHead = currentSelection . head ;
791833
792- // Convert both positions to markdown using OLD mapping
834+ // Convert to markdown positions in OLD document
793835 const oldMarkdownAnchor = this . proseMirrorPosToMarkdownPos ( currentPmAnchor ) ;
794836 const oldMarkdownHead = this . proseMirrorPosToMarkdownPos ( currentPmHead ) ;
795837
796- // Calculate position shifts for both anchor and head
838+ // Calculate text changes
797839 const changes = this . diffTexts ( currentText , stateText ) ;
798840
841+ // Calculate how cursor positions should shift based on changes
799842 const calculateShift = ( oldPos : number ) : number => {
800843 let shift = 0 ;
801844 for ( const change of changes ) {
802845 if ( change . position < oldPos ) {
803846 if ( change . type === 'insert' ) {
847+ // Text inserted before cursor - shift forward
804848 shift += change . text ! . length ;
805849 } else if ( change . type === 'delete' ) {
850+ // Text deleted before cursor - shift backward
806851 const deleteEnd = change . position + change . length ! ;
807852 if ( deleteEnd <= oldPos ) {
853+ // Entire deletion before cursor
808854 shift -= change . length ! ;
809855 } else {
810- // Deletion overlaps position
856+ // Deletion overlaps cursor - place cursor at deletion start
811857 shift = change . position - oldPos ;
812858 }
813859 }
@@ -816,15 +862,12 @@ export class SynPmEditor extends LitElement {
816862 return shift ;
817863 } ;
818864
865+ // Apply shifts to markdown positions
819866 const newMarkdownAnchor = oldMarkdownAnchor + calculateShift ( oldMarkdownAnchor ) ;
820867 const newMarkdownHead = oldMarkdownHead + calculateShift ( oldMarkdownHead ) ;
821868
822- const lines = stateText . split ( '\n' ) ;
823- // console.log('Split into lines:', lines.length, 'lines:', JSON.stringify(lines));
824-
869+ // Build new document
825870 const newDoc = this . createDocFromText ( stateText ) ;
826- const newDocText = this . docToText ( newDoc ) ;
827- // console.log('Created doc, paragraphs:', newDoc.childCount, 'docToText:', JSON.stringify(newDocText), 'matches input:', newDocText === stateText);
828871
829872 // Replace entire document
830873 const tr = this . view . state . tr . replaceWith (
@@ -833,31 +876,30 @@ export class SynPmEditor extends LitElement {
833876 newDoc . content
834877 ) ;
835878
836- // Restore selection (both anchor and head) using adjusted positions
837- if ( newMarkdownAnchor !== null && newMarkdownAnchor !== undefined &&
838- newMarkdownHead !== null && newMarkdownHead !== undefined ) {
839- const clampedAnchor = Math . max ( 0 , Math . min ( newMarkdownAnchor , stateText . length ) ) ;
840- const clampedHead = Math . max ( 0 , Math . min ( newMarkdownHead , stateText . length ) ) ;
841-
842- const newPmAnchor = this . markdownPosToProseMirrorPos ( clampedAnchor ) ;
843- const newPmHead = this . markdownPosToProseMirrorPos ( clampedHead ) ;
844-
845- const docSize = tr . doc . content . size ;
846- const safeAnchor = Math . max ( 0 , Math . min ( newPmAnchor , docSize ) ) ;
847- const safeHead = Math . max ( 0 , Math . min ( newPmHead , docSize ) ) ;
848-
879+ // Mark this transaction as remote so plugin doesn't broadcast cursor position
880+ tr . setMeta ( 'remote' , true ) ;
881+
882+ // Convert adjusted markdown positions back to ProseMirror positions in NEW document
883+ const clampedAnchor = Math . max ( 0 , Math . min ( newMarkdownAnchor , stateText . length ) ) ;
884+ const clampedHead = Math . max ( 0 , Math . min ( newMarkdownHead , stateText . length ) ) ;
885+
886+ const newPmAnchor = this . markdownPosToProseMirrorPos ( clampedAnchor ) ;
887+ const newPmHead = this . markdownPosToProseMirrorPos ( clampedHead ) ;
888+
889+ const docSize = tr . doc . content . size ;
890+ const safeAnchor = Math . max ( 0 , Math . min ( newPmAnchor , docSize ) ) ;
891+ const safeHead = Math . max ( 0 , Math . min ( newPmHead , docSize ) ) ;
892+
893+ try {
894+ const $anchor = tr . doc . resolve ( safeAnchor ) ;
895+ const $head = tr . doc . resolve ( safeHead ) ;
896+ tr . setSelection ( new TextSelection ( $anchor , $head ) ) ;
897+ } catch ( e ) {
898+ // Selection might be invalid, fallback
849899 try {
850- // Create a TextSelection with both anchor and head to preserve highlighting
851- const $anchor = tr . doc . resolve ( safeAnchor ) ;
852- const $head = tr . doc . resolve ( safeHead ) ;
853- tr . setSelection ( new TextSelection ( $anchor , $head ) ) ;
854- } catch ( e ) {
855- // Selection might be invalid, fallback to cursor at anchor
856- try {
857- tr . setSelection ( TextSelection . near ( tr . doc . resolve ( safeAnchor ) ) ) ;
858- } catch ( e2 ) {
859- // Ignore if still fails
860- }
900+ tr . setSelection ( TextSelection . near ( tr . doc . resolve ( safeAnchor ) ) ) ;
901+ } catch ( e2 ) {
902+ // If that fails too, just let ProseMirror use default selection
861903 }
862904 }
863905
@@ -1029,13 +1071,16 @@ export class SynPmEditor extends LitElement {
10291071 return pmPos + 1 ; // +1 for document opening
10301072 }
10311073
1032- // Fallback: find closest mapped position
1074+ // Fallback: find closest mapped position before this position
10331075 let closestMd = markdownPos ;
10341076 while ( closestMd > 0 && ! this . markdownToPmMap . has ( closestMd ) ) {
10351077 closestMd -= 1 ;
10361078 }
1079+
10371080 const closestPm = this . markdownToPmMap . get ( closestMd ) || 0 ;
1038- return closestPm + ( markdownPos - closestMd ) + 1 ;
1081+ // Apply offset proportionally - this handles formatting characters between mapped positions
1082+ const offset = markdownPos - closestMd ;
1083+ return closestPm + offset + 1 ;
10391084 }
10401085
10411086 // Map a ProseMirror document position to markdown text position
@@ -1047,21 +1092,21 @@ export class SynPmEditor extends LitElement {
10471092
10481093 // Use cached mapping if available
10491094 const mdPos = this . pmToMarkdownMap . get ( adjustedPmPos ) ;
1050- // console.log('PM to MD mapping:', { pmPos, adjustedPmPos, mdPos, hasMapping: mdPos !== undefined });
10511095
10521096 if ( mdPos !== undefined ) {
10531097 return mdPos ;
10541098 }
10551099
1056- // Fallback: find closest mapped position
1100+ // Fallback: find closest mapped position before this position
10571101 let closestPm = adjustedPmPos ;
10581102 while ( closestPm > 0 && ! this . pmToMarkdownMap . has ( closestPm ) ) {
10591103 closestPm -= 1 ;
10601104 }
1105+
10611106 const closestMd = this . pmToMarkdownMap . get ( closestPm ) || 0 ;
1062- const result = closestMd + ( adjustedPmPos - closestPm ) ;
1063- // console.log('PM to MD fallback:', { closestPm, closestMd, result }) ;
1064- return result ;
1107+ // Apply offset proportionally
1108+ const offset = adjustedPmPos - closestPm ;
1109+ return closestMd + offset ;
10651110 }
10661111
10671112 renderCursor ( agent : AgentPubKey , agentSelection : AgentSelection ) {
@@ -1087,7 +1132,9 @@ export class SynPmEditor extends LitElement {
10871132 ) ;
10881133
10891134 if ( position === null || position === undefined ) return html `` ;
1090- if ( markdown . length < position ) return html `` ;
1135+
1136+ // Validate position is within document bounds
1137+ if ( position < 0 || position > markdown . length ) return html `` ;
10911138
10921139 // Map markdown position to ProseMirror position
10931140 const pmPos = this . markdownPosToProseMirrorPos ( position ) ;
@@ -1102,7 +1149,14 @@ export class SynPmEditor extends LitElement {
11021149 // Clamp position to valid range
11031150 const clampedPos = Math . max ( 0 , Math . min ( pmPos , this . view . state . doc . content . size ) ) ;
11041151
1105- const coords = this . view . coordsAtPos ( clampedPos ) ;
1152+ // Try to get coordinates, return empty if invalid position
1153+ let coords ;
1154+ try {
1155+ coords = this . view . coordsAtPos ( clampedPos ) ;
1156+ } catch ( e ) {
1157+ // Invalid position, skip rendering
1158+ return html `` ;
1159+ }
11061160
11071161 if ( ! coords ) return html `` ;
11081162
@@ -1124,7 +1178,14 @@ export class SynPmEditor extends LitElement {
11241178
11251179 return html `
11261180 <div style= "position: relative; overflow: auto; flex: 1; background-color: white; display: flex; flex-direction: column;" >
1127- ${ this . _showSecretButton ? html `
1181+ <div id= "editor" > </ div>
1182+ ${ Object . entries ( this . _cursors . value )
1183+ . filter ( ( [ pubKeyB64 , _ ] ) => pubKeyB64 !== encodeHashToBase64 ( this . slice . myPubKey ) )
1184+ . map ( ( [ pubKeyB64 , position ] ) =>
1185+ this . renderCursor ( decodeHashFromBase64 ( pubKeyB64 ) , position )
1186+ ) }
1187+ </ div>
1188+ ${ this . _showSecretButton ? html `
11281189 <div style= "display: flex; justify-content: center; padding: 12px; background-color: #f8f9fa; border-bottom: 2px solid #e9ecef;" >
11291190 <butto n
11301191 @click = ${ this . _toggleAutoType }
@@ -1155,13 +1216,6 @@ export class SynPmEditor extends LitElement {
11551216 </ butto n>
11561217 </ div>
11571218 ` : '' }
1158- <div id= "editor" > </ div>
1159- ${ Object . entries ( this . _cursors . value )
1160- . filter ( ( [ pubKeyB64 , _ ] ) => pubKeyB64 !== encodeHashToBase64 ( this . slice . myPubKey ) )
1161- . map ( ( [ pubKeyB64 , position ] ) =>
1162- this . renderCursor ( decodeHashFromBase64 ( pubKeyB64 ) , position )
1163- ) }
1164- </ div>
11651219 ` ;
11661220 }
11671221
0 commit comments