Kristoffer Strube’s Blog

.NET, Blazor, and much more!

Blazor.SVGEditor: Released

7/26/2023
Blazor.SVGEditor: Released

One of my first big Open Source projects was my Blazor SVG Editor. This project lets users edit SVG definitions in a graphical interface, all from Blazor. The users can edit and navigate existing SVGs from the editor using common user interactions like panning, zooming, translating, and scaling. They can likewise change details of the SVG elements that don't have a natural mapping and create new elements using an extensive context menu. I've seen multiple projects that have used the project or parts of it to construct some cool interactive UIs. So I figured I wanted to make it possible for more people to use it by releasing it as a component library on NuGet. In this article, we will look at what features the Blazor SVG Editor NuGet package brings out of the box and how you can extend the functions of the base library to make your own interactive graphical tool.


Features

Before we look at how we can use the package, we will go through some of the central features of the editor, accompanied by some small videos that show those features.

You can find the project on GitHub: https://github.com/KristofferStrube/Blazor.SVGEditor

You can find the package on NuGet: KristofferStrube.Blazor.SVGEditor

And you can demo all the below features in the online demo site: https://kristofferstrube.github.io/Blazor.SVGEditor/

Setting input and getting updates

The editor parses the XML structure that SVGs are defined in to be able to edit every detail of an SVG. To make it possible for the library consumer to populate this initial value, the Input is exposed as a Parameter that needs to be set when the component is used. The component has another Parameter InputUpdated that you can set to listen for when changes happen to the underlying SVG code. This can be used to get a live view of the underlying code while you edit it.

Editing shapes

The editor enables us to update all shapes. Among these shapes are lines, polygons, rectangles, and paths. Paths are especially complex to parse as they contain many instructions that can be used in many combinations.

Creating new shapes

The editor supports creating new shapes through its context menu and supports unique flows for easy creation for each of these shapes.

Panning and zooming

Sometimes some fine details in the SVG would be easier to edit up close either because they are very small or because you need high precision. To support this, we enable you to zoom and move around the canvas using the scroll wheel and the mouse's middle button. These interactions are only supported for desktop as they rely on you having a mouse or at least some way to scroll. If you want to contribute to the project, then I have an open issue to add support for touch devices: Blazor.SVGEditor Issue #11

Multi-select and area selection

The editor also enables the user to select multiple elements when editing by holding the CTRL key down while selecting. To select multiple items in an area, the user can hold down the left mouse button and drag to mark the desired elements, similar to selecting multiple files in the Windows file explorer.

Color selection

Using the context menu, the users can change the fill color of all shapes by picking a color from a modal. They can likewise change the color of the strokes (the outline of shapes) and other details related to the stroke.

Grouping and Ungrouping

SVG elements can be grouped using the <g> tag. When elements are grouped, the editor moves them together and disables the ability to edit them individually. After selecting one or more elements, you can create a new group using the context menu. You can likewise ungroup elements that are grouped together using the context menu.

Copy, paste, remove, and re-order

The last core part of the editor is the ability to copy, paste, remove, and re-order elements. You can mark one or more elements and press copy to paste their content into your clipboard. Then you can click paste to insert a copy. If you had any item marked when you pasted, it would make sure to add the element just above that; otherwise, it will paste it in front of all items. You can likewise select any amount of items and press remove to remove them. The last function is under the menu called move, which gives options for moving elements back or forward in the render hierarchy.

Miscellaneous features

Some other features are also available in the editor but need more refinement. Among these are support for editing and creating linear gradients, editing and creating animations, and optimizing paths via the context menu and anchor movement interactions.

Customization

Now that we have seen what the editor can do out of the box let's see what we can do with it if we extend it. If you want to follow these steps yourself, you should start by following the Getting Started section in the repository README. If you don't want to follow these steps and just want to browse the nice videos, then that is also okay.

The example customization we will make is a network editor consisting of nodes and connectors. Commonly known as a graph. To start, we create the following page to set up a minimal editor that doesn't support any elements, has no options for adding new elements, and has a subset of all our possible context menu features.

<div style="height:80vh">
    <SVGEditor Input=@Input
               InputUpdated="(string s) => { Input = s; StateHasChanged(); }"¨
               SnapToInteger=true
               SupportedElements=SupportedElements
               AddNewSVGElementMenuItems=AddNewSVGElementMenuItems
               ActionMenuItems=ActionMenuItems />
</div>

@code {
    protected string Input = @"";

    protected List<SupportedElement> SupportedElements { get; set; } = new()
    {
    };

    protected List<SupportedAddNewSVGElementMenuItem> AddNewSVGElementMenuItems { get; set; } = new()
    {
    };

    protected List<ActionMenuItem> ActionMenuItems { get; set; } = new() {
        new(typeof(StrokeMenuItem), (_, data) => data is Shape shape && !shape.IsChildElement),
        new(typeof(MoveMenuItem), (_, data) => data is Shape shape && !shape.IsChildElement),
        new(typeof(GroupMenuItem), (_, data) => data is Shape shape && !shape.IsChildElement),
        new(typeof(UngroupMenuItem), (_, data) => data is G g && !g.IsChildElement),
        new(typeof(RemoveMenuItem), (svgEditor, data) => data is Shape && !svgEditor.DisableRemoveElement),
        new(typeof(CopyMenuItem), (svgEditor, data) => data is Shape && !svgEditor.DisableCopyElement),
        new(typeof(PasteMenuItem), (svgEditor, _) => !svgEditor.DisablePasteElement),
    };
}

Node.cs

We will first make the classes and components needed to control, render, and add new nodes. This will build on the existing code we have created for editing Circles. We first add a class called Node that will handle how we interact with nodes and how we create new ones. We will later add more logic to this class to enrich its interaction with connectors.

public class Node : Circle
{
    public Node(IElement element, SVGEditor svg) : base(element, svg)
    {
        string? id = element.GetAttribute("id");
        if (id is null || svg.Elements.Any(e => e.Id == id))
        {
            Id = Guid.NewGuid().ToString();
        }
    }
}

The class extends Circle as it shares most of its logic with it. The first thing we do in the constructor is to check if the SVG definition that the Node is constructed from has a unique Id, and if it doesn't, then assign a new Id to it so that we have a way to reference each node individually.

Next we override the Presenter property to customize how we present the Node with a NodeEditor instead of using the inherited CircleEditor.

public override Type Presenter => typeof(NodeEditor);

We also override the R property that defines the radius of the Circle so that it is always 50 but still enables the R to be set to something else.

public new double R { get => 50; set => base.R = value; }

Next, we override the Stroke property of the Circle so that it also updates the Fill. This aims to limit the user's options for editing the nodes to give a more homogeneous visual look.

public override string Stroke
{
    get => base.Stroke;
    set
    {
        base.Stroke = value;
        int[] parts = value[1..].Chunk(2).Select(part => int.Parse(part, System.Globalization.NumberStyles.HexNumber)).ToArray();
        Fill = "#" + string.Join("", parts.Select(part => Math.Min(255, part + 50).ToString("X2")));
    }
}

Our goal is to set the Fill to a color that is a bit lighter than the newly set Stroke color. We know the Stroke will always be set using a 6-character long hex value prepended by a pound sign '#'. To increase the color brightness, we chunk the 6 characters into chunks of 2 characters, each representing one of the color parts, i.e., red, green, and blue. We parse these chunks as hex values and use these parsed values to construct the Fill color. To do this, we just join the parts back together as 2-digit hex values but increase the intensity of each color by 50, capped at 255 as that is the largest possible 2-digit hex value.

The last part we add to the Node class right now is a method for adding a new Node.

public static new void AddNew(SVGEditor SVG)
{
    IElement element = SVG.Document.CreateElement("CIRCLE");
    element.SetAttribute("data-elementtype", "node");

    Node node = new(element, SVG)
    {
        Changed = SVG.UpdateInput,
        Stroke = "#28B6F6",
        R = 50
    };

    (node.Cx, node.Cy) = SVG.LocalDetransform(SVG.LastRightClick);

    SVG.ClearSelectedShapes();
    SVG.SelectShape(node);
    SVG.AddElement(node);
}

We first create the underlying XML element that the Node will use as its data underlying data structure. We set the attribute data-elementtype on this to "node" so that we can distinguish it from other <circle> tags.

Then we construct a new Node from this that will trigger the SVGEditors´s UpdateInput method whenever it changes and set its Stroke color and radius to our defaults.

We also set the center of the new Node to be the last place that the user has right-clicked so that it will appear near where they last used the context menu. The SVGEditor automatically keeps track of this position in the screen coordinate system. We might have panned around the SVG or zoomed in, so to get the original position in the coordinate system of the SVG, we parse the screen coordinate through the SVGEditor method LocalDetransform which can transform any point from the screen coordinate system to the SVG coordinate system.

Lastly, we deselect all selected shapes, select the new Node, and add it to the SVGEditor using the AddElement method.

NodeEditor.razor

We defined that the Node should be presented by a NodeEditor component. We want something very close to how we present a Circle, but without the capability to edit the radius. That gives us the following component:

@using BlazorContextMenu
@using KristofferStrube.Blazor.SVGEditor.ShapeEditors
@using KristofferStrube.Blazor.SVGEditor.Extensions
@inherits ShapeEditor<Node>

<ContextMenuTrigger MenuId="SVGMenu" WrapperTag="g" Data=@SVGElement MouseButtonTrigger="SVGElement.ShouldTriggerContextMenu ? MouseButtonTrigger.Right : (MouseButtonTrigger)4">
    <g transform="translate(@SVGElement.SVG.Translate.x.AsString() @SVGElement.SVG.Translate.y.AsString()) scale(@SVGElement.SVG.Scale.AsString())">
        <circle @ref=ElementReference
        @onfocusin="FocusElement"
        @onfocusout="UnfocusElement"
        @onpointerdown="SelectAsync"
        @onkeyup="KeyUp"
                tabindex="@(SVGElement.IsChildElement ? -1 : 0)"
                cx=@SVGElement.Cx.AsString()
                cy=@SVGElement.Cy.AsString()
                r=@SVGElement.R.AsString()
                stroke="@SVGElement.Stroke"
                stroke-width="@SVGElement.StrokeWidth"
                stroke-linecap="@SVGElement.StrokeLinecap.AsString()"
                stroke-linejoin="@SVGElement.StrokeLinejoin.AsString()"
                stroke-dasharray="@SVGElement.StrokeDasharray"
                stroke-dashoffset="@SVGElement.StrokeDashoffset.AsString()"
                fill="@SVGElement.Fill"
                style="filter:brightness(@(SVGElement.Selected ? "0.8" : "1"))">
        </circle>
    </g>
</ContextMenuTrigger>

Another thing we have changed is that the circle tag applies a filter to darken its fill if selected. It extends the abstract ShapeEditor component that implements all its necessary logic.

AddNewNodeMenuItem.razor

We also need to define a simple menu item that we can use to add new nodes.

@using BlazorContextMenu

<Item OnClick="_ => Node.AddNew(SVGEditor)">
    <div class="icon">⚫</div> New Node
</Item>

@code {
    [CascadingParameter]
    public required SVGEditor SVGEditor { get; set; }

    [Parameter]
    public required object Data { get; set; }
}

The component will get a reference to the SVGEditor that it is used in through a CascadingParameter. It also gets initialized with reference to the item that was last right-clicked through the Data Parameter, if there was any. We don't use the Data property in our sample, but one way we could have used this would be to check if the last right-clicked element was a Node, and if it were, then use the same Stroke color. Instead, we simply invoke the AddNew method when the menu item is clicked.

Let's add these new components and classes to our sample razor page and see how it looks. We add the Node class to our list of supported elements and specify that it should handle representing any tag that is a circle with the data-elementtype set to "node".

protected List<SupportedElement> SupportedElements = new()
{
    new(typeof(Node), element => element.TagName is "CIRCLE" && element.GetAttribute("data-elementtype") == "node"),
};

And we add the menu item for adding a new Node to our list of menu items for the "Add New" sub-menu and specify that the menu item should always be presented.

protected List<SupportedAddNewSVGElementMenuItem> AddNewSVGElementMenuItems = new()
{
    new(typeof(AddNewNodeMenuItem), (_,_) => true),
};

Now when we run the project, we will be able to view, edit and create nodes.

Connector.cs

Now we are ready to add our connectors. We start of with creating a new class that extends the existing Line class.

public class Connector : Line
{
    public Connector(IElement element, SVGEditor svg) : base(element, svg)
    {
        UpdateLine();
    }
}

When it is initialized, we call a method that will update the position of the line. We also define a custom presenter for the Connector that slightly changes how it is presented and how a user can interact with it.

public override Type Presenter => typeof(ConnectorEditor);

We will not set the connector's position directly by moving its endpoints. Instead, this will be controlled by which nodes it is connected to. We define two properties for this that will simplify how we can access and update them later. We first define which Node the connecter starts in.

public Node? From
{
    get
    {
        var from = (Node?)SVG.Elements.FirstOrDefault(e => e is Node && e.Id == Element.GetAttribute("data-from"));
        _ = from?.RelatedConnectors.Add(this);
        return from;
    }
    set
    {
        if (From is { } from)
        {
            _ = from.RelatedConnectors.Remove(this);
        }
        if (value is null)
        {
            _ = Element.RemoveAttribute("data-from");
        }
        else
        {
            Element.SetAttribute("data-from", value.Id);
            _ = value.RelatedConnectors.Add(this);
        }
        Changed?.Invoke(this);
    }
}

When we get the From Node, it finds the Node among all registered elements that also match the Id with the content of the data-from attribute of the connecter. After this, it adds this connector to a HashSet called RelatedConnectors on the Node. If we set the From Node, we remove the previous From Node from the HashSet and either remove the attribute if it was set to null or set it to the Id of the new Node and again adds the connector to the HashSet. We also invoke Changed as we have changed some a part of the SVG definition when we set this property. We will show what we have added to the Node class to support the RelatedConnectors HashSet in just a bit, but let's first show the code for the To property as that is almost identical to the From node.

public Node? To
{
    get
    {
        var to = (Node?)SVG.Elements.FirstOrDefault(e => e is Node && e.Id == Element.GetAttribute("data-to"));
        _ = to?.RelatedConnectors.Add(this);
        return to;
    }
    set
    {
        if (To is { } to)
        {
            _ = to.RelatedConnectors.Remove(this);
        }
        if (value is null)
        {
            _ = Element.RemoveAttribute("data-to");
        }
        else
        {
            Element.SetAttribute("data-to", value.Id);
            _ = value.RelatedConnectors.Add(this);
        }
        Changed?.Invoke(this);
    }
}

Let's see what we have added to the Node class to support the relationship between the connectors and nodes. We first add the HashSet that we mentioned to the Node class.

public HashSet<Connector> RelatedConnectors { get; } = new();

We also override the HandlePointerMove method that every Shape implements to ensure that the related connectors are updated when the Node is moved.

public override void HandlePointerMove(PointerEventArgs eventArgs)
{
    base.HandlePointerMove(eventArgs);
    if (SVG.EditMode is EditMode.Move)
    {
        foreach (Connector connector in RelatedConnectors)
        {
            connector.UpdateLine();
        }
    }
}

All related connectors should also be removed if a Node is removed. To support this, we can override the BeforeBeingRemoved method, which is called before any ISVGElement is removed from the editor.

public override void BeforeBeingRemoved()
{
    foreach (Connector connector in RelatedConnectors)
    {
        SVG.RemoveElement(connector);
    }
}

Next we will go back to the Connector class. We start of with implementing the method for adding a new Connector.

public static void AddNew(SVGEditor SVG, Node from)
{
    IElement element = SVG.Document.CreateElement("LINE");
    element.SetAttribute("data-elementtype", "connector");

    Connector connector = new(element, SVG)
    {
        Changed = SVG.UpdateInput,
        Stroke = "black",
        StrokeWidth = "5",
        From = from
    };
    SVG.EditMode = EditMode.Add;

    SVG.ClearSelectedShapes();
    SVG.SelectShape(connector);
    SVG.AddElement(connector);
}

We create a new XML element in the form of a line. We set the data-elementtype to "connector" on this, which we will use later to specify that it should only be the Connector class that handles this element. Next, we create a new Connector instance and set some defaults. This AddNew method differs from the one we specified by the Node. This method also takes a Node as a parameter. We use this to select which Node the Connector should go from so that we can finish the addition of the Connector with very few interactions. In the end, we do the same thing as for the Node AddNew method and clear all selected shapes, set the new Connector as the single selected shape, and add the new Connector to the SVGEditor.

To give the user some feedback while selecting a second Node to connect to, we want to update where the Connector points to follow the mouse until the user has decided on a Node. To control this we again override the HandlePointerMove method.

public override void HandlePointerMove(PointerEventArgs eventArgs)
{
    if (SVG.EditMode is EditMode.Add)
    {
        (X2, Y2) = SVG.LocalDetransform((eventArgs.OffsetX, eventArgs.OffsetY));
        SetStart((X2, Y2));
    }
}

We don't want the Connector to handle a pointer move in any other case than when we are adding a new one. In this case, we set the end position equal to the pointer position, which is parsed to the method. After this, we update the position of the start position so that it is oriented towards where the cursor is currently. Let's see how we do that now. This includes a tiny bit of trigonometry, so be warned.

public void SetStart((double x, double y) towards)
{
    double differenceX = towards.x - From!.Cx;
    double differenceY = towards.y - From!.Cy;
    double distance = Math.Sqrt((differenceX * differenceX) + (differenceY * differenceY));

    if (distance > 0)
    {
        X1 = From!.Cx + (differenceX / distance * 50);
        Y1 = From!.Cy + (differenceY / distance * 50);
    }
}

We first calculate the difference between the point we are drawing toward and the center of the Node we draw from for the x and y axes, respectively. Then we calculate the Euclidean distance between the two same points using the Pythagorean theorem. If the cursor is any distance from the From Node, we update the start of the line. We wish to start the line at the edge of the From Node circle and find the point closest to the cursor. We already have a vector that points to the cursor (differenceX, differenceY), so we start with normalizing it by dividing it by its length, which is our distance, and then we multiply it with 50 to make that vector point out to the edge of the Node as that has a radius of 50. Then we add this re-scaled vector to the center of the From Node.

Before continuing, let's demo this to see that it updates the line to point to the edge of the From Node. To show this, I've added a @ref to the SVGEditor in our sample page, programmatically initialized a new Node, and started the addition of a Connector like just as a temporary change.

private SVGEditor SVGEditor { get; set; } = default!;

protected override void OnAfterRender(bool firstRender)
{
    if (!firstRender) return;

    Node.AddNew(SVGEditor);
    var node = (Node)SVGEditor.Elements.First();
    Connector.AddNew(SVGEditor, node);
}

Next, we need to be able to select a Node while adding a Connector to end the interaction. Shapes cannot be selected when other elements are being created by default, so we first need to override this inherited logic of the NodeEditor. We add the following to the code section of our NodeEditor component:

public new async Task SelectAsync(MouseEventArgs eventArgs)
{
    if (SVGElement.SVG.EditMode is EditMode.Add && SVGElement.SVG.SelectedShapes.Any(s => s is Connector))
    {
        SVGElement.SVG.SelectedShapes.Add(SVGElement);
    }
    else
    {
        await base.SelectAsync(eventArgs);
    }
}

We wish to change the behavior of the SelectAsync method which is invoked when the pointer is pressed down on a Node, so we new the SelectAsync method in the component. In this, we have two cases. If the SVGEditor is in Add mode and any Connector is currently selected, then we add the Node to the list of selected shapes. Else we call the base implementation of the SelectAsync method.

Now we can select a Node while a Connector is being added. Then we need to handle when the pointer is being raised while the Connecter is added. We override the HandlePointerUp on the Connector class for this.

public override void HandlePointerUp(PointerEventArgs eventArgs)
{
    if (SVG.EditMode is EditMode.Add
        && SVG.SelectedShapes.FirstOrDefault(s => s is Node node && node != From) is Node { } to)
    {
        if (to.RelatedConnectors.Any(c => c.To == From || c.From == From))
        {
            Complete();
        }
        else
        {
            To = to;
            SVG.EditMode = EditMode.None;
            UpdateLine();
        }
    }
}

We only care about the case where we are adding a Connector so we first check for this and then check if we can find any selected shape that is a Node. If we have matched this, we then do one of two things. If the two nodes that are going to be connected already are connected, then we call Complete, which we will implement to remove this unfinished Connector in a bit. We do this so that two nodes can't be connected more than once. If there wasn't already a Connection present between these two nodes then we see the To Node, reset the SVGEditor mode, and update its placement.

Before going to the UpdateLine method, let's see the Complete method we used.

public override void Complete()
{
    if (To is null)
    {
        SVG.RemoveElement(this);
        Changed?.Invoke(this);
    }
}

This method can also be called from the context menu to enable the user to cancel creating a new Connector after starting. So in these two cases, we remove the temporary Connector if To is not set.

Now let's see the UpdateLine method that we have referenced a couple of times now.

public void UpdateLine()
{
    if (From is null || To is null)
    {
        (X1, Y1) = (X2, Y2);
        return;
    }

    double differenceX = To.Cx - From.Cx;
    double differenceY = To.Cy - From.Cy;
    double distance = Math.Sqrt((differenceX * differenceX) + (differenceY * differenceY));

    if (distance < 100)
    {
        (X1, Y1) = (X2, Y2);
    }
    else
    {
        SetStart((To.Cx, To.Cy));
        X2 = To.Cx - (differenceX / distance * 50);
        Y2 = To.Cy - (differenceY / distance * 50);
    }
}

If the From or To Node is not set, we set the start of the line equal to the end position so that it will not appear.

Then we do something very close to what we have seen previously by finding the vector from the To Node to the From Node. Then we calculate the length of this vector. And if the length of the vector is less than 100, then we know that they are so close that the Connector should not be rendered, so we once again set the start equal to the end. Else we set the start position to point towards the To Node and then calculate the point on the edge of the circle of the To Node closest to the From Node exactly like we did in the SetStart method for the From Node.

ConnectorEditor.razor

We also need to add the custom ConnectorEditor we previously referenced in the Connector. We have just copied the LineEditor component and made some adjustments for this.

@using BlazorContextMenu
@using KristofferStrube.Blazor.SVGEditor.Extensions;
@using KristofferStrube.Blazor.SVGEditor.ShapeEditors;
@inherits ShapeEditor<Connector>

<ContextMenuTrigger MenuId="SVGMenu" WrapperTag="g" Data=@SVGElement MouseButtonTrigger="SVGElement.ShouldTriggerContextMenu ? MouseButtonTrigger.Right : (MouseButtonTrigger)4">
    <g style="@(SVGElement.SVG.EditMode is EditMode.Add ? "pointer-events:none;" : "")" transform="translate(@SVGElement.SVG.Translate.x.AsString() @SVGElement.SVG.Translate.y.AsString()) scale(@SVGElement.SVG.Scale.AsString())">
        <line @ref=ElementReference
        @onfocusin="FocusElement"
        @onfocusout="UnfocusElement"
        @onpointerdown="SelectAsync"
        @onkeyup="KeyUp"
              tabindex="@(SVGElement.IsChildElement ? -1 : 0)"
              x1=@SVGElement.X1.AsString()
              y1=@SVGElement.Y1.AsString()
              x2=@SVGElement.X2.AsString()
              y2=@SVGElement.Y2.AsString()
              stroke="@SVGElement.Stroke"
              stroke-width="@SVGElement.StrokeWidth"
              stroke-linecap="@SVGElement.StrokeLinecap.AsString()"
              stroke-linejoin="@SVGElement.StrokeLinejoin.AsString()"
              stroke-dasharray="@SVGElement.StrokeDasharray"
              stroke-dashoffset="@SVGElement.StrokeDashoffset.AsString()"
              fill="@SVGElement.Fill">
        </line>
    </g>
</ContextMenuTrigger>

We have changed two things for the markup part of the ConnectorEditor. We have added a style tag to the <g> that encloses the <line> itself. The style tag makes it so that it will not handle any pointer events if the editor is currently in the Add mode. This is to ensure that we can click through the line when we are adding a new Connector so that it doesn't block any Node we would like to click. Secondly, we have removed the two anchors that normally appear when a Line is selected to make the UI less noisy. We have also overridden the OnAfterRenderAsync method in the code section of the component.

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        SVGElement.UpdateLine();
    }
    await base.OnAfterRenderAsync(firstRender);
}

This ensures that all connectors get their position updates when first rendered.

AddNewConnectorMenuItem.razor

The last component we need is the context menu item for when we want to add a new Connector.

@using BlazorContextMenu

@if (Data is Node node)
{
    <Item OnClick="_ => Connector.AddNew(SVGEditor, node)">
        <div class="icon">〰</div> New Connector
    </Item>
}

@code {
    [CascadingParameter]
    public required SVGEditor SVGEditor { get; set; }

    [Parameter]
    public required object Data { get; set; }
}

This menu item is a bit more complex than the one we made for adding a Node as we only want it to render if we opened the context menu from a Node. So we check the Data property to get the Node that was right-clicked if there was any, and parse that to the AddNew method if the menu item is clicked.

Lastly we need to update the configuration in our sample page to include these new handlers and components. We first add the Connector class to the list of SupportedElements and specify that it can handle any <line> tag with a data-elementtype attribute equal to "connector".

protected List<SupportedElement> SupportedElements { get; set; } = new()
{
    new(typeof(Node), element => element.TagName is "CIRCLE" && element.GetAttribute("data-elementtype") == "node"),
    new(typeof(Connector), element => element.TagName is "LINE" && element.GetAttribute("data-elementtype") == "connector"),
};

And then, we add the menu item for adding new connectors and define that it should only count as a menu item if the context menu started from a Node as we defined in the component itself.

protected List<SupportedAddNewSVGElementMenuItem> AddNewSVGElementMenuItems { get; set; } = new()
{
    new(typeof(AddNewNodeMenuItem), (_,_) => true),
    new(typeof(AddNewConnectorMenuItem), (_,data) => data is Node)
};

Now let's get to the final demo with all the parts working together.

You might have noticed that the area select tool also had a new orange color in this sample video. We changed this by adding the following scoped CSS styling to our sample page.

::deep .anchor-primary {
    stroke: orange;
}

::deep .box {
    fill: orange;
    fill-opacity: 0.5;
    stroke: orange;
}

You can try the demo shown in the video yourself here: kristofferstrube.github.io/Blazor.SVGEditor/CustomElements And you can see the source code for the whole sample project, including some other demo pages, here: Demo project source code

Future work

I will continue to develop on the project whenever I find some feature or nice interaction that I would like to add. I currently have three open issues if you would to contribute yourself: #5, #11, and #12. One project that I plan to use the package in already is a demo for my Web Audio Blazor wrapper. The demo will be a network of sound sources, processors, and outputs that can be wired together to make some simple audio editing from the browser using Blazor.

Conclusion

At the start of the article, we went through all the different features of the Blazor.SVGEditor package. Features include parsing SVG definitions, adding, selecting, editing, moving, removing shapes, and navigating the canvas using the mouse. Then we went through a sample project where we added some custom handlers for SVG elements to the editor and made a network editor with these. While developing this customization, we also saw how we could still use many of the features in the package by inheriting from existing shapes. If you have any feedback or questions relating to the article, feel free to contact me on any of my social media profiles.