Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion packages/core/src/prosemirror/plugins/suggestionMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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<typeof createSuggestionModePlugin>,
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', () => {
Expand Down Expand Up @@ -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);
});
});
});
29 changes: 26 additions & 3 deletions packages/core/src/prosemirror/plugins/suggestionMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down