Skip to content

Commit 6c9c7a3

Browse files
authored
fix: improve backspace behavior near run boundaries for tracked changes (#2175)
* fix: improve backspace behavior near run boundaries for tracked changes * fix: cross boundry highlight and delete is broken * fix: bound backspace text scan and prevent double-mapping in tracked replace steps * test: push remote doc tests
1 parent 52962fc commit 6c9c7a3

9 files changed

Lines changed: 657 additions & 20 deletions

File tree

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { Selection } from 'prosemirror-state';
22

3+
const findPreviousTextDeleteRange = (doc, cursorPos, minPos) => {
4+
for (let pos = cursorPos - 1; pos >= minPos; pos -= 1) {
5+
const $probe = doc.resolve(pos);
6+
const nodeBefore = $probe.nodeBefore;
7+
if (!nodeBefore?.isText || !nodeBefore.text?.length) continue;
8+
return { from: pos - 1, to: pos };
9+
}
10+
return null;
11+
};
12+
313
/**
414
* Backspaces a single character when the cursor sits adjacent to a run boundary.
515
* Deletes the last character of the previous run (or the previous sibling run) without removing the whole run node.
@@ -16,21 +26,28 @@ export const backspaceNextToRun =
1626
if ($pos.nodeBefore?.type !== runType && $pos.pos !== $pos.start()) return false;
1727

1828
if ($pos.nodeBefore) {
19-
// Should delete the last character in the run before
20-
// and not the entire run.
2129
if ($pos.nodeBefore.content.size === 0) return false;
22-
23-
tr.delete($pos.pos - 2, $pos.pos - 1).setSelection(Selection.near(tr.doc.resolve($pos.pos - 2)));
24-
if (dispatch) {
25-
dispatch(tr.scrollIntoView());
26-
}
2730
} else {
2831
const prevNode = state.doc.resolve($pos.start() - 1).nodeBefore;
2932
if (prevNode?.type !== runType || prevNode.content.size === 0) return false;
30-
tr.delete($pos.pos - 3, $pos.pos - 2).setSelection(Selection.near(tr.doc.resolve($pos.pos - 3)));
31-
if (dispatch) {
32-
dispatch(tr.scrollIntoView());
33-
}
33+
}
34+
35+
// Constrain the text scan to the adjacent run so we never delete
36+
// text from a previous paragraph or an unrelated run.
37+
let runContentStart;
38+
if ($pos.nodeBefore) {
39+
runContentStart = $pos.pos - $pos.nodeBefore.nodeSize + 1;
40+
} else {
41+
const prevNode = state.doc.resolve($pos.start() - 1).nodeBefore;
42+
runContentStart = $pos.start() - 1 - prevNode.nodeSize + 1;
43+
}
44+
45+
const deleteRange = findPreviousTextDeleteRange(state.doc, $pos.pos, runContentStart);
46+
if (!deleteRange) return false;
47+
48+
tr.delete(deleteRange.from, deleteRange.to).setSelection(Selection.near(tr.doc.resolve(deleteRange.from)));
49+
if (dispatch) {
50+
dispatch(tr.scrollIntoView());
3451
}
3552
return true;
3653
};

packages/super-editor/src/core/commands/backspaceNextToRun.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const makeSchema = () =>
99
doc: { content: 'block+' },
1010
paragraph: { group: 'block', content: 'inline*' },
1111
run: { inline: true, group: 'inline', content: 'inline*' },
12+
bookmarkEnd: { inline: true, group: 'inline', atom: true },
1213
text: { group: 'inline' },
1314
},
1415
marks: {},
@@ -81,4 +82,54 @@ describe('backspaceNextToRun', () => {
8182
expect(ok).toBe(false);
8283
expect(dispatch).not.toHaveBeenCalled();
8384
});
85+
86+
it('skips non-text inline nodes and deletes the previous text character', () => {
87+
const schema = makeSchema();
88+
const doc = schema.node('doc', null, [
89+
schema.node('paragraph', null, [
90+
schema.node('run', null, [schema.text('A'), schema.node('bookmarkEnd')]),
91+
schema.node('run', null, schema.text('.')),
92+
]),
93+
]);
94+
95+
const boundary = posBetweenRuns(doc, 'A');
96+
expect(boundary).not.toBeNull();
97+
98+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, boundary ?? 1) });
99+
let dispatched;
100+
const ok = backspaceNextToRun()({ state, tr: state.tr, dispatch: (t) => (dispatched = t) });
101+
102+
expect(ok).toBe(true);
103+
expect(dispatched).toBeDefined();
104+
// Should remove "A", not the bookmark node.
105+
expect(dispatched.doc.textContent).toBe('.');
106+
let bookmarkCount = 0;
107+
dispatched.doc.descendants((node) => {
108+
if (node.type.name === 'bookmarkEnd') bookmarkCount += 1;
109+
});
110+
expect(bookmarkCount).toBe(1);
111+
});
112+
113+
it('does not scan into previous paragraphs when adjacent run has only non-text inline content', () => {
114+
const schema = makeSchema();
115+
const doc = schema.node('doc', null, [
116+
schema.node('paragraph', null, [schema.node('run', null, schema.text('A'))]),
117+
schema.node('paragraph', null, [
118+
schema.node('run', null, [schema.node('bookmarkEnd')]),
119+
schema.node('run', null, schema.text('B')),
120+
]),
121+
]);
122+
123+
const boundary = posBetweenRuns(doc, '');
124+
expect(boundary).not.toBeNull();
125+
126+
const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, boundary ?? 1) });
127+
const dispatch = vi.fn();
128+
129+
const ok = backspaceNextToRun()({ state, tr: state.tr, dispatch });
130+
131+
expect(ok).toBe(false);
132+
expect(dispatch).not.toHaveBeenCalled();
133+
expect(state.doc.textContent).toBe('AB');
134+
});
84135
});

packages/super-editor/src/core/commands/deleteSelection.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ export const deleteSelection =
3838
const { from, to, empty } = state.selection;
3939

4040
// Fix for SD-1013
41-
// Docs that are loaded into SuperDoc, when user selects text from right to left and replace it with a single char:
42-
// Prosemirror will interpret this as a backspace operation, which will delete the character.
43-
// This is a workaround to prevent this from happening, by checking if the current DOM selection is a single character.
41+
// Docs loaded into SuperDoc can emit a stray Backspace command while replacing
42+
// a selection with a single character (right-to-left selection case).
43+
// Apply this guard only for collapsed selections so real range deletion
44+
// (highlight + Backspace/Delete) still works across run boundaries.
4445
if (typeof document !== 'undefined' && document.getSelection) {
4546
const currentDomSelection = document.getSelection();
46-
// If the current DOM selection is a single character, we don't want to delete it.
47-
if (currentDomSelection?.baseNode?.data?.length === 1) {
47+
if (empty && currentDomSelection?.baseNode?.data?.length === 1) {
4848
return false;
4949
}
5050
}

packages/super-editor/src/core/commands/deleteSelection.test.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ describe('deleteSelection', () => {
149149
// When user selects text from right to left and replace it with a single char,
150150
// Prosemirror will interpret this as a backspace operation, which will delete the character.
151151
// This is a workaround to prevent this from happening, by checking if the current DOM selection is a single character.
152-
it('returns false when current dom selection is a single character', () => {
152+
it('returns false for collapsed selection when current dom selection is a single character', () => {
153153
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('abc def ghi'))]);
154-
const sel = TextSelection.create(doc, 2, 5);
154+
const sel = TextSelection.create(doc, 2, 2);
155155
const state = EditorState.create({ schema, doc, selection: sel });
156156

157157
vi.spyOn(document, 'getSelection').mockReturnValue({
@@ -165,6 +165,28 @@ describe('deleteSelection', () => {
165165
expect(ok).toBe(false);
166166
});
167167

168+
it('does not short-circuit non-empty selection when dom baseNode length is 1', () => {
169+
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('abc def ghi'))]);
170+
const sel = TextSelection.create(doc, 2, 5);
171+
const state = EditorState.create({ schema, doc, selection: sel });
172+
173+
vi.spyOn(document, 'getSelection').mockReturnValue({
174+
baseNode: {
175+
data: 'a',
176+
},
177+
});
178+
179+
pmDeleteSelection.mockReturnValueOnce('delegated-single-char-node');
180+
181+
const cmd = deleteSelection();
182+
const dispatch = vi.fn();
183+
const res = cmd({ state, tr: state.tr, dispatch });
184+
185+
expect(pmDeleteSelection).toHaveBeenCalledTimes(1);
186+
expect(pmDeleteSelection).toHaveBeenCalledWith(state, dispatch);
187+
expect(res).toBe('delegated-single-char-node');
188+
});
189+
168190
it('handles SSR environment when document is undefined', () => {
169191
// Save original document reference
170192
const originalDocument = globalThis.document;

packages/super-editor/src/extensions/track-changes/trackChangesHelpers/replaceStep.js

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,137 @@ import { TrackChangesBasePluginKey } from '../plugins/index.js';
77
import { CommentsPluginKey } from '../../comment/comments-plugin.js';
88
import { 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
*/
23154
export 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

Comments
 (0)