fix: support multi-step redo by refactoring undo/redo recording (#1192)

Multi-step redo was broken because `getUndoHistoryItem()` cleared the
redo stack whenever the last undo item was sealed — even during redo
operations. After the first redo, the 50ms debounce timer would seal
the item, causing the next redo to wipe the remaining redo history.

Refactored the undo/redo system with:
- `TransactionSource` enum (userEdit, undo, redo, none) replacing
  boolean flag combinations in ApplyOptions
- `UndoManager.record()` with symmetric paths for each source:
  - userEdit: groups into unsealed items, clears redo on branch
  - undo: pushes directly to redo stack
  - redo: pushes directly to undo stack (sealed), no redo clear
- Deprecated `getUndoHistoryItem()` and legacy boolean flags
This commit is contained in:
Lucas 2026-03-25 15:25:00 +08:00 • committed by GitHub
commit 37521c205f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -184,11 +184,63 @@ void main() async {
expect(editor.nodeAtPath([1])!.delta!.toPlainText(), isEmpty);
expect(editor.nodeAtPath([1, 0])!.delta!.toPlainText(), text01);
// second redo
// second redo - should restore the tab indent
await _pressRedoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), text0);
expect(editor.nodeAtPath([1])!.delta!.toPlainText(), isEmpty);
expect(editor.nodeAtPath([1, 0])!.delta!.toPlainText(), text01);
expect(editor.nodeAtPath([0, 0])!.delta!.toPlainText(), isEmpty);
expect(editor.nodeAtPath([0, 0, 0])!.delta!.toPlainText(), text01);
await editor.dispose();
});
// Type A, B, C, D with delays between each,
// undo 4 times to empty, then redo 4 times.
// Each redo should restore one character.
testWidgets('Multi-step typing then multi-step undo and redo',
(tester) async {
final editor = tester.editor..addParagraph(initialText: '');
await editor.startTesting();
// Place cursor at [0] offset 0
await editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 0)),
);
// Type 4 characters with pumpAndSettle between each
// to ensure debounce sealing
await editor.ime.typeText('A');
await tester.pumpAndSettle();
await editor.ime.typeText('B');
await tester.pumpAndSettle();
await editor.ime.typeText('C');
await tester.pumpAndSettle();
await editor.ime.typeText('D');
await tester.pumpAndSettle();
expect(
editor.nodeAtPath([0])!.delta!.toPlainText(),
'ABCD',
);
// Undo 4 times
await _pressUndoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'ABC');
await _pressUndoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'AB');
await _pressUndoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'A');
await _pressUndoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), '');
// Redo 4 times - each should restore one character
await _pressRedoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'A');
await _pressRedoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'AB');
await _pressRedoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'ABC');
await _pressRedoCommand(editor);
expect(editor.nodeAtPath([0])!.delta!.toPlainText(), 'ABCD');
await editor.dispose();
});