// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; import 'focus_manager.dart'; import 'framework.dart'; import 'text_editing_intents.dart'; /// Provides undo/redo capabilities for a [ValueNotifier]. /// /// Listens to [value] and saves relevant values for undoing/redoing. The /// cadence at which values are saved is a best approximation of the native /// behaviors of a number of hardware keyboard on Flutter's desktop /// platforms, as there are subtle differences between each of the platforms. /// /// Listens to keyboard undo/redo shortcuts and calls [onTriggered] when a /// shortcut is triggered that would affect the state of the [value]. /// /// The [child] must manage focus on the [focusNode]. For example, using a /// [TextField] or [Focus] widget. class UndoHistory<T> extends StatefulWidget { /// Creates an instance of [UndoHistory]. const UndoHistory({ super.key, this.shouldChangeUndoStack, required this.value, required this.onTriggered, required this.focusNode, this.controller, required this.child, }); /// The value to track over time. final ValueNotifier<T> value; /// Called when checking whether a value change should be pushed onto /// the undo stack. final bool Function(T? oldValue, T newValue)? shouldChangeUndoStack; /// 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. /// /// Changes to the [value] while this method is running will not be recorded /// on the undo stack. For example, a [TextInputFormatter] may change the value /// from what was on the undo stack, but this new value will not be recorded, /// as that would wipe out the redo history. final void Function(T value) onTriggered; /// The [FocusNode] that will be used to listen for focus to set the initial /// undo state for the element. final FocusNode focusNode; /// {@template flutter.widgets.undoHistory.controller} /// Controls the undo state. /// /// If null, this widget will create its own [UndoHistoryController]. /// {@endtemplate} final UndoHistoryController? controller; /// The child widget of [UndoHistory]. final Widget child; @override State<UndoHistory<T>> createState() => UndoHistoryState<T>(); } /// State for a [UndoHistory]. /// /// Provides [undo], [redo], [canUndo], and [canRedo] for programmatic access /// to the undo state for custom undo and redo UI implementations. @visibleForTesting class UndoHistoryState<T> extends State<UndoHistory<T>> with UndoManagerClient { final _UndoStack<T> _stack = _UndoStack<T>(); late final _Throttled<T> _throttledPush; Timer? _throttleTimer; bool _duringTrigger = false; // 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); // Record the last value to prevent pushing multiple // of the same value in a row onto the undo stack. For example, _push gets // called both in initState and when the EditableText receives focus. T? _lastValue; UndoHistoryController? _controller; UndoHistoryController get _effectiveController => widget.controller ?? (_controller ??= UndoHistoryController()); @override void undo() { _update(_stack.undo()); _updateState(); } @override void redo() { _update(_stack.redo()); _updateState(); } @override bool get canUndo => _stack.canUndo; @override bool get canRedo => _stack.canRedo; void _updateState() { _effectiveController.value = UndoHistoryValue(canUndo: canUndo, canRedo: canRedo); if (defaultTargetPlatform != TargetPlatform.iOS) { return; } if (UndoManager.client == this) { UndoManager.setUndoState(canUndo: canUndo, canRedo: canRedo); } } void _undoFromIntent(UndoTextIntent intent) { undo(); } void _redoFromIntent(RedoTextIntent intent) { redo(); } void _update(T? nextValue) { if (nextValue == null) { return; } if (nextValue == _lastValue) { return; } _lastValue = nextValue; _duringTrigger = true; try { widget.onTriggered(nextValue); assert(widget.value.value == nextValue); } finally { _duringTrigger = false; } } void _push() { if (widget.value.value == _lastValue) { return; } if (_duringTrigger) { return; } if (!(widget.shouldChangeUndoStack?.call(_lastValue, widget.value.value) ?? true)) { return; } _lastValue = widget.value.value; _throttleTimer = _throttledPush(widget.value.value); } void _handleFocus() { if (!widget.focusNode.hasFocus) { return; } UndoManager.client = this; _updateState(); } @override void handlePlatformUndo(UndoDirection direction) { switch(direction) { case UndoDirection.undo: undo(); break; case UndoDirection.redo: redo(); break; } } @override void initState() { super.initState(); _throttledPush = _throttle<T>( duration: _kThrottleDuration, function: (T currentValue) { _stack.push(currentValue); _updateState(); }, ); _push(); widget.value.addListener(_push); _handleFocus(); widget.focusNode.addListener(_handleFocus); _effectiveController.onUndo.addListener(undo); _effectiveController.onRedo.addListener(redo); } @override void didUpdateWidget(UndoHistory<T> oldWidget) { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value) { _stack.clear(); oldWidget.value.removeListener(_push); widget.value.addListener(_push); } if (widget.focusNode != oldWidget.focusNode) { oldWidget.focusNode.removeListener(_handleFocus); widget.focusNode.addListener(_handleFocus); } if (widget.controller != oldWidget.controller) { _effectiveController.onUndo.removeListener(undo); _effectiveController.onRedo.removeListener(redo); _controller?.dispose(); _controller = null; _effectiveController.onUndo.addListener(undo); _effectiveController.onRedo.addListener(redo); } } @override void dispose() { widget.value.removeListener(_push); widget.focusNode.removeListener(_handleFocus); _effectiveController.onUndo.removeListener(undo); _effectiveController.onRedo.removeListener(redo); _controller?.dispose(); _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: _undoFromIntent)), RedoTextIntent: Action<RedoTextIntent>.overridable(context: context, defaultAction: CallbackAction<RedoTextIntent>(onInvoke: _redoFromIntent)), }, child: widget.child, ); } } /// Represents whether the current undo stack can undo or redo. @immutable class UndoHistoryValue { /// Creates a value for whether the current undo stack can undo or redo. /// /// The [canUndo] and [canRedo] arguments must have a value, but default to /// false. const UndoHistoryValue({this.canUndo = false, this.canRedo = false}); /// A value corresponding to an undo stack that can neither undo nor redo. static const UndoHistoryValue empty = UndoHistoryValue(); /// Whether the current undo stack can perform an undo operation. final bool canUndo; /// Whether the current undo stack can perform a redo operation. final bool canRedo; @override String toString() => '${objectRuntimeType(this, 'UndoHistoryValue')}(canUndo: $canUndo, canRedo: $canRedo)'; @override bool operator ==(Object other) { if (identical(this, other)) { return true; } return other is UndoHistoryValue && other.canUndo == canUndo && other.canRedo == canRedo; } @override int get hashCode => Object.hash( canUndo.hashCode, canRedo.hashCode, ); } /// A controller for the undo history, for example for an editable text field. /// /// Whenever a change happens to the underlying value that the [UndoHistory] /// widget tracks, that widget updates the [value] and the controller notifies /// it's listeners. Listeners can then read the canUndo and canRedo /// properties of the value to discover whether [undo] or [redo] are possible. /// /// The controller also has [undo] and [redo] methods to modify the undo /// history. /// /// {@tool dartpad} /// This example creates a [TextField] with an [UndoHistoryController] /// which provides undo and redo buttons. /// /// ** See code in examples/api/lib/widgets/undo_history/undo_history_controller.0.dart ** /// {@end-tool} /// /// See also: /// /// * [EditableText], which uses the [UndoHistory] widget and allows /// control of the underlying history using an [UndoHistoryController]. class UndoHistoryController extends ValueNotifier<UndoHistoryValue> { /// Creates a controller for an [UndoHistory] widget. UndoHistoryController({UndoHistoryValue? value}) : super(value ?? UndoHistoryValue.empty); /// Notifies listeners that [undo] has been called. final ChangeNotifier onUndo = ChangeNotifier(); /// Notifies listeners that [redo] has been called. final ChangeNotifier onRedo = ChangeNotifier(); /// Reverts the value on the stack to the previous value. void undo() { if (!value.canUndo) { return; } onUndo.notifyListeners(); } /// Updates the value on the stack to the next value. void redo() { if (!value.canRedo) { return; } onRedo.notifyListeners(); } @override void dispose() { onUndo.dispose(); onRedo.dispose(); super.dispose(); } } /// 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 -1 if the list is empty. int _index = -1; /// Returns the current value of the stack. T? get currentValue => _list.isEmpty ? null : _list[_index]; bool get canUndo => _list.isNotEmpty && _index > 0; bool get canRedo => _list.isNotEmpty && _index < _list.length - 1; /// 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 != _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!; }; }