From f12577e085909e98f16f9f816c84077336f232d0 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Fri, 13 Mar 2026 16:30:24 +0200 Subject: [PATCH 1/2] 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 c9afa2420..419902518 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 23c1ab9a0..1decad8ce 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 cbc3ddf1a..b69c97c4c 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/2] 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 1decad8ce..f6111437d 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 {