From f12577e085909e98f16f9f816c84077336f232d0 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 13 Mar 2026 16:30:24 +0200 Subject: [PATCH 1/3] fix: clear selection on undo/redo --- .../core/extensions/keymap-history.test.js | 24 ++++++++++++ .../custom-selection/custom-selection.js | 23 +++++++++++ .../src/extensions/history/history.js | 39 ++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/extensions/keymap-history.test.js b/packages/super-editor/src/core/extensions/keymap-history.test.js index c9afa24209..4199025185 100644 --- a/packages/super-editor/src/core/extensions/keymap-history.test.js +++ b/packages/super-editor/src/core/extensions/keymap-history.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; import { closeHistory, undoDepth } from 'prosemirror-history'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { handleEnter, handleBackspace, handleDelete } from './keymap.js'; @@ -100,6 +101,29 @@ describe('keymap history grouping', () => { expect(editor.state.doc.textContent).toBe('hello'); }); + it('collapses selection after undo so layout does not treat it as active range', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

Hello world

' })); + + // Select "Hello" + const from = 1; + const to = 6; + const sel = TextSelection.create(editor.state.doc, from, to); + editor.view.dispatch(editor.state.tr.setSelection(sel)); + + expect(editor.state.selection.from).toBe(from); + expect(editor.state.selection.to).toBe(to); + expect(editor.state.selection.empty).toBe(false); + + // Simple edit to create an undo step + editor.view.dispatch(editor.state.tr.insertText('!', to)); + + // Undo should both revert the content change and collapse selection + editor.commands.undo(); + + const selectionAfterUndo = editor.state.selection; + expect(selectionAfterUndo.empty).toBe(true); + }); + it('closeHistory before deletion creates its own undo step', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

' })); diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.js index 23c1ab9a04..1decad8cee 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.js @@ -163,6 +163,21 @@ export const CustomSelection = Extension.create({ if (!nextState?.preservedSelection) return nextState; if (!tr.docChanged) return nextState; + // For history/undo-like transactions and other non-history mutations + // (marked with addToHistory: false), clear any preserved visual + // selection instead of remapping it. This ensures that highlights + // don't "reappear" after undo/redo cycles, while still allowing + // normal typing/editing (which uses the default addToHistory: true) + // to preserve selection overlays when appropriate. + const addToHistoryMeta = tr.getMeta('addToHistory'); + if (addToHistoryMeta === false) { + return { + ...nextState, + preservedSelection: null, + showVisualSelection: false, + }; + } + const mappedSelection = mapPreservedSelection(nextState.preservedSelection, tr); if (!mappedSelection) { return { @@ -378,6 +393,14 @@ export const CustomSelection = Extension.create({ skipFocusReset: false, }), ); + + // Also clear editor-level preserved selection snapshots so that + // subsequent commands (linked styles, mark commands, etc.) don't + // resurrect an old selection after history undo/redo. + this.editor.setOptions({ + preservedSelection: null, + lastSelection: null, + }); } }, }, diff --git a/packages/super-editor/src/extensions/history/history.js b/packages/super-editor/src/extensions/history/history.js index cbc3ddf1ab..b69c97c4c5 100644 --- a/packages/super-editor/src/extensions/history/history.js +++ b/packages/super-editor/src/extensions/history/history.js @@ -1,7 +1,40 @@ // @ts-nocheck +import { TextSelection } from 'prosemirror-state'; import { history, redo as originalRedo, undo as originalUndo } from 'prosemirror-history'; import { undo as yUndo, redo as yRedo, yUndoPlugin } from 'y-prosemirror'; import { Extension } from '@core/Extension.js'; +import { CustomSelectionPluginKey } from '../custom-selection/custom-selection.js'; + +function createHistoryDispatch(editor, dispatch) { + if (!dispatch) return dispatch; + + return (historyTr) => { + let cleared = historyTr.setMeta(CustomSelectionPluginKey, { + focused: false, + preservedSelection: null, + showVisualSelection: false, + skipFocusReset: false, + }); + + const sel = cleared.selection; + if (sel && sel instanceof TextSelection && !sel.empty) { + const headPos = typeof sel.head === 'number' ? sel.head : sel.to; + try { + const collapsed = TextSelection.create(cleared.doc, headPos); + cleared = cleared.setSelection(collapsed); + } catch { + // Ignore collapse failures and fall back to original selection + } + } + + editor.setOptions({ + preservedSelection: null, + lastSelection: null, + }); + + dispatch(cleared); + }; +} /** * Configuration options for History @@ -55,7 +88,8 @@ export const History = Extension.create({ return yUndo(state); } tr.setMeta('inputType', 'historyUndo'); - return originalUndo(state, dispatch); + const wrappedDispatch = createHistoryDispatch(this.editor, dispatch); + return originalUndo(state, wrappedDispatch); }, /** @@ -71,7 +105,8 @@ export const History = Extension.create({ return yRedo(state); } tr.setMeta('inputType', 'historyRedo'); - return originalRedo(state, dispatch); + const wrappedDispatch = createHistoryDispatch(this.editor, dispatch); + return originalRedo(state, wrappedDispatch); }, }; }, From f8b9a1774ad54c785c143d5248cdc8fb7eb5ed99 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 13 Mar 2026 16:53:28 +0200 Subject: [PATCH 2/3] fix: don't drop prservedSelection for all transactions --- .../custom-selection/custom-selection.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.js index 1decad8cee..f6111437d6 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.js @@ -163,21 +163,6 @@ export const CustomSelection = Extension.create({ if (!nextState?.preservedSelection) return nextState; if (!tr.docChanged) return nextState; - // For history/undo-like transactions and other non-history mutations - // (marked with addToHistory: false), clear any preserved visual - // selection instead of remapping it. This ensures that highlights - // don't "reappear" after undo/redo cycles, while still allowing - // normal typing/editing (which uses the default addToHistory: true) - // to preserve selection overlays when appropriate. - const addToHistoryMeta = tr.getMeta('addToHistory'); - if (addToHistoryMeta === false) { - return { - ...nextState, - preservedSelection: null, - showVisualSelection: false, - }; - } - const mappedSelection = mapPreservedSelection(nextState.preservedSelection, tr); if (!mappedSelection) { return { From aa2f13db1ebf83c5c4de2cec06bebc2847e21927 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 16 Mar 2026 16:30:37 +0200 Subject: [PATCH 3/3] fix: address comments --- .../core/extensions/keymap-history.test.js | 22 ++++++ .../custom-selection/custom-selection.js | 2 +- .../src/extensions/history/history.js | 67 +++++++++++-------- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/packages/super-editor/src/core/extensions/keymap-history.test.js b/packages/super-editor/src/core/extensions/keymap-history.test.js index 4199025185..0bae556d66 100644 --- a/packages/super-editor/src/core/extensions/keymap-history.test.js +++ b/packages/super-editor/src/core/extensions/keymap-history.test.js @@ -124,6 +124,28 @@ describe('keymap history grouping', () => { expect(selectionAfterUndo.empty).toBe(true); }); + it('clears preservedSelection/lastSelection on undo so toolbar state does not resurrect old ranges', () => { + ({ editor } = initTestEditor({ mode: 'text', content: '

Hello world

' })); + + // Seed editor-level selection snapshots (simulating toolbar/command preservation) + const from = 1; + const to = 6; + const sel = TextSelection.create(editor.state.doc, from, to); + editor.options.preservedSelection = sel; + editor.options.lastSelection = sel; + + // Simple edit to create an undo step + editor.view.dispatch(editor.state.tr.insertText('!', to)); + + // Undo should trigger history cleanup, which clears editor-level selection snapshots + // and collapses any active text selection. + editor.commands.undo(); + + expect(editor.state.selection.empty).toBe(true); + expect(editor.options.preservedSelection).toBeNull(); + expect(editor.options.lastSelection).toBeNull(); + }); + it('closeHistory before deletion creates its own undo step', () => { ({ editor } = initTestEditor({ mode: 'text', content: '

' })); diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.js index f6111437d6..11164e9b8b 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.js @@ -5,7 +5,7 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { shouldAllowNativeContextMenu } from '../../utils/contextmenu-helpers.js'; -const DEFAULT_SELECTION_STATE = Object.freeze({ +export const DEFAULT_SELECTION_STATE = Object.freeze({ focused: false, preservedSelection: null, showVisualSelection: false, diff --git a/packages/super-editor/src/extensions/history/history.js b/packages/super-editor/src/extensions/history/history.js index b69c97c4c5..c4df191399 100644 --- a/packages/super-editor/src/extensions/history/history.js +++ b/packages/super-editor/src/extensions/history/history.js @@ -3,39 +3,48 @@ import { TextSelection } from 'prosemirror-state'; import { history, redo as originalRedo, undo as originalUndo } from 'prosemirror-history'; import { undo as yUndo, redo as yRedo, yUndoPlugin } from 'y-prosemirror'; import { Extension } from '@core/Extension.js'; -import { CustomSelectionPluginKey } from '../custom-selection/custom-selection.js'; +import { CustomSelectionPluginKey, DEFAULT_SELECTION_STATE } from '../custom-selection/custom-selection.js'; -function createHistoryDispatch(editor, dispatch) { - if (!dispatch) return dispatch; - - return (historyTr) => { - let cleared = historyTr.setMeta(CustomSelectionPluginKey, { - focused: false, - preservedSelection: null, - showVisualSelection: false, - skipFocusReset: false, - }); +function applySelectionCleanup(editor, tr) { + let cleaned = tr.setMeta(CustomSelectionPluginKey, DEFAULT_SELECTION_STATE); - const sel = cleared.selection; - if (sel && sel instanceof TextSelection && !sel.empty) { - const headPos = typeof sel.head === 'number' ? sel.head : sel.to; - try { - const collapsed = TextSelection.create(cleared.doc, headPos); - cleared = cleared.setSelection(collapsed); - } catch { - // Ignore collapse failures and fall back to original selection - } + const sel = cleaned.selection; + if (sel && sel instanceof TextSelection && !sel.empty) { + try { + const collapsed = TextSelection.create(cleaned.doc, sel.head); + cleaned = cleaned.setSelection(collapsed); + } catch { + // Ignore collapse failures and fall back to original selection } + } + + editor.setOptions({ + preservedSelection: null, + lastSelection: null, + }); - editor.setOptions({ - preservedSelection: null, - lastSelection: null, - }); + return cleaned; +} - dispatch(cleared); +function createHistoryDispatch(editor, dispatch) { + if (!dispatch) return dispatch; + return (historyTr) => { + const cleaned = applySelectionCleanup(editor, historyTr); + dispatch(cleaned); }; } +function runSelectionCleanupAfterCollabHistory(editor) { + const view = editor?.view; + const state = editor?.state; + if (!view || !state) return; + + let tr = applySelectionCleanup(editor, state.tr); + // Avoid creating a new undo step for this synthetic cleanup transaction. + tr = tr.setMeta('addToHistory', false); + view.dispatch(tr); +} + /** * Configuration options for History * @typedef {Object} HistoryOptions @@ -85,7 +94,9 @@ export const History = Extension.create({ undo: () => ({ state, dispatch, tr }) => { if (this.editor.options.collaborationProvider && this.editor.options.ydoc) { tr.setMeta('preventDispatch', true); - return yUndo(state); + const result = yUndo(state); + runSelectionCleanupAfterCollabHistory(this.editor); + return result; } tr.setMeta('inputType', 'historyUndo'); const wrappedDispatch = createHistoryDispatch(this.editor, dispatch); @@ -102,7 +113,9 @@ export const History = Extension.create({ redo: () => ({ state, dispatch, tr }) => { if (this.editor.options.collaborationProvider && this.editor.options.ydoc) { tr.setMeta('preventDispatch', true); - return yRedo(state); + const result = yRedo(state); + runSelectionCleanupAfterCollabHistory(this.editor); + return result; } tr.setMeta('inputType', 'historyRedo'); const wrappedDispatch = createHistoryDispatch(this.editor, dispatch);