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
|
|
@ -36,17 +36,41 @@ class ApplyOptions {
|
|||
const ApplyOptions({
|
||||
this.recordUndo = true,
|
||||
this.recordRedo = false,
|
||||
this.source,
|
||||
this.inMemoryUpdate = false,
|
||||
});
|
||||
|
||||
/// This flag indicates that
|
||||
/// whether the transaction should be recorded into
|
||||
/// the undo stack
|
||||
/// Whether the transaction should be recorded into the undo stack.
|
||||
@Deprecated('Use [source] instead')
|
||||
final bool recordUndo;
|
||||
|
||||
@Deprecated('Use [source] instead')
|
||||
final bool recordRedo;
|
||||
|
||||
/// The source of the transaction. When set, takes precedence over
|
||||
/// the legacy `recordUndo` and `recordRedo` flags for determining
|
||||
/// how the transaction is recorded in the undo/redo history.
|
||||
final TransactionSource? source;
|
||||
|
||||
/// This flag used to determine whether the transaction is in-memory update.
|
||||
final bool inMemoryUpdate;
|
||||
|
||||
/// Returns the resolved [TransactionSource].
|
||||
/// Prefers explicit [source], falls back to legacy boolean flags.
|
||||
///
|
||||
/// Legacy mapping (for backward compatibility):
|
||||
/// - `recordRedo: true` → [TransactionSource.undo] (records *to* redo stack)
|
||||
/// - `recordUndo: true` → [TransactionSource.userEdit]
|
||||
/// - both false → [TransactionSource.none]
|
||||
TransactionSource get resolvedSource {
|
||||
if (source != null) return source!;
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
if (recordRedo) return TransactionSource.undo;
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
if (recordUndo) return TransactionSource.userEdit;
|
||||
|
||||
return TransactionSource.none;
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('use SelectionUpdateReason instead')
|
||||
|
|
@ -671,14 +695,11 @@ class EditorState {
|
|||
Transaction transaction,
|
||||
bool skipDebounce,
|
||||
) {
|
||||
if (options.recordUndo) {
|
||||
final undoItem = undoManager.getUndoHistoryItem();
|
||||
undoItem.addAll(transaction.operations);
|
||||
if (undoItem.beforeSelection == null &&
|
||||
transaction.beforeSelection != null) {
|
||||
undoItem.beforeSelection = transaction.beforeSelection;
|
||||
}
|
||||
undoItem.afterSelection = transaction.afterSelection;
|
||||
final source = options.resolvedSource;
|
||||
undoManager.record(transaction, source);
|
||||
|
||||
// Only debounce-seal for user edits (grouping consecutive keystrokes).
|
||||
if (source == TransactionSource.userEdit) {
|
||||
if (skipDebounce && undoManager.undoStack.isNonEmpty) {
|
||||
AppFlowyEditorLog.editor.debug('Seal history item');
|
||||
final last = undoManager.undoStack.last;
|
||||
|
|
@ -686,12 +707,6 @@ class EditorState {
|
|||
} else {
|
||||
_debouncedSealHistoryItem();
|
||||
}
|
||||
} else if (options.recordRedo) {
|
||||
final redoItem = HistoryItem();
|
||||
redoItem.addAll(transaction.operations);
|
||||
redoItem.beforeSelection = transaction.beforeSelection;
|
||||
redoItem.afterSelection = transaction.afterSelection;
|
||||
undoManager.redoStack.push(redoItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,22 @@ import 'package:appflowy_editor/src/core/transform/transaction.dart';
|
|||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/infra/log.dart';
|
||||
|
||||
/// Describes the origin of a transaction for undo/redo recording.
|
||||
enum TransactionSource {
|
||||
/// A normal user edit. Records to undo stack and clears redo stack
|
||||
/// when branching (new edit after undo).
|
||||
userEdit,
|
||||
|
||||
/// An undo operation. Records to redo stack.
|
||||
undo,
|
||||
|
||||
/// A redo operation. Records to undo stack without clearing redo stack.
|
||||
redo,
|
||||
|
||||
/// Not recorded in either stack (e.g. remote or in-memory updates).
|
||||
none,
|
||||
}
|
||||
|
||||
/// A [HistoryItem] contains list of operations committed by users.
|
||||
/// If a [HistoryItem] is not sealed, operations can be added sequentially.
|
||||
/// Otherwise, the operations should be added to a new [HistoryItem].
|
||||
|
|
@ -84,6 +100,8 @@ class FixedSizeStack {
|
|||
bool get isEmpty => _list.isEmpty;
|
||||
|
||||
bool get isNonEmpty => _list.isNotEmpty;
|
||||
|
||||
int get length => _list.length;
|
||||
}
|
||||
|
||||
class UndoManager {
|
||||
|
|
@ -95,6 +113,82 @@ class UndoManager {
|
|||
: undoStack = FixedSizeStack(stackSize),
|
||||
redoStack = FixedSizeStack(stackSize);
|
||||
|
||||
/// Record a transaction into the appropriate stack based on [source].
|
||||
///
|
||||
/// Returns the [HistoryItem] that was created or updated, or null
|
||||
/// if the source is [TransactionSource.none].
|
||||
HistoryItem? record(
|
||||
Transaction transaction,
|
||||
TransactionSource source,
|
||||
) {
|
||||
switch (source) {
|
||||
case TransactionSource.userEdit:
|
||||
return _recordUserEdit(transaction);
|
||||
|
||||
case TransactionSource.undo:
|
||||
return _recordFromUndo(transaction);
|
||||
|
||||
case TransactionSource.redo:
|
||||
return _recordFromRedo(transaction);
|
||||
|
||||
case TransactionSource.none:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// User edit: add to current unsealed undo item or create a new one.
|
||||
/// Clears redo stack when branching (new sealed item means new edit
|
||||
/// after a previous undo).
|
||||
HistoryItem _recordUserEdit(Transaction transaction) {
|
||||
HistoryItem undoItem;
|
||||
if (undoStack.isEmpty) {
|
||||
undoItem = HistoryItem();
|
||||
undoStack.push(undoItem);
|
||||
} else {
|
||||
final last = undoStack.last;
|
||||
if (last.sealed) {
|
||||
redoStack.clear();
|
||||
undoItem = HistoryItem();
|
||||
undoStack.push(undoItem);
|
||||
} else {
|
||||
undoItem = last;
|
||||
}
|
||||
}
|
||||
undoItem.addAll(transaction.operations);
|
||||
if (undoItem.beforeSelection == null &&
|
||||
transaction.beforeSelection != null) {
|
||||
undoItem.beforeSelection = transaction.beforeSelection;
|
||||
}
|
||||
undoItem.afterSelection = transaction.afterSelection;
|
||||
|
||||
return undoItem;
|
||||
}
|
||||
|
||||
/// Undo operation: push directly to redo stack.
|
||||
HistoryItem _recordFromUndo(Transaction transaction) {
|
||||
final redoItem = HistoryItem();
|
||||
redoItem.addAll(transaction.operations);
|
||||
redoItem.beforeSelection = transaction.beforeSelection;
|
||||
redoItem.afterSelection = transaction.afterSelection;
|
||||
redoStack.push(redoItem);
|
||||
|
||||
return redoItem;
|
||||
}
|
||||
|
||||
/// Redo operation: push directly to undo stack, sealed immediately.
|
||||
/// Does NOT clear redo stack.
|
||||
HistoryItem _recordFromRedo(Transaction transaction) {
|
||||
final undoItem = HistoryItem();
|
||||
undoItem.addAll(transaction.operations);
|
||||
undoItem.beforeSelection = transaction.beforeSelection;
|
||||
undoItem.afterSelection = transaction.afterSelection;
|
||||
undoItem.seal();
|
||||
undoStack.push(undoItem);
|
||||
|
||||
return undoItem;
|
||||
}
|
||||
|
||||
@Deprecated('Use record() with TransactionSource instead')
|
||||
HistoryItem getUndoHistoryItem() {
|
||||
if (undoStack.isEmpty) {
|
||||
final item = HistoryItem();
|
||||
|
|
@ -127,10 +221,7 @@ class UndoManager {
|
|||
final transaction = historyItem.toTransaction(s);
|
||||
s.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: false,
|
||||
recordRedo: true,
|
||||
),
|
||||
options: const ApplyOptions(source: TransactionSource.undo),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -147,10 +238,7 @@ class UndoManager {
|
|||
final transaction = historyItem.toTransaction(s);
|
||||
s.apply(
|
||||
transaction,
|
||||
options: const ApplyOptions(
|
||||
recordUndo: true,
|
||||
recordRedo: false,
|
||||
),
|
||||
options: const ApplyOptions(source: TransactionSource.redo),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue