diff --git a/packages/core/src/prosemirror/plugins/suggestionMode.test.ts b/packages/core/src/prosemirror/plugins/suggestionMode.test.ts index 565d8f72..af3159ff 100644 --- a/packages/core/src/prosemirror/plugins/suggestionMode.test.ts +++ b/packages/core/src/prosemirror/plugins/suggestionMode.test.ts @@ -4,7 +4,7 @@ import { describe, test, expect } from 'bun:test'; import { Schema } from 'prosemirror-model'; -import { EditorState, TextSelection } from 'prosemirror-state'; +import { EditorState, TextSelection, type Transaction } from 'prosemirror-state'; import { createSuggestionModePlugin, suggestionModeKey, setSuggestionMode } from './suggestionMode'; // Minimal schema with insertion/deletion marks @@ -40,6 +40,13 @@ function createState(text: string, active = false): EditorState { return EditorState.create({ doc, plugins: [plugin] }); } +function createStateWithPlugin(text: string, active = false) { + const plugin = createSuggestionModePlugin(active, 'TestUser'); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text(text)])]); + const state = EditorState.create({ doc, plugins: [plugin] }); + return { plugin, state }; +} + function getPluginState(state: EditorState) { return suggestionModeKey.getState(state); } @@ -65,6 +72,43 @@ function getMarks(state: EditorState): Array<{ text: string; marks: string[] }> return result; } +function getMarkedSegments(state: EditorState, markName: 'insertion' | 'deletion') { + const result: Array<{ text: string; revisionId: number; from: number; to: number }> = []; + state.doc.descendants((node, pos) => { + if (!node.isText) return; + const mark = node.marks.find((m) => m.type.name === markName); + if (!mark) return; + result.push({ + text: node.text!, + revisionId: mark.attrs.revisionId as number, + from: pos, + to: pos + node.nodeSize, + }); + }); + return result; +} + +function dispatchSuggestionKey( + plugin: ReturnType, + state: EditorState, + key: 'Backspace' | 'Delete' +): EditorState { + let nextState = state; + const handled = plugin.props.handleKeyDown?.call( + plugin, + { + state, + dispatch: (tr: Transaction) => { + nextState = state.apply(tr); + }, + } as never, + { key } as KeyboardEvent + ); + + expect(handled).toBe(true); + return nextState; +} + describe('SuggestionMode Plugin', () => { describe('plugin state', () => { test('initializes with active=false by default', () => { @@ -181,4 +225,22 @@ describe('SuggestionMode Plugin', () => { expect(worldEntry!.marks).toContain('deletion'); }); }); + + describe('single character delete grouping', () => { + test('consecutive backspaces reuse one deletion revision', () => { + const { plugin, state: initialState } = createStateWithPlugin('Hello', true); + let state = initialState.apply( + initialState.tr.setSelection(TextSelection.create(initialState.doc, 6)) + ); + + state = dispatchSuggestionKey(plugin, state, 'Backspace'); + state = dispatchSuggestionKey(plugin, state, 'Backspace'); + + expect(getText(state)).toBe('Hello'); + + const deletions = getMarkedSegments(state, 'deletion'); + expect(deletions.map((segment) => segment.text).join('')).toBe('lo'); + expect(new Set(deletions.map((segment) => segment.revisionId)).size).toBe(1); + }); + }); }); diff --git a/packages/core/src/prosemirror/plugins/suggestionMode.ts b/packages/core/src/prosemirror/plugins/suggestionMode.ts index 07b8a367..4673f3ca 100644 --- a/packages/core/src/prosemirror/plugins/suggestionMode.ts +++ b/packages/core/src/prosemirror/plugins/suggestionMode.ts @@ -71,6 +71,23 @@ function findAdjacentRevision( return null; } +/** + * Find an adjacent revision at either edge of a range. + * This keeps consecutive backspaces grouped even though the cursor moves left. + */ +function findAdjacentRevisionForRange( + doc: PMNode, + from: number, + to: number, + markTypeName: string, + author: string +): MarkAttrs | null { + return ( + findAdjacentRevision(doc, from, markTypeName, author) ?? + findAdjacentRevision(doc, to, markTypeName, author) + ); +} + /** * Walk a text range and either mark as deletion or retract own insertions. * Processes in reverse order to maintain position validity. @@ -100,7 +117,8 @@ function markRangeAsDeleted( if (ranges.length === 0) return; const delAttrs = - findAdjacentRevision(doc, from, 'deletion', pluginState.author) || makeMarkAttrs(pluginState); + findAdjacentRevisionForRange(doc, from, to, 'deletion', pluginState.author) || + makeMarkAttrs(pluginState); for (let i = ranges.length - 1; i >= 0; i--) { const range = ranges[i]; @@ -218,8 +236,13 @@ function handleSuggestionDelete( } else { // Mark as deletion instead of removing const delAttrs = - findAdjacentRevision(state.doc, deletePos, 'deletion', pluginState.author) || - makeMarkAttrs(pluginState); + findAdjacentRevisionForRange( + state.doc, + deletePos, + deleteEnd, + 'deletion', + pluginState.author + ) || makeMarkAttrs(pluginState); tr.addMark(deletePos, deleteEnd, deletionType.create(delAttrs)); // Move cursor past the deletion mark const newPos = isBackward ? deletePos : deleteEnd;