Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Undo/Redo (with snapshots + one-way commands) #1900

Open
wants to merge 20 commits into
base: main
Choose a base branch
from

Conversation

matthew-carroll
Copy link
Contributor

Undo/Redo (with snapshots + one-way commands)

This is an undo/redo concept that's intended to avoid some of the complexities of the reversible command undo/redo version (#1881) by using Editable state snapshots, combined with a history of one-way EditCommands.

With this approach, when the user wants to undo a change, the Editables are completely reverted to their most recent "snapshot" and then all historical EditCommands are replayed, except the most recent EditCommand, thus achieving undo.

This approach requires that Editables be capable of providing some kind of immutable snapshot. It might be a Dart data structure, it might be JSON, etc. The format doesn't matter. Each Editable needs to be able to provide some kind of snapshot, and then restore itself with that snapshot. This approach also re-runs a number of commands when undo'ing the previous command. This extra compute cost allows us to avoid the complexity of implementing reversible commands.

@matthew-carroll matthew-carroll force-pushed the undo-redo_snapshot-with-commands branch from 4363290 to 50deab3 Compare May 26, 2024 06:08
@matthew-carroll matthew-carroll marked this pull request as ready for review May 27, 2024 00:35
@matthew-carroll
Copy link
Contributor Author

@miguelcmedeiros @brian-superlist @knopp - Can you guys give this PR a try in Superlist to see if everything still works on your end? I don't expect that you'll use the new undo feature, but I'm doing surgery on the editor system to implement undo/redo.

There's still more work to do in this PR, but I think at this moment we're at a good place to start finding where it breaks things.

@matthew-carroll
Copy link
Contributor Author

@Jethro87 @jmatth - Please let me know if you have any thoughts about the approach in this PR. Things are starting to solidify for undo/redo and related behaviors. The tests added in this PR should provide a high level look at the new behaviors.

Copy link
Collaborator

@angelosilvestre angelosilvestre left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments. I'm wondering if we should have a way to notify the commands that were undone/redone, for the cases where commands could be used to start actions outside of the editor.

For example, if an app has a reaction that, when an user tag is typed on a task, it runs a command that calls to the backend to assign the user to this task, should the app be able to unassign the user when the command is undone?

Comment on lines +117 to +119
// End the editor transaction for all deltas in this call.
editor.endTransaction();

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a try/finally to still end the transaction even if something crashes in between?

Comment on lines +2225 to +2226

editor.endTransaction();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want a try/finally to still end the transaction even if something crashes in between?

Comment on lines +285 to +291
// editor.execute([
// ChangeSelectionRequest(
// insertionSelection,
// SelectionChangeType.placeCaret,
// SelectionReason.userInteraction,
// ),
// ]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the commented code.

Comment on lines +86 to +87
final history = <CommandTransaction>[];
final future = <CommandTransaction>[];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you intend to leave these properties public?

return;
}

print("STARTING TRANSACTION");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this and the other print statements.

void main() {
group("Super Editor > undo redo >", () {
group("text insertion >", () {
testWidgets("insert a word", (widgetTester) async {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use tester instead of widgetTester to match our existing tests?

Comment on lines +174 to +179

// Type text that causes a conversion to a header node.
await widgetTester.typeImeText("--");

// Ensure that the paragraph is now a header.
expect(SuperEditorInspector.findTextInComponent("1").text, "—");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments don't look correct, since we are not dealing with headers here.

expect(SuperEditorInspector.findTextInComponent("1").text, "Hello");

// Undo the typing.
print("------------ UNDO -------------");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have this print statement here?

// Move caret to start of paragraph.
await widgetTester.placeCaretInParagraph("1", 0);

// Type a few more characters
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please end the comment with a dot.

});
});
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a test to ensure that, when we undo or redo, we sync our editing value with the IME ?

@matthew-carroll
Copy link
Contributor Author

@angelosilvestre I'll check your comments, by this PR is still at about the 75% mark. I just tagged you so you could start to see what's going on.

@jmatth
Copy link
Contributor

jmatth commented Jun 7, 2024

@matthew-carroll I haven't gone through every line of this PR but here are my thoughts from the code I have read and the behavior I've observed in the example app so far:

Lone selection changes shouldn't be included in the undo stack

Based on some of the comments in this PR I think this might be on your radar already but thought I'd call it out anyway. In the current implementation moving the seletion gets included in the history, which is not true of other text editors I've tested against and I believe not what end users would expect. Consider the attached video: during undo/redo the selection moves between the c and f nodes without any actual changes taking place. This is something I've struggled to get right in my current custom history system since my attempted solutions have so far all led to edge cases where the selection can get out of sync with the nodes in the document and cause an exception to be thrown.

Recording.2024-06-07.151220.mp4

Support for other undo algorithms would be nice

As-is this PR has a stack based history system baked into the Editor. This is a reasonable default but we would like to eventually support alternate history systems such as undo branches. Ideally the history functionality could be contained it its own interface, with the Editor being provided an object that satisfies that interface when it is initialized. If that's not practical or other funders prefer the history functionality stay in the Editor class, we'd just prefer that the history methods be reasonably easy to override in subclasses.

@matthew-carroll
Copy link
Contributor Author

@jmatth There's behavior in this PR that merges back to back selection changes. Can you be more specific about which selection changes should and shouldn't be captured? Obviously when the selection changes as the result of a content change, we need to capture that. But please let me know what policies you expect beyond that.

WRT a branching history, do you have any resources where I can learn about that concept?

@jmatth
Copy link
Contributor

jmatth commented Jun 8, 2024

For selection changes: I don't think undo/redo should ever produce a change that only modifies the selection, which is what happens from about the 6 second mark onward in my video: I delete content from two separate nodes, then repeatedly use ctr-z and ctrl-shift-z to step backward and forward in the history, which only moves the selection between the two nodes.

It may also make more sense to phrase the requirement in terms of what does need to change for it to register in the history system, such as "an undo/redo operation must always produce a change in the Document", or even more generically "an undo/redo operation must always produce a change in one or more Editables marked as participating in the history system".

For branching history: I've only ever seen it implemented in Vim/Neovim. Instead of storing the history in stacks and trashing the redo stack when the user begins making new changes, vim stores its history as a tree and new changes just create a new branch. Here is the quickest explanation I could find on Youtube and here is plugin I've seen most people use to visualize the tree. To be clear, I'm not asking for this feature to be included, just that the final API keep in mind that history implementations might need to be more complex than simple undo/redo stacks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants