In the above image, it demostrated muptile features:
- Unified node editor
- Customized link style
- Extended nodes
- Selection/deleting/pasting/alignment
There are a lot of out of box features from Blazor.Diagrams nuget package also.
- Grid widget
- Overview widget
- Selection/deleting
- Drag/drop for movement
- Ports/links connection
and more...
Host the diagram
<ContextMenuTrigger MenuId="dashboardMenu" CssClass="w-full h-full">
<CascadingValue Value="diagram" IsFixed="true">
<DiagramCanvas>
<Widgets>
<SelectionBoxWidget />
@if (showGrid)
{
<GridWidget Size="24" Mode="GridMode.Line" BackgroundColor="transparent" />
}
<NavigatorWidget Width="200" Height="120" Class="border-1px border-slate-200 bg-white absolute bottom-2 right-2 shadow-md" />
</Widgets>
</DiagramCanvas>
</CascadingValue>
</ContextMenuTrigger>
public partial class Dashboard
{
[Parameter] public long? Id { get; set; }
[Parameter] public string? Name { get; set; }
[Parameter] public string? Descrition { get; set; }
[Inject] public required IModalService ModalService { get; set; }
[Inject] public required PlcDbContext PlcDbContext { get; set; }
[Inject] public required NavigationManager NavigationManager { get; set; }
private readonly Dictionary<string, string> customizedLinkStyle = [];
private bool isLoading;
private bool showGrid;
private bool isPointerReleased = true;
private BlazorDiagram diagram = null!;
private Db.Dashboard? dashboard;
private Model? editingModel;
protected override void OnInitialized()
{
var options = new BlazorDiagramOptions
{
AllowMultiSelection = true,
AllowPanning = true,
GridSnapToCenter = true,
GridSize = 4,
Zoom =
{
Enabled = true,
},
Links =
{
DefaultRouter = new NormalRouter(),
DefaultPathGenerator = new SmoothPathGenerator()
},
};
diagram = new BlazorDiagram(options);
NodeModelBase.RegisterAllDerivedModels(diagram);
diagram.UnregisterBehavior<SelectionBehavior>();
diagram.UnregisterBehavior<DragMovablesBehavior>();
diagram.RegisterBehavior(new SelectionBehavior2(diagram));
diagram.RegisterBehavior(new DragMovablesBehavior(diagram));
diagram.SelectionChanged += Diagram_SelectionChanged;
diagram.PointerDown += Diagram_PointerDown;
diagram.PointerUp += Diagram_PointerUp;
diagram.Links.Added += Links_Added;
diagram.Links.Removed += Links_Removed;
diagram.Nodes.Removed += Nodes_Removed;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && Id.HasValue)
{
await Task.Delay(10); // Let the diagram layout to be fully ready
await LoadDashboard();
}
}
private void Diagram_SelectionChanged(SelectableModel model)
{
if (model.Selected)
{
editingModel = model;
}
else
{
editingModel?.Refresh();
editingModel = null;
}
StateHasChanged();
}
private void Diagram_PointerDown(Model? arg1, Blazor.Diagrams.Core.Events.PointerEventArgs arg2)
{
isPointerReleased = false;
StateHasChanged();
}
private void Diagram_PointerUp(Model? arg1, Blazor.Diagrams.Core.Events.PointerEventArgs arg2)
{
isPointerReleased = true;
StateHasChanged();
}
private void Links_Added(BaseLinkModel link)
{
if (link is not StyledLinkModel linkModel)
{
link.Changed += Link_Changed;
}
}
private void Links_Removed(BaseLinkModel obj)
{
if (obj is StyledLinkModel linkModel)
{
linkModel.Changed -= StyledLinkModel_Changed;
}
StateHasChanged();
}
private void Nodes_Removed(NodeModel obj)
{
}
private void Link_Changed(Model link)
{
// Relapace default LinkModel with StyledLinkModel
if (link is LinkModel linkModel && linkModel.IsAttached)
{
link.Changed -= Link_Changed;
var styledLinkModel = new StyledLinkModel(linkModel.Source, linkModel.Target);
diagram.Links.Remove(linkModel);
diagram.Links.Add(styledLinkModel);
diagram.SelectModel(styledLinkModel, true);
styledLinkModel.Changed += StyledLinkModel_Changed;
StyledLinkModel_Changed(styledLinkModel);
}
}
private void StyledLinkModel_Changed(Model model)
{
if (model is StyledLinkModel styledLinkModel)
{
if (styledLinkModel.Dash != 0 || styledLinkModel.Color != StyledLinkModel.DefaultColor)
{
customizedLinkStyle[styledLinkModel.Id] = styledLinkModel.MakeStyleClass();
}
else
{
customizedLinkStyle.Remove(styledLinkModel.Id);
}
StateHasChanged();
}
}
private void AddNode(ItemClickEventArgs e, NodeModel node)
{
node.Position = new Point(e.MouseEvent.ClientX - e.MouseEvent.OffsetX - diagram.Pan.X, e.MouseEvent.ClientY - e.MouseEvent.OffsetY - 80 - diagram.Pan.Y);
diagram.Nodes.Add(node);
diagram.SelectModel(node, true);
StateHasChanged();
}
private async Task Save()
{
try
{
isLoading = true;
StateHasChanged();
if (dashboard == null)
{
dashboard = new Db.Dashboard();
PlcDbContext.Dashboards.Add(dashboard);
}
dashboard.Name = Name ?? "PLC Dashboard";
dashboard.Description = Descrition;
dashboard.Layout = DashboardLayout.FromModels(diagram.Nodes, diagram.Links).SerializeAsJson();
await PlcDbContext.SaveChangesAsync();
Id = dashboard.Id;
//await LoadDashboard(dashboard);
NavigationManager.NavigateTo(NavigationManager.GetUriWithQueryParameter(Dashboards.SelectedIdQueryName, dashboard.Id));
}
catch (Exception ex)
{
isLoading = false;
await ModalService.ShowSimpleDialog("Save dashboard failed", ex.Message, level: NotificationLevel.Error);
}
finally
{
isLoading = false;
}
}
private async Task LoadDashboard(Db.Dashboard? dashboardFromDb = null)
{
if (!Id.HasValue) return;
try
{
isLoading = true;
StateHasChanged();
diagram.Nodes.Clear();
diagram.Links.Clear();
dashboard = dashboardFromDb ?? await PlcDbContext.Dashboards.FirstOrDefaultAsync(x => x.Id == Id);
if (dashboard != null)
{
Name = dashboard.Name;
Descrition = dashboard.Description;
DashboardLayout.FromJson(dashboard.Layout).ApplyToDiagram(diagram, StyledLinkModel_Changed);
}
}
catch (Exception ex)
{
isLoading = false;
await ModalService.ShowSimpleDialog("Load dashboard failed", ex.Message, level: NotificationLevel.Error);
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private void CloseModelEditor()
{
editingModel?.Refresh();
editingModel = null;
}
private void PasteSelectedItems(ItemClickEventArgs e)
{
var selectedItems = diagram.GetSelectedModels();
if (selectedItems.Any())
{
var nodes = selectedItems.Where(x => x is NodeModelBase).Select(x => (x as NodeModelBase)!).ToList();
var links = selectedItems.Where(x => x is BaseLinkModel).Select(x => (x as BaseLinkModel)!).ToHashSet()!;
// If link is attached between selected nodes, we should also include it for copying
foreach (var node in nodes)
{
var tryAddLink = (BaseLinkModel? link) =>
{
if (link?.Source.Model is PortModel sourcePortModel && nodes.Contains(sourcePortModel.Parent) &&
link?.Target.Model is PortModel targetPortModel && nodes.Contains(targetPortModel.Parent))
{
links.Add(link);
}
};
foreach (var link in node.Links)
{
tryAddLink(link);
}
foreach (var link in node.PortLinks)
{
tryAddLink(link);
}
}
var json = DashboardLayout.FromModels(nodes, links).SerializeAsJson();
var firstNode = nodes.FirstOrDefault();
// TODO: The delta needs to be optimized
var deltaX = e.MouseEvent.ClientX - e.MouseEvent.OffsetX - diagram.Pan.X - firstNode?.Position.X;
var deltaY = e.MouseEvent.ClientY - e.MouseEvent.OffsetY - diagram.Pan.Y - firstNode?.Position.Y;
var layout = DashboardLayout.FromJson(json);
foreach (var node in layout.Nodes)
{
var oldRefId = node.RefId;
var newPosition = node.Position.Add(deltaX ?? 0, deltaY ?? 0);
node.RefId = Guid.NewGuid();
node.SetPosition(newPosition.X, newPosition.Y);
// Replace with new RefId
foreach (var link in layout.Links)
{
if (link.SourceId == oldRefId) link.SourceId = node.RefId;
if (link.TargetId == oldRefId) link.TargetId = node.RefId;
}
}
// Unselect all first, so we can select pasted items later
diagram.UnselectAll();
layout.ApplyToDiagram(
diagram,
mapNode: node =>
{
diagram.SelectModel(node, false);
return node;
},
mapLink: link =>
{
diagram.SelectModel(link, false);
return link;
}
);
}
}
private void DeleteSelectedItems()
{
foreach (var item in diagram.GetSelectedModels())
{
if (item is NodeModel node) diagram.Nodes.Remove(node);
else if (item is LinkModel link) diagram.Links.Remove(link);
}
}
private void AlignToTop()
{
var nodes = diagram.GetSelectedModels().Where(x => x is NodeModelBase).Select(x => (NodeModelBase)x).ToList();
if (nodes.Count > 1)
{
var y = nodes.Select(x => x.Position.Y).Min();
foreach (var node in nodes)
{
node.SetPosition(node.Position.X, y);
}
}
}
private void AlignToBottom()
{
var nodes = diagram.GetSelectedModels().Where(x => x is NodeModelBase).Select(x => (NodeModelBase)x).ToList();
if (nodes.Count > 1)
{
var y = nodes.Select(x => x.Position.Y + x.Size?.Height).Max();
foreach (var node in nodes)
{
node.SetPosition(node.Position.X, y - node.Size?.Height ?? 0);
}
}
}
private void AlignToLeft()
{
var nodes = diagram.GetSelectedModels().Where(x => x is NodeModelBase).Select(x => (NodeModelBase)x).ToList();
if (nodes.Count > 1)
{
var x = nodes.Select(x => x.Position.X).Min();
foreach (var node in nodes)
{
node.SetPosition(x, node.Position.Y);
}
}
}
private void AlignToRight()
{
var nodes = diagram.GetSelectedModels().Where(x => x is NodeModelBase).Select(x => (NodeModelBase)x).ToList();
if (nodes.Count > 1)
{
var x = nodes.Select(x => x.Position.X + x.Size?.Width).Max();
foreach (var node in nodes)
{
node.SetPosition(x - node.Size?.Width ?? 0, node.Position.Y);
}
}
}
}
Serialize diagram for saving
With System.Text.Json I can simplify the serialization and deserialization:
[JsonDerivedType(typeof(TextNodeModel), typeDiscriminator: nameof(TextNodeModel))]
[JsonDerivedType(typeof(TagNodeModel), typeDiscriminator: nameof(TagNodeModel))]
[JsonDerivedType(typeof(UrlNodeModel), typeDiscriminator: nameof(UrlNodeModel))]
[JsonDerivedType(typeof(ImageNodeModel), typeDiscriminator: nameof(ImageNodeModel))]
[JsonDerivedType(typeof(EmbedNodeModel), typeDiscriminator: nameof(EmbedNodeModel))]
public class NodeModelBase : NodeModel
{
public Guid RefId { get; set; } = Guid.NewGuid();
public static void RegisterAllDerivedModels(BlazorDiagram diagram)
{
diagram.RegisterComponent<TextNodeModel, TextNode>();
diagram.RegisterComponent<TagNodeModel, TagNode>();
diagram.RegisterComponent<UrlNodeModel, UrlNode>();
diagram.RegisterComponent<ImageNodeModel, ImageNode>();
diagram.RegisterComponent<EmbedNodeModel, EmbedNode>();
}
}
Create a DashboardLayout class to manage all the saving related logic (currently only support Nodes and Links):
public class DashboardLayout
{
public required IEnumerable<NodeModelBase> Nodes { get; init; }
public required IEnumerable<LinkContext> Links { get; init; }
public string SerializeAsJson()
{
return JsonSerializer.Serialize(this, jsonSerializerOptions);
}
public void ApplyToDiagram(Diagram diagram, Action<Model>? StyledLinkModel_Changed = null, Func<NodeModelBase, NodeModelBase>? mapNode = null, Func<BaseLinkModel, BaseLinkModel>? mapLink = null)
{
diagram.Nodes.Add(mapNode == null ? Nodes : Nodes.Select(mapNode));
foreach (var link in Links)
{
var sourceNode = diagram.Nodes.Where(x => x is NodeModelBase baseNode && baseNode.RefId == link.SourceId).FirstOrDefault();
var targetNode = diagram.Nodes.Where(x => x is NodeModelBase baseNode && baseNode.RefId == link.TargetId).FirstOrDefault();
if (sourceNode != null && targetNode != null)
{
var sourcePort = sourceNode.GetPort(link.SourceAlignment);
var targetPort = targetNode.GetPort(link.TargetAlignment);
if (sourcePort != null && targetPort != null)
{
var linkModel = new StyledLinkModel(sourcePort, targetPort)
{
Dash = link.LineDash,
Color = link.LineColor,
Animated = link.LineAnimated,
};
if (StyledLinkModel_Changed != null)
{
linkModel.Changed += StyledLinkModel_Changed;
}
if (link.LineShape.HasValue)
{
switch (link.LineShape.Value)
{
case LineShape.Curve:
linkModel.Router = new NormalRouter();
linkModel.PathGenerator = new SmoothPathGenerator();
break;
case LineShape.Orthogonal:
linkModel.Router = new OrthogonalRouter();
linkModel.PathGenerator = new StraightPathGenerator();
break;
default:
break;
}
}
if (!string.IsNullOrEmpty(link.SourceMarkerPath))
{
linkModel.SourceMarker = new LinkMarker(link.SourceMarkerPath, link.SourceMarkerWidth);
}
if (!string.IsNullOrEmpty(link.TargetMarkerPath))
{
linkModel.TargetMarker = new LinkMarker(link.TargetMarkerPath, link.TargetMarkerWidth);
}
diagram.Links.Add(mapLink == null ? linkModel : mapLink(linkModel));
}
}
}
}
private static readonly JsonSerializerOptions jsonSerializerOptions = new()
{
IgnoreReadOnlyFields = true,
IgnoreReadOnlyProperties = true,
};
public static DashboardLayout FromModels(IEnumerable<NodeModel> nodeModels, IEnumerable<BaseLinkModel> linkModels)
{
var nodes = new List<NodeModelBase>();
var links = new List<LinkContext>();
foreach (var link in linkModels)
{
if (link.Source.Model is PortModel sourcePort && sourcePort.Parent is NodeModelBase sourceNode &&
link.Target.Model is PortModel targetPort && targetPort.Parent is NodeModelBase targetNode)
{
var linkContext = new LinkContext()
{
SourceId = sourceNode.RefId,
SourceAlignment = sourcePort.Alignment,
TargetId = targetNode.RefId,
TargetAlignment = targetPort.Alignment,
LineShape = link.Router switch
{
NormalRouter _ => LineShape.Curve,
OrthogonalRouter _ => LineShape.Orthogonal,
_ => null,
},
SourceMarkerPath = link.SourceMarker?.Path,
SourceMarkerWidth = link.SourceMarker?.Width ?? 10,
TargetMarkerPath = link.TargetMarker?.Path,
TargetMarkerWidth = link.TargetMarker?.Width ?? 10,
};
if (link is StyledLinkModel styledLinkModel)
{
linkContext.LineDash = styledLinkModel.Dash;
linkContext.LineColor = styledLinkModel.Color;
linkContext.LineAnimated = styledLinkModel.Animated;
}
links.Add(linkContext);
}
}
foreach (var node in nodeModels)
{
if (node is NodeModelBase nodeModelBase)
{
if (nodeModelBase.RefId == Guid.Empty)
{
nodeModelBase.RefId = Guid.NewGuid();
}
nodes.Add(nodeModelBase);
}
}
return new DashboardLayout
{
Nodes = nodes,
Links = links
};
}
public static DashboardLayout FromJson(string json) => JsonSerializer.Deserialize<DashboardLayout>(json, jsonSerializerOptions)!;
}
public class LinkContext
{
public required Guid SourceId { get; set; }
public required Guid TargetId { get; set; }
public PortAlignment SourceAlignment { get; init; }
public PortAlignment TargetAlignment { get; init; }
public LineShape? LineShape { get; set; }
public int LineDash { get; set; }
public bool LineAnimated { get; set; }
public string? LineColor { get; set; }
public string? SourceMarkerPath { get; set; }
public double SourceMarkerWidth { get; set; } = 10;
public string? TargetMarkerPath { get; set; }
public double TargetMarkerWidth { get; set; } = 10;
}
public enum LineShape
{
Curve,
Orthogonal
}
Extended nodes and unified node editor
Take the ImageNode as the exsample:
@using Blazor.Diagrams.Core.Models
<div class="group relative bg-transparent">
<img class="flex flex-col items-center justify-center border-1px @(Node.Selected ? "border-primary" : "border-transparent") object-contain select-none"
style="width: @(Node.Width)px; height: @(Node.Height)px;"
ondragstart="event.preventDefault()"
src="data:image/*;base64,@Node.ImageBase64" />
@foreach (var port in Node.Ports)
{
var portCss = port.Alignment switch
{
PortAlignment.Top => "-top-2 left-[calc(50%-8px)]",
PortAlignment.Right => "right-[-16px] top-[calc(50%-8px)]",
PortAlignment.Bottom => "-bottom-2 left-[calc(50%-8px)]",
PortAlignment.Left => "-left-4 top-[calc(50%-8px)]",
_ => "",
};
<Blazor.Diagrams.Components.Renderers.PortRenderer @key="port" Port="port" Class=@($"w-[16px] h-[16px] rounded-full opacity-0 group-hover:opacity-50 absolute bg-primary {portCss}") />
}
</div>
public partial class ImageNode
{
[Parameter] public ImageNodeModel Node { get; set; } = null!;
}
[NodeParameter("Image Node")]
public class ImageNodeModel : NodeModelBase
{
public ImageNodeModel()
{
AddPort(PortAlignment.Top);
AddPort(PortAlignment.Right);
AddPort(PortAlignment.Bottom);
AddPort(PortAlignment.Left);
}
[NodeParameter<ImageBase64Uploader, string>("Image(<5MB)")]
public string? ImageBase64 { get; set; }
[NodeParameter]
public int Width { get; set; } = 200;
[NodeParameter]
public int Height { get; set; } = 200;
}
Customized image uploader field: ImageBase64Uploader:
@inherits NodeParameterComponentBase<string>
@code {
[Inject] public required IModalService ModalService { get; set; }
private async Task OnFileUploaded(IBrowserFile file)
{
try
{
using var stream = new MemoryStream();
using var fileStream = file.OpenReadStream(1024 * 1024 * 5);
await fileStream.CopyToAsync(stream);
Value = Convert.ToBase64String(stream.ToArray());
ValueChanged?.Invoke(Value);
}
catch (Exception ex)
{
await ModalService.ShowSimpleDialog("Upload image failed", ex.Message, level: NotificationLevel.Error);
}
}
}
<InputFile OnChange="e => OnFileUploaded(e.File)"
class="file-input file-input-sm file-input-bordered" />
All the editable properties are annotated with NodeParameter attribute. With this, we can use reflection to help to build the node editor:
@typeparam T
<h3 class="font-bold text-lg mb-2">@title</h3>
<div class="flex flex-col gap-2">
@foreach (var (property, parameterAttr) in properties)
{
var label = parameterAttr.Name ?? property.Name;
var value = property.GetValue(NodeModel);
var setValue = (object? x) =>
{
property.SetValue(NodeModel, x);
NodeModel.Refresh();
};
<NodeFieldWraper Label="@label">
@if (parameterAttr.FieldRenderType != null)
{
var ps = new Dictionary<string, object?>()
{
{ nameof(NodeParameterComponentBase<int>.Value), value },
{ nameof(NodeParameterComponentBase<int>.ValueChanged), setValue },
};
<DynamicComponent Type="parameterAttr.FieldRenderType" Parameters="ps" />
}
else
{
@if (property.PropertyType == typeof(string))
{
@if (parameterAttr.Textarea)
{
<textarea class="join-item textarea textarea-bordered py-2 textarea-sm w-full" style="line-height: 1rem; white-space: nowrap; min-height: 100px;" placeholder="@label"
value=@value
@onchange="e => setValue(e.Value?.ToString())">
</textarea>
}
else
{
<input type="text" class="join-item input input-sm input-bordered w-full" placeholder="@label"
value=@value
@onchange="e => setValue(e.Value?.ToString())">
}
}
else if (property.PropertyType == typeof(bool) || property.PropertyType == typeof(bool?))
{
<input type="checkbox" class="join-item toggle toggle-primary" checked="@((bool?)value == true)" @onchange="_ => setValue(!(bool?)value!)">
}
else if (property.PropertyType.IsEnum)
{
<select
value=@(value?.ToString() ?? "")
@onchange="x => { if (Enum.TryParse(property.PropertyType, x.Value?.ToString(), out var v)) setValue(v); }"
class="select select-sm select-bordered join-item w-full"
>
@foreach (var item in Enum.GetValues(property.PropertyType))
{
<option value="@item">@item</option>
}
</select>
}
else if (property.PropertyType == typeof(int) || property.PropertyType == typeof(int?))
{
<input type="number" class="join-item input input-sm input-bordered w-full" placeholder="@label"
value="@value"
@onchange="e => { int.TryParse(e.Value?.ToString(), out var v); setValue(v); }" />
}
}
</NodeFieldWraper>
}
</div>
public partial class NodeEditor<T> where T : NodeModelBase
{
[Parameter] public required T NodeModel { get; set; }
private string title = "";
private IEnumerable<(PropertyInfo Property, NodeParameterAttribute ParameterAttr)> properties = [];
protected override void OnInitialized()
{
var modelType = NodeModel.GetType();
var parameterAttr = modelType.GetCustomAttributes(typeof(NodeParameterAttribute), false).FirstOrDefault();
title = ((NodeParameterAttribute?)parameterAttr)?.Name ?? modelType.Name;
properties = modelType.GetProperties()
.Select(p =>
{
var parameterAttr = p.GetCustomAttributes(typeof(NodeParameterAttribute), false).FirstOrDefault();
return (p, (NodeParameterAttribute?)parameterAttr);
})
.Where(x => x.Item2 != null)
.Select(x => (Property: x.p, ParameterAttr: x.Item2!))
.ToList();
}
}
Customized link style
Currently, it only supports to change the color, dash width and toggle animation when dash is enabled:
public class StyledLinkModel : LinkModel
{
public const string DefaultColor = "grey";
public StyledLinkModel(PortModel sourcePort, PortModel targetPort) : base(sourcePort, targetPort)
{
Color = DefaultColor;
}
public StyledLinkModel(Anchor source, Anchor target) : base(source, target)
{
Color = DefaultColor;
}
public int Dash { get; set; } = 0;
public bool Animated { get; set; }
public static string MakeDefaultStyleClass()
{
return $$"""
g.diagram-link > path:not(.selection-helper) {
stroke-dasharray: 0;
}
@keyframes diagram-link-dash-animation {
100% {
stroke-dashoffset: -10;
}
}
""";
}
public string MakeStyleClass()
{
var animatedCss = Animated ? "animation: diagram-link-dash-animation .5s linear infinite;" : "";
return $$"""
g.diagram-link[data-link-id="{{Id}}"] > path:not(.selection-helper) {
stroke-dasharray: {{Dash}};
{{animatedCss}}
}
""";
}
}
The end
Overall, the Blazor.Diagrams project is quite easy to extend and customize. And it is naturally integrated with blazor ecosystem.