I recently had a scenario where I wanted a number of WPF text fields to support a co-ordinated undo/redo capability. The regular undo/redo facility in WPF (and most control tool-kits) applies to a single field. Changes to that field can be undone or re-applied, but each field is very much stand-alone and independent of those around it. I wanted a facility where a number of fields could be logically grouped together for the purposes of undo and redo operations, since I thought this would more closely match the user’s mental model of how the system worked. Changes should be tracked across multiple fields, and undo and redo operations should occur in the order they are made across the different fields. I also wanted the focus to change also as undo/redo operations occurred.
Using the shared undo/redo scope I created is quite easy, and can be accomplished just using XAML. An “undo manager” is set as an attached property as shown in the code sample below. The SharedUndoRedoScope attached property on the UndoManager type I created is set for a StackPanel. The property is inherited, so it can be set on a parent element (like a panel) and applied to all the children. In the example below the two textboxes share a common “undo/redo manager” because they are both children of the same stackpanel
<TextBox Text="text box 1" />
<TextBox Text="text box 2" />
The sample application (shown below) creates two shared undo/redo scopes, and a third set of elements that don’t have any special undo/redo capabilities for comparison. You can download the sample application with full source from here.
The undo manager has two main roles internally – firstly to track changes in the text boxes across all the text boxes it is managing (so they can be un-done in that order), and then actually stepping in at the right time and triggering the undo.
Tracking changes is relatively straightforward - When text changed events are fired for textboxes the undo manager is managing, it pushes the changes onto an internal undo stack. Only changes that are important are added. For example many TextChanged events will fire when someone is typing with the UndoAction of Merge, these aren’t necessary for the UndoManager to track.
Actually triggering Undo and Redo operations is slightly trickier, and I needed to work around some implementation details of the TextBox and RichTextBox controls in WPF. Both of the TextBoxBase-derived controls wire up command handlers on an internal class called the TextEditor inside their constructors. In here amongst a group of internal classes some handlers for ApplicationCommands.Undo and ApplicationCommands.Redo are created. When the WPF TextBox and RichTextBox handle these ApplicationCommands they mark them as “handled” to prevent them bubbling up to your code. While usually this is quite desirable, preventing your application code having to care about undo and redo commands being fired which the TextBox has already taken care of in this instance it causes a problem because the UndoManager needs to step in at this point and potentially do something different. Fortunately with routed events you can optionally choose to “see” routed events that have already been marked as handled, which is what I have done, by calling the AddHandler() method overload that takes a third Boolean parameter for specifying if you want to see the handled events.
textBox.AddHandler(CommandManager.PreviewCanExecuteEvent, new CanExecuteRoutedEventHandler(manager.CanExecuteRoutedEventHandler), true);
When the UndoManager sees an Undo or Redo ApplicationCommands it pops an item off its internal undo or redo stack, and calls the Undo() or Redo() method on the text box on the stack as appropriate.
Currently the implementation only tracks changes in TextBox and RichTextBox. You could extend it to handle undo and redo of selection in lists and toggling of checkboxes etc. also.
In implementing this I was conscious of trying to create a programming model that “meshed” with the rest of WPF, in particular I thought choosing an inheritable attached property was an appropriate way to expose this kind of feature. I would be keen to hear any feedback on this aspect of the implementation, in particular how this could have been done better.
Update: Roman was kind enough to point out some embarrassingly obvious flaws in my implementation (see comments below), which are fixed in the source code you can download now.