@@ -7,6 +7,137 @@ import { TrackChangesBasePluginKey } from '../plugins/index.js';
77import { CommentsPluginKey } from '../../comment/comments-plugin.js' ;
88import { findMarkPosition } from './documentHelpers.js' ;
99
10+ /**
11+ * Given a range (from..to) and a count of characters ("the Nth character in that range"),
12+ * returns the exact index in the document where that character sits. We only count
13+ * real text—things like embedded widgets or block boundaries are skipped. Returns
14+ * null if the count is beyond the end of the text in the range.
15+ *
16+ * @param {{ doc: import('prosemirror-model').Node, from: number, to: number, textOffset: number } } options
17+ * @returns {number | null }
18+ */
19+ const findDocPosByTextOffset = ( { doc, from, to, textOffset } ) => {
20+ let remaining = textOffset ;
21+ let foundPos = null ;
22+
23+ doc . nodesBetween ( from , to , ( node , pos ) => {
24+ if ( foundPos !== null ) {
25+ return false ;
26+ }
27+ if ( ! node . isText || ! node . text ) {
28+ return ;
29+ }
30+
31+ const nodeStart = Math . max ( from , pos ) ;
32+ const nodeEnd = Math . min ( to , pos + node . text . length ) ;
33+ if ( nodeStart >= nodeEnd ) {
34+ return ;
35+ }
36+
37+ const nodeLen = nodeEnd - nodeStart ;
38+ if ( remaining < nodeLen ) {
39+ foundPos = nodeStart + remaining ;
40+ return false ;
41+ }
42+
43+ remaining -= nodeLen ;
44+ } ) ;
45+
46+ return foundPos ;
47+ } ;
48+
49+ /**
50+ * When the user deletes one character (e.g. backspace), the editor sometimes
51+ * reports a change that spans a whole range—for example when the cursor is at
52+ * the end of a paragraph. If the only real change is one character removed, we
53+ * rewrite that into a simple "delete one character at position X" so we can
54+ * show the right red strikethrough and put the cursor in the right place.
55+ * We first try to see that from the changed range alone; if that fails (e.g. the
56+ * range includes bookmarks or paragraph boundaries), we compare the full document
57+ * text before and after to find the single deleted character. Returns the
58+ * original change unchanged if it isn't actually a one-character delete or if
59+ * we can't safely rewrite it.
60+ *
61+ * @param {{ step: import('prosemirror-transform').ReplaceStep, doc: import('prosemirror-model').Node } } options
62+ * @returns {import('prosemirror-transform').ReplaceStep }
63+ */
64+ const normalizeReplaceStepSingleCharDelete = ( { step, doc } ) => {
65+ if (
66+ ! ( step instanceof ReplaceStep ) ||
67+ step . from === step . to ||
68+ step . to - step . from <= 1 ||
69+ step . slice . content . size === 0
70+ ) {
71+ return step ;
72+ }
73+
74+ const findSingleDeletedCharPos = ( { oldText, newText, from, to } ) => {
75+ if ( oldText . length - newText . length !== 1 ) {
76+ return null ;
77+ }
78+
79+ let prefix = 0 ;
80+ while ( prefix < newText . length && oldText . charCodeAt ( prefix ) === newText . charCodeAt ( prefix ) ) {
81+ prefix += 1 ;
82+ }
83+
84+ let suffix = 0 ;
85+ while (
86+ suffix < newText . length - prefix &&
87+ oldText . charCodeAt ( oldText . length - 1 - suffix ) === newText . charCodeAt ( newText . length - 1 - suffix )
88+ ) {
89+ suffix += 1 ;
90+ }
91+
92+ if ( prefix + suffix !== newText . length ) {
93+ return null ;
94+ }
95+
96+ return findDocPosByTextOffset ( { doc, from, to, textOffset : prefix } ) ;
97+ } ;
98+
99+ // First try: only look at the text in the range that changed.
100+ const rangeOldText = doc . textBetween ( step . from , step . to ) ;
101+ const rangeNewText = step . slice . content . textBetween ( 0 , step . slice . content . size ) ;
102+ let deleteFrom = findSingleDeletedCharPos ( {
103+ oldText : rangeOldText ,
104+ newText : rangeNewText ,
105+ from : step . from ,
106+ to : step . to ,
107+ } ) ;
108+
109+ // If that didn't work—the range can include things that aren't plain text
110+ // (e.g. bookmarks or paragraph boundaries)—compare the whole document before
111+ // and after the change to find the one character that was removed. This path
112+ // is rare and O(doc size); acceptable for normal docs.
113+ if ( deleteFrom === null ) {
114+ const applied = step . apply ( doc ) ;
115+ if ( applied . failed || ! applied . doc ) {
116+ return step ;
117+ }
118+ const oldDocText = doc . textBetween ( 0 , doc . content . size ) ;
119+ const newDocText = applied . doc . textBetween ( 0 , applied . doc . content . size ) ;
120+ deleteFrom = findSingleDeletedCharPos ( {
121+ oldText : oldDocText ,
122+ newText : newDocText ,
123+ from : 0 ,
124+ to : doc . content . size ,
125+ } ) ;
126+ if ( deleteFrom === null || deleteFrom < step . from || deleteFrom >= step . to ) {
127+ return step ;
128+ }
129+ }
130+
131+ try {
132+ const deleteTo = deleteFrom + 1 ;
133+ const candidate = new ReplaceStep ( deleteFrom , deleteTo , Slice . empty , step . structure ) ;
134+ const result = candidate . apply ( doc ) ;
135+ return result . failed ? step : candidate ;
136+ } catch {
137+ return step ;
138+ }
139+ } ;
140+
10141/**
11142 * Replace step.
12143 * @param {import('prosemirror-state').EditorState } options.state Editor state.
@@ -21,6 +152,13 @@ import { findMarkPosition } from './documentHelpers.js';
21152 * @param {number } options.originalStepIndex Original step index.
22153 */
23154export const replaceStep = ( { state, tr, step, newTr, map, user, date, originalStep, originalStepIndex } ) => {
155+ const originalRange = { from : step . from , to : step . to , sliceSize : step . slice . content . size } ;
156+ step = normalizeReplaceStepSingleCharDelete ( { step, doc : newTr . doc } ) ;
157+ const stepWasNormalized =
158+ step . from !== originalRange . from ||
159+ step . to !== originalRange . to ||
160+ step . slice . content . size !== originalRange . sliceSize ;
161+
24162 // Handle structural deletions with no inline content (e.g., empty paragraph removal,
25163 // paragraph joins). When there's no content being inserted and no inline content in
26164 // the deletion range, markDeletion has nothing to mark — apply the step directly.
@@ -118,6 +256,7 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS
118256 }
119257
120258 // Condense insertion down to a single replace step (so this tracked transaction remains a single-step insertion).
259+ const docBeforeCondensedStep = newTr . doc ;
121260 const condensedStep = new ReplaceStep ( positionTo , positionTo , trackedInsertedSlice , false ) ;
122261 if ( newTr . maybeStep ( condensedStep ) . failed ) {
123262 // If the condensed step can't be applied, fall back to the original step and skip deletion tracking.
@@ -128,7 +267,15 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS
128267 }
129268
130269 // We didn't apply the original step in its original place. We adjust the map accordingly.
131- const invertStep = originalStep . invert ( tr . docs [ originalStepIndex ] ) . map ( map ) ;
270+ // When stepWasNormalized is true, `step` is already in the mapped position space
271+ // (originalStep.map(map) was applied before entering replaceStep). Calling .map(map)
272+ // again would double-map positions and corrupt subsequent step/selection mapping
273+ // in multi-step transactions.
274+ const invertSourceStep = stepWasNormalized ? step : originalStep ;
275+ const invertSourceDoc = stepWasNormalized ? docBeforeCondensedStep : tr . docs [ originalStepIndex ] ;
276+ const invertStep = stepWasNormalized
277+ ? invertSourceStep . invert ( invertSourceDoc )
278+ : invertSourceStep . invert ( invertSourceDoc ) . map ( map ) ;
132279 map . appendMap ( invertStep . getMap ( ) ) ;
133280 const mirrorIndex = map . maps . length - 1 ;
134281 map . appendMap ( condensedStep . getMap ( ) , mirrorIndex ) ;
@@ -174,6 +321,13 @@ export const replaceStep = ({ state, tr, step, newTr, map, user, date, originalS
174321 meta . insertedTo = deletionMap . map ( meta . insertedTo , 1 ) ;
175322 }
176323
324+ // Normalized broad -> single-char deletions should keep the caret at the
325+ // normalized deletion edge, not the original broad transaction selection.
326+ // This avoids follow-up Backspace events targeting structural boundaries.
327+ if ( stepWasNormalized && ! meta . insertedMark ) {
328+ meta . selectionPos = deletionMap . map ( step . from , - 1 ) ;
329+ }
330+
177331 map . appendMapping ( deletionMap ) ;
178332 }
179333
0 commit comments