super_editor icon indicating copy to clipboard operation
super_editor copied to clipboard

[SuperTextField] undo/redo support

Open venkatd opened this issue 4 years ago • 7 comments

@matthew-carroll

We've had some requests for undo/redo support. We'd be interested in this feature depending on the complexity/time it would take to implement.

Ideally, we want it to match the native undo-redo, but would be open to a rougher implementation to start.

One idea api-wise would be to add some sort of undo checkpoints. But I'd need to investigate more how undo/redo works on a native Mac text field.

venkatd avatar May 17 '21 01:05 venkatd

Hi @matthew-carroll,

I'd like to suggest an approach for the undo/redo functionality here since this request already exists:

I've previously implemented the functionality in an app that uses a similar command approach to SuperEditor. I ended up letting each command define its own inverse function (i.e. undo). In SuperEditor terms, the EditorCommand interface will become:

added undo function only

abstract class EditorCommand {
  /// Executes this command against the given `document`, with changes
  /// applied to the given `transaction`.
  ///
  /// The `document` is provided in case this command needs to query
  /// the current content of the `document` to make appropriate changes.
  void execute(Document document, DocumentEditorTransaction transaction);
  
  /// Executes the inverse of command against the given `document`, with changes
  /// applied to the given `transaction`.
  ///
  /// The `document` is provided in case this command needs to query
  /// the current content of the `document` to make appropriate changes.
  void undo(Document document, DocumentEditorTransaction transaction);
}

The implementations of this interface may need to cache some values that can be used later by undo. For instance, when a selected text is deleted, the undo action will need to know both the previous-selection and deleted-text in order to reapply them. So for DeleteSelectionCommand, both previous-selection and deleted-text need to be cached for the sake of this example.

In order to make this work, the undo package can be used here within DocumentEditor to track changes such as:

new code at the constructor and from executeCommand function onward

import 'package:undo/undo.dart';

/// Editor for a `Document`.
///
/// A `DocumentEditor` executes commands that alter the structure
/// of a `Document`. Commands are used so that document changes
/// can be event-sourced, allowing for undo/redo behavior.
// TODO: design and implement comprehensive event-sourced editing API (#49)
class DocumentEditor {
  static final Uuid _uuid = Uuid();

  /// Generates a new ID for a `DocumentNode`.
  ///
  /// Each generated node ID is universally unique.
  static String createNodeId() => _uuid.v4();

  /// Constructs a `DocumentEditor` that makes changes to the given
  /// `MutableDocument`.
  DocumentEditor({
    required MutableDocument document,
    int? undoHistoryLimit,
  })  : _document = document,
        _changeStack = ChangeStack(limit: undoHistoryLimit);

  final MutableDocument _document;

  /// Returns a read-only version of the `Document` that this editor
  /// is editing.
  Document get document => _document;

  /// Executes the given `command` to alter the `Document` that is tied
  /// to this `DocumentEditor`.
  void executeCommand(EditorCommand command) {
    _changeStack.add(Change<Null>(
      null, // 'oldValue'-- it's easier for command itself to cache the value(s) since there could be multiple. 
      () => command.execute(_document, DocumentEditorTransaction._(_document)), 
      (oldValue) => command.undo(_document, DocumentEditorTransaction._(_document)), 
    ));
  }

  // the undo/redo API 
  late final ChangeStack _changeStack;
  bool get canRedo => _changeStack.canRedo;
  bool get canUndo => _changeStack.canUndo;
  void undo() => _changeStack.undo();
  void redo() => _changeStack.redo();
  void clearChangesHistory() => _changeStack.clearHistory();
}

Given the suggested changes above, I believe the remaining work would be modifying the existing commands*, writing their inverse function and writing their tests.

* by modifying existing commands, I mean encapsulating the entire effect of the command within the execute function. I haven't looked much into this but, IIRC, some keyboardActions modify the selection after executeCommand is called. (edit: actually I'm not sure if this would be an issue since selection changes aren't supposed to be tracked anyway)

I hope you find this suggestion helpful.

osaxma avatar Jun 04 '21 23:06 osaxma

@osaxma thanks for the input. That's pretty much what I've had in mind. Hopefully it works out.

One additional aspect that we'll need to factor in is persistence. I think we'll want the ability to serialize and persist the event log. This might be accomplished by adding a serialization/deserialization responsibility to each command. Or, perhaps desired changes should be represented as events and then those events are executed by commands.

We'll also need some kind of composite command, or transaction, such that multiple changes are executed or undone as a single operation. But order within a transaction still matters, so that has to be taken into account, too.

matthew-carroll avatar Jun 05 '21 01:06 matthew-carroll

We have a naive implementation of undo/redo running in production that I think may work fine for a default SuperTextField which just needs to mimic native undo/redo behaviors. It stores the text/selection after every change but for a default implementation, it might be a good start. I'll share it early next week.

On an event-sourced implementation, I have some thoughts on approach based on what we learned implementing the pattern in our backend, but need to organize them before sharing.

venkatd avatar Jun 05 '21 16:06 venkatd

I moved our internal undo/redo implementation to an event-sourced strategy. It is similar to what @osaxma has suggested.

To represent the entire state of a SuperTextField, I've introduced a TextEditValue which contains text and selection. This is similar to how a TextEditingController has a TextEditingValue. (This might cause confusion so consider this a placeholder for a better name.)

Here's an example flow:

  • The character A is pressed
  • The handler calls controller.execute(ReplaceSelectedText(char))
  • The ReplaceSelectedText returns a Edit<TextEditValue> which represents a state change for TextEditValue. The edit has forward and reverse method.
  • We call timeline.forward(edit) to advance the state based on the edit. The timeline maintains a history of edits in case of undo/redo.
  • We set text/selection on the controller based on the current value

Some advantages to this approach:

  • The text/selection get set only once after executing a command. This should make code easier to reason about since contoller.text and controller.selection are "frozen" until the very last line of code where we apply the change.
  • Having a distinction between commands & edits, we'd only have to implement serialization and forward/reverse for a small number of edit primitives. (In my implementation there's only 2.) Anything that composes on top of these gets forward/reverse and serialization for free.
  • It's extensible. We could write our own custom commands and edits in a separate file and make use of them. As long as they respect the interfaces, everything including undo/redo should keep working.

Here are the interfaces:

interfaces.dart
abstract class Edit<T> {
  T forward(T current);
  T reverse(T current);
}


class TextEditValue {
  const TextEditValue({
    required this.text,
    required this.selection,
  });

  final AttributedText text;
  final TextSelection selection;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other is! TextEditValue) return false;
    // @TODO, understand why we can't compare AttributedText for equality
    return selection == other.selection && text.text == other.text.text;
  }

  @override
  int get hashCode => text.hashCode;

  @override
  String toString() {
    return 'TextEditValue(text: $text, selection: $selection)';
  }

  TextEditValue copyWith({AttributedText? text, TextSelection? selection}) {
    return TextEditValue(
      text: text ?? this.text,
      selection: selection ?? this.selection,
    );
  }
}


abstract class TextEditCommand {
  const TextEditCommand();

  Edit<TextEditValue> execute({
    required TextEditValue value,
    SuperSelectableTextState? state,
  });
}

All edits are composed from TextEdit and SetSelection:

edits.dart
import 'package:flutter/rendering.dart';
import 'package:platform_super_textfield/src/text_edit_value.dart';

import 'edit_timeline.dart';

class TextEdit implements Edit<TextEditValue> {
  const TextEdit.replace({
    required this.start,
    required this.find,
    required this.replace,
    required this.previousSelection,
  });

  const TextEdit.delete({
    required this.start,
    required this.find,
    required this.previousSelection,
  }) : replace = '';

  final int start;
  final String find;
  final String replace;
  final TextSelection previousSelection;

  @override
  TextEditValue forward(TextEditValue current) {
    final text = current.text;
    final textWithRegionRemoved = find.isEmpty
        ? text
        : text.removeRegion(
            startOffset: start,
            endOffset: start + find.length,
          );
    final updatedText = textWithRegionRemoved.insertString(
      textToInsert: replace,
      startOffset: start,
    );
    return current.copyWith(
      text: updatedText,
      selection: TextSelection.collapsed(offset: start + replace.length),
    );
  }

  @override
  TextEditValue reverse(TextEditValue current) {
    final text = current.text;
    final textWithRegionRemoved = replace.isEmpty
        ? text
        : text.removeRegion(
            startOffset: start,
            endOffset: start + replace.length,
          );

    final updatedText = find.isEmpty
        ? textWithRegionRemoved
        : textWithRegionRemoved.insertString(
            textToInsert: find,
            startOffset: start,
          );

    return current.copyWith(
      text: updatedText,
      selection: previousSelection,
    );
  }
}

class SetSelection implements Edit<TextEditValue> {
  const SetSelection({
    required this.previous,
    required this.next,
  });

  final TextSelection previous;
  final TextSelection next;

  @override
  TextEditValue forward(TextEditValue current) =>
      current.copyWith(selection: next);

  @override
  TextEditValue reverse(TextEditValue current) =>
      current.copyWith(selection: previous);
}

And they are all stored in a timeline that's managed by the AttributedTextEditingController

edit_timeline.dart

import 'dart:collection';

class EditTimeline<T> {
  EditTimeline({
    required T current,
    this.addToHistory,
  }) : _current = current;

  // The timeline looks like this:
  // [..._pastEdits, _current, ..._futureEdits]

  final Queue<Edit<T>> _pastEdits = ListQueue<Edit<T>>();
  final Queue<Edit<T>> _futureEdits = ListQueue<Edit<T>>();

  final void Function(Queue<Edit<T>> history, Edit<T> edit)? addToHistory;

  T _current;
  T get current => _current;

  // Move the history forward
  // All future values will get erased
  void advance(Edit<T> edit) {
    _current = edit.forward(current);

    final addToHistoryHandler = addToHistory ?? defaultAddToHistory;
    addToHistoryHandler(_pastEdits, edit);

    _futureEdits.clear();
  }

  bool get canUndo => _pastEdits.isNotEmpty;
  void undo() {
    assert(canUndo);
    final edit = _pastEdits.removeLast();
    _current = edit.reverse(_current);
    _futureEdits.addFirst(edit);
  }

  bool get canRedo => _futureEdits.isNotEmpty;
  void redo() {
    assert(canRedo);
    final edit = _futureEdits.removeFirst();
    _current = edit.forward(_current);
    _pastEdits.addLast(edit);
  }
}

void defaultAddToHistory<T>(Queue<Edit<T>> history, Edit<T> edit) {
  history.addLast(edit);
}

We can control how we handle checkpointing by implementing a custom addToHistory handler. In our implementation, we sometimes wrap a series of edits inside of a CompoundEdit so that undo/redo will undo all of them at once.

This can be customized depending on app's use case.

add to history handler
// This handler is intended to roughly mimic how desktop and web platforms
// implement undo/redo in native text fields. There may be more sophisticated
// ways to implement undo/redo, but that is out of the scope of the default
// platform text field
void defaultAddToHistoryHandler(
  Queue<Edit<TextEditValue>> history,
  Edit<TextEditValue> edit,
) {
  bool _shouldMergeEdits(
      {required Edit<TextEditValue> prev, required Edit<TextEditValue> next}) {
    return prev is TextEdit &&
        next is TextEdit &&
        prev.find.isEmpty &&
        prev.replace.isNotEmpty &&
        next.find.isEmpty &&
        next.replace.isNotEmpty;
  }

  // we don't want to persist purely selection updates to history
  if (edit is SetSelection) return;

  if (history.isEmpty) {
    history.addLast(edit);
    return;
  }

  final prevEdit = history.last;
  if (edit is TextEdit && prevEdit is CompoundEdit<TextEditValue>) {
    if (_shouldMergeEdits(prev: prevEdit.edits.first, next: edit)) {
      history.removeLast();
      history.addLast(CompoundEdit([...prevEdit.edits, edit]));
    } else {
      history.addLast(edit);
    }
  } else if (_shouldMergeEdits(prev: prevEdit, next: edit)) {
    history.removeLast();
    history.addLast(CompoundEdit([prevEdit, edit]));
  } else {
    history.addLast(edit);
  }
}

Maybe it's better to do this over a call once I've cleaned up and documented everything?

venkatd avatar Jun 08 '21 01:06 venkatd

I know you're partly done with this, but have you considered using a redux-style model? I was able to implements this pretty simply by just adding a clone() method on each DocumentNode (an immutable model might be better). At regular intervals, I capture a snapshot of the nodes in the document, and then simply undo/redo is simply a matter of replacing all nodes in the document with a prior version.

Tying "undo" to the EditorCommand may not be desired behavior, for example, it will be annoying if "undo" of text happens one letter at a time.

ericmartineau avatar Nov 04 '21 21:11 ericmartineau

I have an on-going technical proposal for undo/redo here: https://github.com/superlistapp/super_editor/discussions/243

That proposal is all about "event sourcing". Redux is a specific, opinionated implementation of event sourcing.

This project definitely won't adopt Redux because Redux wants to reach it's tentacles into every aspect of your app, but the final structure will probably have pieces that look similar to a "store" and "actions"

matthew-carroll avatar Nov 04 '21 21:11 matthew-carroll

Will this be implemented anytime soon?

junaid1460 avatar Oct 25 '23 15:10 junaid1460