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:
parent
99e7e1efa2
commit
37521c205f
2 changed files with 580 additions and 28 deletions
|
|
@ -45,6 +45,401 @@ void main() async {
|
|||
historyItem.seal();
|
||||
expect(historyItem.sealed, true);
|
||||
});
|
||||
|
||||
test('Multi-step redo preserves redo stack', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Apply 3 separate transactions, sealing each one
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
i, // offset
|
||||
String.fromCharCode(65 + i), // 'A', 'B', 'C'
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
}
|
||||
|
||||
// Document should be "ABC"
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'ABC',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 3);
|
||||
expect(editorState.undoManager.redoStack.length, 0);
|
||||
|
||||
// Undo 3 times → back to empty
|
||||
editorState.undoManager.undo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'AB',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 2);
|
||||
expect(editorState.undoManager.redoStack.length, 1);
|
||||
|
||||
editorState.undoManager.undo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'A',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 1);
|
||||
expect(editorState.undoManager.redoStack.length, 2);
|
||||
|
||||
editorState.undoManager.undo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 0);
|
||||
expect(editorState.undoManager.redoStack.length, 3);
|
||||
|
||||
// Redo 3 times → should restore each step
|
||||
editorState.undoManager.redo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'A',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 1);
|
||||
expect(editorState.undoManager.redoStack.length, 2);
|
||||
|
||||
// Manually seal to simulate the 50ms debounce timer
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
editorState.undoManager.redo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'AB',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 2);
|
||||
expect(editorState.undoManager.redoStack.length, 1);
|
||||
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
editorState.undoManager.redo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'ABC',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 3);
|
||||
expect(editorState.undoManager.redoStack.length, 0);
|
||||
});
|
||||
|
||||
test('New edit after undo still clears redo stack', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert "A"
|
||||
final t1 = editorState.transaction;
|
||||
t1.insertText(editorState.document.nodeAtPath([0])!, 0, 'A');
|
||||
editorState.apply(t1);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
// Insert "B"
|
||||
final t2 = editorState.transaction;
|
||||
t2.insertText(editorState.document.nodeAtPath([0])!, 1, 'B');
|
||||
editorState.apply(t2);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
// Undo once → "A", redo stack should have 1 item
|
||||
editorState.undoManager.undo();
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'A',
|
||||
);
|
||||
expect(editorState.undoManager.redoStack.isNonEmpty, true);
|
||||
|
||||
// Make a NEW edit (not redo) → redo stack should be cleared
|
||||
final t3 = editorState.transaction;
|
||||
t3.insertText(editorState.document.nodeAtPath([0])!, 1, 'X');
|
||||
editorState.apply(t3);
|
||||
|
||||
expect(editorState.undoManager.redoStack.isEmpty, true);
|
||||
});
|
||||
|
||||
test('Undo after redo restores previous state', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert A, B, C
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
i,
|
||||
String.fromCharCode(65 + i),
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
}
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
|
||||
|
||||
// Undo all
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
|
||||
|
||||
// Redo 2
|
||||
editorState.undoManager.redo();
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
|
||||
expect(editorState.undoManager.undoStack.length, 2);
|
||||
expect(editorState.undoManager.redoStack.length, 1);
|
||||
|
||||
// Undo 1 — should undo the last redo
|
||||
editorState.undoManager.undo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
|
||||
expect(editorState.undoManager.undoStack.length, 1);
|
||||
expect(editorState.undoManager.redoStack.length, 2);
|
||||
|
||||
// Redo all remaining
|
||||
editorState.undoManager.redo();
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
|
||||
});
|
||||
|
||||
test('Partial undo then redo', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert A, B, C, D
|
||||
for (var i = 0; i < 4; i++) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
i,
|
||||
String.fromCharCode(65 + i),
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
}
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABCD');
|
||||
|
||||
// Undo 2 of 4
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
|
||||
expect(editorState.undoManager.undoStack.length, 2);
|
||||
expect(editorState.undoManager.redoStack.length, 2);
|
||||
|
||||
// Redo 2
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABCD');
|
||||
expect(editorState.undoManager.undoStack.length, 4);
|
||||
expect(editorState.undoManager.redoStack.length, 0);
|
||||
});
|
||||
|
||||
test('Interleaved undo and redo', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert A, B, C
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
i,
|
||||
String.fromCharCode(65 + i),
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
}
|
||||
|
||||
// undo → redo → undo → redo cycle
|
||||
editorState.undoManager.undo(); // ABC → AB
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
|
||||
|
||||
editorState.undoManager.redo(); // AB → ABC
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
|
||||
|
||||
editorState.undoManager.undo(); // ABC → AB
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
|
||||
|
||||
editorState.undoManager.undo(); // AB → A
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
|
||||
|
||||
editorState.undoManager.redo(); // A → AB
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AB');
|
||||
|
||||
editorState.undoManager.redo(); // AB → ABC
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'ABC');
|
||||
});
|
||||
|
||||
test('New edit mid-redo clears remaining redo stack', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert A, B, C
|
||||
for (var i = 0; i < 3; i++) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
i,
|
||||
String.fromCharCode(65 + i),
|
||||
);
|
||||
editorState.apply(transaction);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
}
|
||||
|
||||
// Undo all 3
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
|
||||
expect(editorState.undoManager.redoStack.length, 3);
|
||||
|
||||
// Redo 1
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'A');
|
||||
expect(editorState.undoManager.redoStack.length, 2);
|
||||
|
||||
// Make a NEW edit → remaining redo should be cleared
|
||||
final t = editorState.transaction;
|
||||
t.insertText(editorState.document.nodeAtPath([0])!, 1, 'X');
|
||||
editorState.apply(t);
|
||||
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AX');
|
||||
expect(editorState.undoManager.redoStack.length, 0);
|
||||
|
||||
// Redo should be no-op now
|
||||
editorState.undoManager.redo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), 'AX');
|
||||
});
|
||||
|
||||
test('Selection is preserved during multi-step redo', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Insert A at offset 0 with selection
|
||||
final t1 = editorState.transaction;
|
||||
t1.insertText(editorState.document.nodeAtPath([0])!, 0, 'A');
|
||||
t1.afterSelection = Selection.collapsed(Position(path: [0], offset: 1));
|
||||
editorState.apply(t1);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
// Insert B at offset 1 with selection
|
||||
final t2 = editorState.transaction;
|
||||
t2.insertText(editorState.document.nodeAtPath([0])!, 1, 'B');
|
||||
t2.afterSelection = Selection.collapsed(Position(path: [0], offset: 2));
|
||||
editorState.apply(t2);
|
||||
editorState.undoManager.undoStack.last.seal();
|
||||
|
||||
// Undo both
|
||||
editorState.undoManager.undo();
|
||||
editorState.undoManager.undo();
|
||||
expect(editorState.getNodeAtPath([0])!.delta!.toPlainText(), '');
|
||||
|
||||
// Redo first — selection should be at offset 1
|
||||
editorState.undoManager.redo();
|
||||
expect(
|
||||
editorState.selection,
|
||||
Selection.collapsed(Position(path: [0], offset: 1)),
|
||||
);
|
||||
|
||||
// Redo second — selection should be at offset 2
|
||||
editorState.undoManager.redo();
|
||||
expect(
|
||||
editorState.selection,
|
||||
Selection.collapsed(Position(path: [0], offset: 2)),
|
||||
);
|
||||
});
|
||||
|
||||
test('TransactionSource.none does not record to either stack', () {
|
||||
final editorState = EditorState(
|
||||
document: Document(
|
||||
root: pageNode(
|
||||
children: [
|
||||
paragraphNode(text: ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
editorState.disableSealTimer = true;
|
||||
|
||||
// Apply with source: none
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertText(
|
||||
editorState.document.nodeAtPath([0])!,
|
||||
0,
|
||||
'A',
|
||||
);
|
||||
editorState.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: false,
|
||||
recordRedo: false,
|
||||
source: TransactionSource.none,
|
||||
),
|
||||
);
|
||||
|
||||
// Document is modified but neither stack is affected
|
||||
expect(
|
||||
editorState.document.nodeAtPath([0])!.delta!.toPlainText(),
|
||||
'A',
|
||||
);
|
||||
expect(editorState.undoManager.undoStack.length, 0);
|
||||
expect(editorState.undoManager.redoStack.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue