Skip to content

Commit 6473acf

Browse files
authored
fix: clear selection on undo/redo (#2385)
* fix: clear selection on undo/redo * fix: don't drop prservedSelection for all transactions * fix: address comments
1 parent 77807e5 commit 6473acf

3 files changed

Lines changed: 107 additions & 5 deletions

File tree

packages/super-editor/src/core/extensions/keymap-history.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, afterEach } from 'vitest';
2+
import { TextSelection } from 'prosemirror-state';
23
import { closeHistory, undoDepth } from 'prosemirror-history';
34
import { initTestEditor } from '@tests/helpers/helpers.js';
45
import { handleEnter, handleBackspace, handleDelete } from './keymap.js';
@@ -100,6 +101,51 @@ describe('keymap history grouping', () => {
100101
expect(editor.state.doc.textContent).toBe('hello');
101102
});
102103

104+
it('collapses selection after undo so layout does not treat it as active range', () => {
105+
({ editor } = initTestEditor({ mode: 'text', content: '<p>Hello world</p>' }));
106+
107+
// Select "Hello"
108+
const from = 1;
109+
const to = 6;
110+
const sel = TextSelection.create(editor.state.doc, from, to);
111+
editor.view.dispatch(editor.state.tr.setSelection(sel));
112+
113+
expect(editor.state.selection.from).toBe(from);
114+
expect(editor.state.selection.to).toBe(to);
115+
expect(editor.state.selection.empty).toBe(false);
116+
117+
// Simple edit to create an undo step
118+
editor.view.dispatch(editor.state.tr.insertText('!', to));
119+
120+
// Undo should both revert the content change and collapse selection
121+
editor.commands.undo();
122+
123+
const selectionAfterUndo = editor.state.selection;
124+
expect(selectionAfterUndo.empty).toBe(true);
125+
});
126+
127+
it('clears preservedSelection/lastSelection on undo so toolbar state does not resurrect old ranges', () => {
128+
({ editor } = initTestEditor({ mode: 'text', content: '<p>Hello world</p>' }));
129+
130+
// Seed editor-level selection snapshots (simulating toolbar/command preservation)
131+
const from = 1;
132+
const to = 6;
133+
const sel = TextSelection.create(editor.state.doc, from, to);
134+
editor.options.preservedSelection = sel;
135+
editor.options.lastSelection = sel;
136+
137+
// Simple edit to create an undo step
138+
editor.view.dispatch(editor.state.tr.insertText('!', to));
139+
140+
// Undo should trigger history cleanup, which clears editor-level selection snapshots
141+
// and collapses any active text selection.
142+
editor.commands.undo();
143+
144+
expect(editor.state.selection.empty).toBe(true);
145+
expect(editor.options.preservedSelection).toBeNull();
146+
expect(editor.options.lastSelection).toBeNull();
147+
});
148+
103149
it('closeHistory before deletion creates its own undo step', () => {
104150
({ editor } = initTestEditor({ mode: 'text', content: '<p></p>' }));
105151

packages/super-editor/src/extensions/custom-selection/custom-selection.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
55
import { Decoration, DecorationSet } from 'prosemirror-view';
66
import { shouldAllowNativeContextMenu } from '../../utils/contextmenu-helpers.js';
77

8-
const DEFAULT_SELECTION_STATE = Object.freeze({
8+
export const DEFAULT_SELECTION_STATE = Object.freeze({
99
focused: false,
1010
preservedSelection: null,
1111
showVisualSelection: false,
@@ -378,6 +378,14 @@ export const CustomSelection = Extension.create({
378378
skipFocusReset: false,
379379
}),
380380
);
381+
382+
// Also clear editor-level preserved selection snapshots so that
383+
// subsequent commands (linked styles, mark commands, etc.) don't
384+
// resurrect an old selection after history undo/redo.
385+
this.editor.setOptions({
386+
preservedSelection: null,
387+
lastSelection: null,
388+
});
381389
}
382390
},
383391
},

packages/super-editor/src/extensions/history/history.js

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,49 @@
11
// @ts-nocheck
2+
import { TextSelection } from 'prosemirror-state';
23
import { history, redo as originalRedo, undo as originalUndo } from 'prosemirror-history';
34
import { undo as yUndo, redo as yRedo, yUndoPlugin } from 'y-prosemirror';
45
import { Extension } from '@core/Extension.js';
6+
import { CustomSelectionPluginKey, DEFAULT_SELECTION_STATE } from '../custom-selection/custom-selection.js';
7+
8+
function applySelectionCleanup(editor, tr) {
9+
let cleaned = tr.setMeta(CustomSelectionPluginKey, DEFAULT_SELECTION_STATE);
10+
11+
const sel = cleaned.selection;
12+
if (sel && sel instanceof TextSelection && !sel.empty) {
13+
try {
14+
const collapsed = TextSelection.create(cleaned.doc, sel.head);
15+
cleaned = cleaned.setSelection(collapsed);
16+
} catch {
17+
// Ignore collapse failures and fall back to original selection
18+
}
19+
}
20+
21+
editor.setOptions({
22+
preservedSelection: null,
23+
lastSelection: null,
24+
});
25+
26+
return cleaned;
27+
}
28+
29+
function createHistoryDispatch(editor, dispatch) {
30+
if (!dispatch) return dispatch;
31+
return (historyTr) => {
32+
const cleaned = applySelectionCleanup(editor, historyTr);
33+
dispatch(cleaned);
34+
};
35+
}
36+
37+
function runSelectionCleanupAfterCollabHistory(editor) {
38+
const view = editor?.view;
39+
const state = editor?.state;
40+
if (!view || !state) return;
41+
42+
let tr = applySelectionCleanup(editor, state.tr);
43+
// Avoid creating a new undo step for this synthetic cleanup transaction.
44+
tr = tr.setMeta('addToHistory', false);
45+
view.dispatch(tr);
46+
}
547

648
/**
749
* Configuration options for History
@@ -52,10 +94,13 @@ export const History = Extension.create({
5294
undo: () => ({ state, dispatch, tr }) => {
5395
if (this.editor.options.collaborationProvider && this.editor.options.ydoc) {
5496
tr.setMeta('preventDispatch', true);
55-
return yUndo(state);
97+
const result = yUndo(state);
98+
runSelectionCleanupAfterCollabHistory(this.editor);
99+
return result;
56100
}
57101
tr.setMeta('inputType', 'historyUndo');
58-
return originalUndo(state, dispatch);
102+
const wrappedDispatch = createHistoryDispatch(this.editor, dispatch);
103+
return originalUndo(state, wrappedDispatch);
59104
},
60105

61106
/**
@@ -68,10 +113,13 @@ export const History = Extension.create({
68113
redo: () => ({ state, dispatch, tr }) => {
69114
if (this.editor.options.collaborationProvider && this.editor.options.ydoc) {
70115
tr.setMeta('preventDispatch', true);
71-
return yRedo(state);
116+
const result = yRedo(state);
117+
runSelectionCleanupAfterCollabHistory(this.editor);
118+
return result;
72119
}
73120
tr.setMeta('inputType', 'historyRedo');
74-
return originalRedo(state, dispatch);
121+
const wrappedDispatch = createHistoryDispatch(this.editor, dispatch);
122+
return originalRedo(state, wrappedDispatch);
75123
},
76124
};
77125
},

0 commit comments

Comments
 (0)