Blazor.Diagrams
Blazor.Diagrams copied to clipboard
[Contribution] Graph History (Undo/Redo)
Hey, I've added this feature to my Diagram implementation which allows me to Undo & Redo some actions (only supports Node & Link Add/Remove for now).
Since this can be possibly useful for anyone in the community, I'm leaving here the code too:
The action object (separate class)
public class GraphAction
{
public enum ActionType { AddNode, RemoveNode, AddLink, RemoveLink }
public ActionType actionType;
public object actionData;
}
Private fields necessary:
private List<GraphAction> undoActionList, redoActionList;
private int actionHistoryMaxSize = 200;
private bool isTrackingHistory, undoActionFlag, redoActionFlag;
The specific functions to handle the feautures (NodeData here is my NodeModel implementation):
#region HistoryManagement
private void InitHistoryTrack()
{
undoActionList = new();
redoActionList = new();
isTrackingHistory = true;
}
public void UndoLastAction()
{
if (!undoActionList.Any()) return;
undoActionFlag = true;
RevertAction(undoActionList[^1]);
RemoveLastUndoAction();
diagram.UnselectAll();
}
public void RedoLastAction()
{
if (!redoActionList.Any()) return;
redoActionFlag = true;
RevertAction(redoActionList[^1]);
RemoveLastRedoAction();
diagram.UnselectAll();
}
private void RevertAction(GraphAction action)
{
switch (action.actionType)
{
case GraphAction.ActionType.AddNode:
diagram.Nodes.Remove((NodeData)action.actionData);
break;
case GraphAction.ActionType.RemoveNode:
diagram.Nodes.Add((NodeData)action.actionData);
break;
case GraphAction.ActionType.AddLink:
diagram.Links.Remove((LinkModel)action.actionData);
break;
case GraphAction.ActionType.RemoveLink:
diagram.Links.Add((LinkModel)action.actionData);
break;
}
}
private void RemoveLastUndoAction()
{
if (undoActionList.Any())
undoActionList.RemoveAt(undoActionList.Count - 1);
}
private void ClearRedoList() => redoActionList.Clear();
private void RegisterRedoHistoryAction(GraphAction.ActionType _actionType, object _data)
{
var action = new GraphAction { actionType = _actionType, actionData = _data };
redoActionList.Add(action);
}
private void RemoveLastRedoAction()
{
if (redoActionList.Any())
redoActionList.RemoveAt(redoActionList.Count - 1);
}
private void RegisterUndoHistoryAction(GraphAction.ActionType _actionType, object _data)
{
if (!isTrackingHistory) return;
if (undoActionFlag && !redoActionFlag)
{
RegisterRedoHistoryAction(_actionType, _data);
undoActionFlag = false;
return;
}
if (redoActionFlag)
redoActionFlag = false;
else
ClearRedoList();
var action = new GraphAction { actionType = _actionType, actionData = _data };
if (undoActionList.Count > actionHistoryMaxSize)
undoActionList.RemoveAt(0);
undoActionList.Add(action);
}
#endregion HistoryManagement
The history track initialization [1st function] w/ its calling context [2nd function], In my implementation I only begin the history tracking after importing/building the graph for logic reasons:
private void InitHistoryTrack()
{
undoActionList = new();
redoActionList = new();
isTrackingHistory = true;
}
public void InitGraph()
{
// diagram.RegisterModelComponent<NodeData, VisualNode>();
//graphBuilder.BuildGraph(); Own code serving as context example
InitHistoryTrack();
}
Subscribing to the necessary events (this can be on the diagram Init even before diagram build/import) & the actions registering :
private void EventsSubscription()
{
diagram.Links.Added += Links_Added;
diagram.Links.Removed += Links_Removed;
diagram.Nodes.Added += Nodes_Added;
diagram.Nodes.Removed += Nodes_Removed;
}
private void Links_Added(Blazor.Diagrams.Core.Models.Base.BaseLinkModel link)
{
if (link.TargetNode is null)
link.TargetPortChanged += Link_Connected; //In case its a empty link being dragged (listen for its connection)
else
//In case it was connected instantaneously (via code)
RegisterUndoHistoryAction(GraphAction.ActionType.AddLink, link);
}
private void Link_Connected(Blazor.Diagrams.Core.Models.Base.BaseLinkModel arg1, PortModel _, PortModel outPort)
{
arg1.SourcePortChanged -= Link_Connected;
RegisterUndoHistoryAction(GraphAction.ActionType.AddLink, arg1);
}
private void Links_Removed(Blazor.Diagrams.Core.Models.Base.BaseLinkModel link)
{
if (link.IsAttached)
RegisterUndoHistoryAction(GraphAction.ActionType.RemoveLink, link);
}
private void Nodes_Added(NodeModel obj)
{
RegisterUndoHistoryAction(GraphAction.ActionType.AddNode, obj);
}
private void Nodes_Removed(NodeModel obj)
{
RegisterUndoHistoryAction(GraphAction.ActionType.RemoveNode, obj);
}
The functions below (UndoLastAction & RedoLastAction are exposed allowing user to undo & redo the actions as intended) Keyboard handle context and functions (Undo w/ Ctrl+Z & redo w/ Ctrl+Y):
diagram.KeyDown += KeyboardHandle; //This should be on Init too
private void KeyboardHandle(KeyboardEventArgs e)
{
if (e.CtrlKey && e.Key.Equals("z"))
{
UndoLastAction();
}
else if (e.CtrlKey && e.Key.Equals("y"))
{
RedoLastAction();
}
}
Hello, thanks for your contributions! I'll leave this as is in this issue for now as I can't add it to the library at the moment, feel free to create a PR for it so we can discuss this, otherwise users can just use it from here (for now).
I took a slightly different, and perhaps, a more aggressive approach to implement undo/redo in that I converted the models within Diagram Core over to ReactiveUI, turning the Model class into a ReactiveObject and thus making all derived models Reactive as well. I changed all properties to include a private backing field and then added an Observable pipeline for each property within it's respective model.
public abstract class Model: ReactiveObject, IDisposable
{
private bool _isTracking;
private bool _locked;
private bool _inhibitMove;
[JsonIgnore]
public CompositeDisposable? Disposables { get; set; }
public Model() : this(Guid.NewGuid().ToString()) { }
public Model(string id)
{
Id = id;
}
public void TrackHistory()
{
if (UndoRedoHistory != null && !_isTracking)
{
_isTracking = true;
Disposables = new CompositeDisposable();
// This will track all undo/redo changes associated to Locked
Disposables?.Add(this.WhenAnyValue(x => x.Locked).ObserveWithHistory(x => Locked = x, Locked, UndoRedoHistory, "Model Lock", "Model Lock", postUndoAction: Refresh, postRedoAction: Refresh));
// This will track all undo/redo changes associated to InhibitMove
Disposables?.Add(this.WhenAnyValue(x => x.InhibitMove).ObserveWithHistory(x => InhibitMove = x, InhibitMove, UndoRedoHistory, "Model InhibitMove", "Model InhibitMove", postUndoAction: Refresh, postRedoAction: Refresh));
}
}
[JsonIgnore]
public IHistory? UndoRedoHistory { get; set; }
public event Action? Changed;
[JsonProperty]
public string Id { get; }
[JsonProperty]
public bool Locked
{
get => _locked;
set => this.RaiseAndSetIfChanged(ref _locked, value, nameof(Locked));
}
[JsonProperty]
public bool InhibitMove
{
get => _inhibitMove;
set => this.RaiseAndSetIfChanged(ref _inhibitMove, value, nameof(InhibitMove));
}
public virtual void Refresh() => Changed?.Invoke();
public void Dispose()
{
Disposables?.Dispose();
}
}
For Undo/Redo, I added a ReactiveHistory instance within the Model.
Reactive History provides both a Property and Collection RX extension to capture and track all property and collection changes. It also provides Undo/Redo/Clear integration support for all operations such as PAN, Zoom, Move, Resize as well as all other property changes. With those behavior operations that are mouse related such as Pan, Zoom, Move and Resize, I add a RX Throttle filter operation to the observable pipelines so as to only capture an undo/redo state once all property changes have ceased. The RX Throttle filter will only pass through the last property change, at the expiration of a specified time delay.
See the use of the Throttle filter in the Track History method below...
public abstract class MovableModel : SelectableModel
{
public MovableModel()
{
}
public MovableModel(Point? position = null ):base()
{
Position = position ?? Point.Zero;
}
public MovableModel( string id, Point? position = null) : base(id)
{
Position = position ?? Point.Zero;
}
private bool _isTracking;
public new void TrackHistory()
{
if (UndoRedoHistory != null && !_isTracking)
{
_isTracking = true;
base.TrackHistory();
// This will track all changes associated to Position
Disposables?.Add(this.WhenAnyValue(x => x.Position)
.Throttle(TimeSpan.FromMilliseconds(500))
.Where(x => x != null)
.ObserveWithHistory(x => SetPositionUndo(x), Position, UndoRedoHistory, "Model Position", "Model Position", postUndoAction: Refresh, postRedoAction: Refresh, IsThrottled: true));
}
}
private Point? _position;
public Point? Position
{
get => _position;
set => this.RaiseAndSetIfChanged(ref _position, value, nameof(Position));
}
private void SetPositionUndo(Point newPosition)
{
if(newPosition != null)
{
_position = newPosition;
}
}
public virtual void SetPosition(double x, double y) => Position = new Point(x, y);
}
For Resize, I integrated with InteractJS . I had to disable the Move Behavior whilst a ReSize operation was in flight as it conflicts with the Move operation.
I also added a copy/paste clipboard manager into the Diagram which saves selected diagram model elements into the clipboard within the Browser.
I notice that I am not able to serialize the ShapeDefiner so I had to Ignore it when serializing for copy/paste and also, upon paste I have a few small clean up tasks were required to allow the newly pasted instances to become fully adopted within an existing diagram. With that I am able to copy and paste between diagrams as well as separate instances of the application since I am using the Browser's Clipboard API.
Finally my core designer interface is hosting a Docking Framework in which documents have their own Content View and it's own Diagram instance.
Here is an example of two diagrams within the Docking Layout control, Sample 1 and Sample 2...

Here is Redo Stack if I were to completely Undo the diagram changes...

I had to make a few minor adjustments to both the Diagram and Reactive History projects in order for this to work.
I plan on adding a Property Editor Tool which will provide the means to edit the state of customized Node Renderers and is why I chose the ReactiveUI and Reactive History approach so that Undo/Redo is inclusive of state changes within related nodes.
The Blazor Diagram project is very well designed and well coded. This made it relatively easy to understand the underlying framework and to integrate the above changes.
Cheers
@jkears Do you have a working example that you are willing to share?