Unverified Commit fa98a522 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Undo/redo (#96968)

parent 93a1b7a5
...@@ -205,6 +205,8 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -205,6 +205,8 @@ class DefaultTextEditingShortcuts extends Shortcuts {
const SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy, const SingleActivator(LogicalKeyboardKey.keyC, control: true): CopySelectionTextIntent.copy,
const SingleActivator(LogicalKeyboardKey.keyV, control: true): const PasteTextIntent(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyV, control: true): const PasteTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyA, control: true): const SelectAllTextIntent(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyA, control: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): const UndoTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, control: true): const RedoTextIntent(SelectionChangedCause.keyboard),
}; };
// The following key combinations have no effect on text editing on this // The following key combinations have no effect on text editing on this
...@@ -215,6 +217,7 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -215,6 +217,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C // * Meta + C
// * Meta + V // * Meta + V
// * Meta + A // * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down // * Meta + shift? + arrow down
// * Meta + shift? + arrow left // * Meta + shift? + arrow left
// * Meta + shift? + arrow right // * Meta + shift? + arrow right
...@@ -235,6 +238,7 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -235,6 +238,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C // * Meta + C
// * Meta + V // * Meta + V
// * Meta + A // * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down // * Meta + shift? + arrow down
// * Meta + shift? + arrow left // * Meta + shift? + arrow left
// * Meta + shift? + arrow right // * Meta + shift? + arrow right
...@@ -259,6 +263,7 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -259,6 +263,7 @@ class DefaultTextEditingShortcuts extends Shortcuts {
// * Meta + C // * Meta + C
// * Meta + V // * Meta + V
// * Meta + A // * Meta + A
// * Meta + shift? + Z
// * Meta + shift? + arrow down // * Meta + shift? + arrow down
// * Meta + shift? + arrow left // * Meta + shift? + arrow left
// * Meta + shift? + arrow right // * Meta + shift? + arrow right
...@@ -319,12 +324,15 @@ class DefaultTextEditingShortcuts extends Shortcuts { ...@@ -319,12 +324,15 @@ class DefaultTextEditingShortcuts extends Shortcuts {
const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy,
const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard), const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): const UndoTextIntent(SelectionChangedCause.keyboard),
const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, meta: true): const RedoTextIntent(SelectionChangedCause.keyboard),
// The following key combinations have no effect on text editing on this // The following key combinations have no effect on text editing on this
// platform: // platform:
// * End // * End
// * Home // * Home
// * Control + shift? + end // * Control + shift? + end
// * Control + shift? + home // * Control + shift? + home
// * Control + shift? + Z
}; };
// The following key combinations have no effect on text editing on this // The following key combinations have no effect on text editing on this
......
...@@ -30,6 +30,7 @@ import 'scroll_configuration.dart'; ...@@ -30,6 +30,7 @@ import 'scroll_configuration.dart';
import 'scroll_controller.dart'; import 'scroll_controller.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'shortcuts.dart';
import 'text.dart'; import 'text.dart';
import 'text_editing_intents.dart'; import 'text_editing_intents.dart';
import 'text_selection.dart'; import 'text_selection.dart';
...@@ -3138,6 +3139,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3138,6 +3139,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
cursor: widget.mouseCursor ?? SystemMouseCursors.text, cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions( child: Actions(
actions: _actions, actions: _actions,
child: _TextEditingHistory(
controller: widget.controller,
onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
},
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
includeSemantics: false, includeSemantics: false,
...@@ -3226,6 +3232,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3226,6 +3232,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
), ),
), ),
), ),
),
); );
} }
...@@ -4154,3 +4161,256 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> { ...@@ -4154,3 +4161,256 @@ class _CopySelectionAction extends ContextAction<CopySelectionTextIntent> {
@override @override
bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed; bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed;
} }
/// A void function that takes a [TextEditingValue].
@visibleForTesting
typedef TextEditingValueCallback = void Function(TextEditingValue value);
/// Provides undo/redo capabilities for text editing.
///
/// Listens to [controller] as a [ValueNotifier] and saves relevant values for
/// undoing/redoing. The cadence at which values are saved is a best
/// approximation of the native behaviors of a hardware keyboard on Flutter's
/// desktop platforms, as there are subtle differences between each of these
/// platforms.
///
/// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a
/// shortcut is triggered that would affect the state of the [controller].
class _TextEditingHistory extends StatefulWidget {
/// Creates an instance of [_TextEditingHistory].
const _TextEditingHistory({
Key? key,
required this.child,
required this.controller,
required this.onTriggered,
}) : super(key: key);
/// The child widget of [_TextEditingHistory].
final Widget child;
/// The [TextEditingController] to save the state of over time.
final TextEditingController controller;
/// Called when an undo or redo causes a state change.
///
/// If the state would still be the same before and after the undo/redo, this
/// will not be called. For example, receiving a redo when there is nothing
/// to redo will not call this method.
///
/// It is also not called when the controller is changed for reasons other
/// than undo/redo.
final TextEditingValueCallback onTriggered;
@override
State<_TextEditingHistory> createState() => _TextEditingHistoryState();
}
class _TextEditingHistoryState extends State<_TextEditingHistory> {
final _UndoStack<TextEditingValue> _stack = _UndoStack<TextEditingValue>();
late final _Throttled<TextEditingValue> _throttledPush;
Timer? _throttleTimer;
// This duration was chosen as a best fit for the behavior of Mac, Linux,
// and Windows undo/redo state save durations, but it is not perfect for any
// of them.
static const Duration _kThrottleDuration = Duration(milliseconds: 500);
void _undo(UndoTextIntent intent) {
_update(_stack.undo());
}
void _redo(RedoTextIntent intent) {
_update(_stack.redo());
}
void _update(TextEditingValue? nextValue) {
if (nextValue == null) {
return;
}
if (nextValue.text == widget.controller.text) {
return;
}
widget.onTriggered(widget.controller.value.copyWith(
text: nextValue.text,
selection: nextValue.selection,
));
}
void _push() {
if (widget.controller.value == TextEditingValue.empty) {
return;
}
_throttleTimer = _throttledPush(widget.controller.value);
}
@override
void initState() {
super.initState();
_throttledPush = _throttle<TextEditingValue>(
duration: _kThrottleDuration,
function: _stack.push,
);
_push();
widget.controller.addListener(_push);
}
@override
void didUpdateWidget(_TextEditingHistory oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
_stack.clear();
oldWidget.controller.removeListener(_push);
widget.controller.addListener(_push);
}
}
@override
void dispose() {
widget.controller.removeListener(_push);
_throttleTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>> {
UndoTextIntent: Action<UndoTextIntent>.overridable(context: context, defaultAction: CallbackAction<UndoTextIntent>(onInvoke: _undo)),
RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redo)),
},
child: widget.child,
);
}
}
/// A data structure representing a chronological list of states that can be
/// undone and redone.
class _UndoStack<T> {
/// Creates an instance of [_UndoStack].
_UndoStack();
final List<T> _list = <T>[];
// The index of the current value, or null if the list is emtpy.
late int _index;
/// Returns the current value of the stack.
T? get currentValue => _list.isEmpty ? null : _list[_index];
/// Add a new state change to the stack.
///
/// Pushing identical objects will not create multiple entries.
void push(T value) {
if (_list.isEmpty) {
_index = 0;
_list.add(value);
return;
}
assert(_index < _list.length && _index >= 0);
if (value == currentValue) {
return;
}
// If anything has been undone in this stack, remove those irrelevant states
// before adding the new one.
if (_index != null && _index != _list.length - 1) {
_list.removeRange(_index + 1, _list.length);
}
_list.add(value);
_index = _list.length - 1;
}
/// Returns the current value after an undo operation.
///
/// An undo operation moves the current value to the previously pushed value,
/// if any.
///
/// Iff the stack is completely empty, then returns null.
T? undo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index != 0) {
_index = _index - 1;
}
return currentValue;
}
/// Returns the current value after a redo operation.
///
/// A redo operation moves the current value to the value that was last
/// undone, if any.
///
/// Iff the stack is completely empty, then returns null.
T? redo() {
if (_list.isEmpty) {
return null;
}
assert(_index < _list.length && _index >= 0);
if (_index < _list.length - 1) {
_index = _index + 1;
}
return currentValue;
}
/// Remove everything from the stack.
void clear() {
_list.clear();
_index = -1;
}
@override
String toString() {
return '_UndoStack $_list';
}
}
/// A function that can be throttled with the throttle function.
typedef _Throttleable<T> = void Function(T currentArg);
/// A function that has been throttled by [_throttle].
typedef _Throttled<T> = Timer Function(T currentArg);
/// Returns a _Throttled that will call through to the given function only a
/// maximum of once per duration.
///
/// Only works for functions that take exactly one argument and return void.
_Throttled<T> _throttle<T>({
required Duration duration,
required _Throttleable<T> function,
// If true, calls at the start of the timer.
bool leadingEdge = false,
}) {
Timer? timer;
bool calledDuringTimer = false;
late T arg;
return (T currentArg) {
arg = currentArg;
if (timer != null) {
calledDuringTimer = true;
return timer!;
}
if (leadingEdge) {
function(arg);
}
calledDuringTimer = false;
timer = Timer(duration, () {
if (!leadingEdge || calledDuringTimer) {
function(arg);
}
timer = null;
});
return timer!;
};
}
...@@ -231,6 +231,16 @@ class PasteTextIntent extends Intent { ...@@ -231,6 +231,16 @@ class PasteTextIntent extends Intent {
final SelectionChangedCause cause; final SelectionChangedCause cause;
} }
/// An [Intent] that represents a user interaction that attempts to go back to
/// the previous editing state.
class RedoTextIntent extends Intent {
/// Creates a [RedoTextIntent].
const RedoTextIntent(this.cause);
/// {@macro flutter.widgets.TextEditingIntents.cause}
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to modify the /// An [Intent] that represents a user interaction that attempts to modify the
/// current [TextEditingValue] in an input field. /// current [TextEditingValue] in an input field.
class ReplaceTextIntent extends Intent { class ReplaceTextIntent extends Intent {
...@@ -250,10 +260,20 @@ class ReplaceTextIntent extends Intent { ...@@ -250,10 +260,20 @@ class ReplaceTextIntent extends Intent {
final SelectionChangedCause cause; final SelectionChangedCause cause;
} }
/// An [Intent] that represents a user interaction that attempts to go back to
/// the previous editing state.
class UndoTextIntent extends Intent {
/// Creates an [UndoTextIntent].
const UndoTextIntent(this.cause);
/// {@macro flutter.widgets.TextEditingIntents.cause}
final SelectionChangedCause cause;
}
/// An [Intent] that represents a user interaction that attempts to change the /// An [Intent] that represents a user interaction that attempts to change the
/// selection in an input field. /// selection in an input field.
class UpdateSelectionIntent extends Intent { class UpdateSelectionIntent extends Intent {
/// Creates a [UpdateSelectionIntent]. /// Creates an [UpdateSelectionIntent].
const UpdateSelectionIntent(this.currentTextEditingValue, this.newSelection, this.cause); const UpdateSelectionIntent(this.currentTextEditingValue, this.newSelection, this.cause);
/// The [TextEditingValue] that this [Intent]'s action should perform on. /// The [TextEditingValue] that this [Intent]'s action should perform on.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment