Skip to content

Commit 68f9d28

Browse files
committed
Fix cursor position updates
1 parent 6fd76d4 commit 68f9d28

File tree

2 files changed

+117
-63
lines changed

2 files changed

+117
-63
lines changed

ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "Notebooks UI",
44
"license": "MIT",
55
"author": "dev@holochain.org",
6-
"version": "0.6.4",
6+
"version": "0.6.5",
77
"dnaVersion": "0.6.0",
88
"scripts": {
99
"start": "vite --port $UI_PORT --clearScreen false",

ui/src/elements/syn-pm-editor.ts

Lines changed: 116 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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
<button
11301191
@click=${this._toggleAutoType}
@@ -1155,13 +1216,6 @@ export class SynPmEditor extends LitElement {
11551216
</button>
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

Comments
 (0)