Managing undo history of multiple views with a single "root" state

Hello!
I have a use case which I cannot seem to find specific resources on, if you feel like there are some similar issues somewhere, please feel free to share.

I am building a tool, where I spawn multiple editors. Essentially, user can add new rows, where each row includes an Editor View. Something like a Code Notebook.

I have some non-editor related state, which I managed to integrate with Undo History just fine, using State Effects. My set up is a “root” Editor State, with a State Field which holds a map of so called Segments. Each Segment holds some non-editor properties, as well as their own state with an Editor View. Each Segments corresponds to a row I mention. My non-editor operations, such as adding or removing Segments are working with the Undo History as expected.

What I am trying to achieve, is a common Undo History state for all Segment’s Editor Views, as well as the non-editor related state. I’ve tried to follow the “Split View” example, but since it talks about Views that are in complete sync, I cannot really apply it to my use case. I don’t want each Editor View to have the same state, but have a common Undo History.

Desired result is basically like this:

User Adds new Segment →
Types “foo” in it →
Adds new Segment again →
types “bar” in it →
Undos → “bar” text gets undone →
Undos again →
“bar” Segment gets removed →
Undos again →
“foo” text gets undone →
Undos again →
“foo” segment gets removed

The add/remove operations work already, as I described. The problem are the changes in each editor, how to append them to the “root” state and how to reverse them in the specific editor once undo is triggered.

Following the Split View example, I pass each Segment a “sync” function, that its View uses in dispatch method. Instead of dispatching the change to other Views as in the example, I update the “root” state with an effect that includes the transaction and the corresponding ID of the Segment. This is where it stops, because it feels like utilising “invertedEffects” to manually reverse changes is kind of re-implementing Undo History extension’s logic.

Feels like I am close there, but is the solution really to update the “root” state with a State Effect including the changes and the ID of the editor it corresponds to and use “invertedEffects” with a State Effect that reverses the changes? If so, what is the best way to do it? Save the transaction in initial effect and add a reverse effect, that when triggered, applies the inverted change of the initial transaction somehow?

I’d also think you’d need something like a shared partial state here that tracks a document and history. But this may be difficult with the built-in history implementation, since it doesn’t make it easy to use its data structures separate from a specific editor view, and assumes selection is also tracked by history (though it sounds like you don’t want that). It may be necessary to fork the history implementation for this situation.

I think I’ve actually found an okay solution for it now. Would be cool to hear your thoughts.

Instead of only the “root” state having a history, I create each new Editor View instance with their own history as well - contrary to the Split View example. But, the views are not handling their own undo/redo mechanisms, the root state does.

I am still updating the root state, when the editors dispatch state updates. State Effects the root state gets updated with only include the ID of a specific segment/View. This way, the root state essentially keeps history of the order in which changes to the different views were made and does not care what the changes were.

Basically, I have three State Effects for handling this; one for basic dispatch, one for undo and one for redo.

When a View dispatches, root state gets updated with the “basic effect” that includes an ID for retrieving this specific View in my State Field. Root state’s “invertedEffects” is configured to apply the “undo effect” for this one. Undo functionality only gets triggered on the root state and when it does, the main State Field handles the “undo effect” by calling the “undo” function on the specific View with the ID coming from this State Effect.

The resulting dispatched change from the View will now include “undo” user event. This will update the root state with the “undo effect”, which inverted effect is the “redo effect”. So, when root state receives a “redo” trigger, it knows that it has to now instead call “redo” for the View with this specific ID. Obviously, the function handling the dispatch also adds an annotation, that prevents my State Field from running undo/redo in an infinite loop.

Function handling dispatch also checks redo and undo depths of transaction’s start state and the resulting state. It will not update the root state if the dispatched transaction does not affect the history depth. This way, I only update the root when reversible changes are being made, since that’s what makes sense to keep track of.

Thoughts? It works pretty well now and does exactly what I need it to do. Are there some potential pitfalls I should think about perhaps?

That’s clever. I cannot think of an obvious pitfall, but it’s complex enough that I also cannot say for sure that is has no issues!

1 Like