DEV Community

Cover image for Creating DEV's offline page using Blazor
Aaron Powell for Microsoft Azure

Posted on

Creating DEV's offline page using Blazor

I came across a fun post from Ali Spittel on Creating DEV's offline page:

Given that I've done some experiments in the past with WebAssembly I decided to have a crack at my own implementation in WebAssembly, in particular with Blazor.

Getting Started

Caveat: Blazor is a platform for building client side web applications using the .NET stack and specifically the C# language. It's highly experimental so there's a chance things will change from what it exists at the time of writing (I'm using build 3.0.0-preview6.19307.2).

First up you'll need to follow the setup guide for Blazor and once that's done create a new project in your favorite editor (I used VS Code).

I've then deleted all the boilerplate code from the Pages and Shared folder (except any _Imports.razor files), Bootstrap from the css folder and sample-data. Now we have a completely empty Blazor project.

Creating Our Layout

First thing we'll need to do is create the Layout file. Blazor, like ASP.NET MVC, uses a Layout file as the base template for all pages (well, all pages that use that Layout, you can have multiple layouts). So, create a new file in Shared called MainLayout.razor and we'll define it. Given that we want it to be full screen it'll be pretty simple:

@inherits LayoutComponentBase

@Body
Enter fullscreen mode Exit fullscreen mode

This file inherits the Blazor-provided base class for layouts, LayoutComponentBase which gives us access to the @Body property which allows us to place the page contents within any HTML we want. We don't need anything around it, so we just put @Body in the page.

Creating Our Offline Page

Time to make the offline page, we'll start by creating a new file in the Pages folder, let's call it Offline.html:

@page "/"

<h3>Offline</h3>
Enter fullscreen mode Exit fullscreen mode

This is our starting point, first we have the @page directive which tells Blazor that this is a page we can navigate to and the URL it'll respond to is "/". We've got some placeholder HTML in there that we'll replace next.

Starting the Canvas

The offline page is essentially a large canvas that we can draw on, and we'll need to create that, let's update Offline.razor with a canvas element:

@page "/"

<canvas></canvas>
Enter fullscreen mode Exit fullscreen mode

Setting the Canvas Size

We need to set the size of the canvas to be full screen and right now it's 0x0, not ideal. Ideally, we want to get the innerWidth and innerHeight of the browser, and to do that we'll need to use the JavaScript interop from Blazor.

We'll quickly make a new JavaScript file to interop with (call it helper.js and put it in wwwroot, also update index.html in wwwroot to reference it):

window.getWindowSize = () => {
    return { height: window.innerHeight, width: window.innerWidth };
};
Enter fullscreen mode Exit fullscreen mode

Next we'll create a C# struct to represent that data (I added a file called WindowSize.cs into the project root):

namespace Blazor.DevToOffline
{
    public struct WindowSize
    {
        public long Height { get; set; }
        public long Width { get; set; }
    }
}

Enter fullscreen mode Exit fullscreen mode

Lastly, we need to use that in our Blazor component:

@page "/"
@inject IJSRuntime JsRuntime

<canvas height="@windowSize.Height" width="@windowSize.Width"></canvas>

@code {
    WindowSize windowSize;

    protected override async Task OnInitAsync()
    {
        windowSize = await JsRuntime.InvokeAsync<WindowSize>("getWindowSize");
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a bit of code added so let's break it down.

@inject IJSRuntime JsRuntime
Enter fullscreen mode Exit fullscreen mode

Here we use Dependency Injection to inject the IJSRuntime as a property called JsRuntime on our component.

<canvas height="@windowSize.Height" width="@windowSize.Width"></canvas>
Enter fullscreen mode Exit fullscreen mode

Next, we'll set the height and width properties of the <canvas> element to the value of fields off an instance of our struct, an instance named windowSize. Note the @ prefix, this tells the compiler that this is referring to a C# variable, not a static string.

@code {
    WindowSize windowSize;

    protected override async Task OnInitAsync()
    {
        windowSize = await JsRuntime.InvokeAsync<WindowSize>("getWindowSize");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we've added a code block into our component. It contains the variable windowSize (which is uninitialized, but it's a struct so it has a default value) and then we override a Lifecycle method, OnInitAsync, in which we call out to JavaScript to get the window size and assign it to our local variable.

Congratulations, you now have a full screen canvas! 🎉

Wiring Up Events

We may have our canvas appearing but it doesn't do anything yet, so let's get cracking on that by adding some event handlers:

@page "/"
@inject IJSRuntime JsRuntime

<canvas height="@windowSize.Height"
        width="@windowSize.Width"
        @onmousedown="@StartPaint"
        @onmousemove="@Paint"
        @onmouseup="@StopPaint"
        @onmouseout="@StopPaint" />

@code {
    WindowSize windowSize;

    protected override async Task OnInitAsync()
    {
        windowSize = await JsRuntime.InvokeAsync<WindowSize>("getWindowSize");
    }

    private void StartPaint(UIMouseEventArgs e)
    {
    }

    private async Task Paint(UIMouseEventArgs e)
    {
    }

    private void StopPaint(UIMouseEventArgs e)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

When you're binding events in Blazor you need to prefix the event name with @, like @onmousedown, and then provide it the name of the function to invoke when the event happens, e.g. @StartPaint. The signature of these functions are to either return a void or Task, depending on whether it's asynchronous or not. The argument to the function will need to be the appropriate type of event arguments, mapping to the DOM equivalent (UIMouseEventArgs, UIKeyboardEventArgs, etc.).

Note: If you're comparing this to the JavaScript reference implementation, you'll notice I'm not using the touch events. This is because, in my experiments today, there is a bug with binding touch events in Blazor. Remember, this is preview!

Getting the Canvas Context

Note: I'm going to talk about how to setup interactions with <canvas> from Blazor, but in a real application you'd more likely want to use BlazorExtensions/Canvas than roll-you-own.

Since we'll need to work with the 2D context of the canvas we're going to need access to that. But here's the thing, that's a JavaScript API and we're in C#/WebAssembly, this will be a bit interesting.

Ultimately, we're going to have to this in JavaScript and rely on the JavaScript interop feature of Blazor, so there's no escaping writing some JavaScript still!

Let's write a little JavaScript module to give us an API to work with:

((window) => {
    let canvasContextCache = {};

    let getContext = (canvas) => {
        if (!canvasContextCache[canvas]) {
            canvasContextCache[canvas] = canvas.getContext('2d');
        }
        return canvasContextCache[canvas];
    };

    window.__blazorCanvasInterop = {
        drawLine: (canvas, sX, sY, eX, eY) => {
            let context = getContext(canvas);

            context.lineJoin = 'round';
            context.lineWidth = 5;
            context.beginPath();
            context.moveTo(eX, eY);
            context.lineTo(sX, sY);
            context.closePath();
            context.stroke();
        },

        setContextPropertyValue: (canvas, propertyName, propertyValue) => {
            let context = getContext(canvas);

            context[propertyName] = propertyValue;
        }
    };
})(window);
Enter fullscreen mode Exit fullscreen mode

I've done this with a closure scope created in an anonymous-self-executing-function so that the canvasContextCache, which I use to avoid constantly getting the context, isn't exposed.

The module provides us two functions, the first is to draw a line on the canvas between two points (we'll need that for the doodling!) and the second updates a property of the context (we'll need that to change colours!).

You might also notice that I don't ever call document.getElementById, I just somehow "magically" get the canvas. This can be achieves by capturing a component reference in C# and passing that reference around.

But this is still all JavaScript, what do we do in C#? Well, we create a C# wrapper class!

public class Canvas2DContext
{
    private readonly IJSRuntime jsRuntime;
    private readonly ElementRef canvasRef;

    public Canvas2DContext(IJSRuntime jsRuntime, ElementRef canvasRef)
    {
        this.jsRuntime = jsRuntime;
        this.canvasRef = canvasRef;
    }

    public async Task DrawLine(long startX, long startY, long endX, long endY)
    {
        await jsRuntime.InvokeAsync<object>("__blazorCanvasInterop.drawLine", canvasRef, startX, startY, endX, endY);
    }

    public async Task SetStrokeStyleAsync(string strokeStyle)
    {
        await jsRuntime.InvokeAsync<object>("__blazorCanvasInterop.setContextPropertyValue", canvasRef, "strokeStyle", strokeStyle);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a generic class that takes the captured reference and the JavaScript interop API and just gives us a nicer programmatic interface.

Wiring Up Our Context

We can now wire up our context and prepare to draw lines on the canvas:

@page "/"
@inject IJSRuntime JsRuntime

<canvas height="@windowSize.Height"
        width="@windowSize.Width"
        @onmousedown="@StartPaint"
        @onmousemove="@Paint"
        @onmouseup="@StopPaint"
        @onmouseout="@StopPaint"
        @ref="@canvas" />

@code {
    ElementRef canvas;

    WindowSize windowSize;

    Canvas2DContext ctx;
    protected override async Task OnInitAsync()
    {
        windowSize = await JsRuntime.InvokeAsync<WindowSize>("getWindowSize");
        ctx = new Canvas2DContext(JsRuntime, canvas);
    }

    private void StartPaint(UIMouseEventArgs e)
    {
    }

    private async Task Paint(UIMouseEventArgs e)
    {
    }

    private void StopPaint(UIMouseEventArgs e)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

By adding @ref="@canvas" to our <canvas> element we create the reference we need and then in the OnInitAsync function we create the Canvas2DContext that we'll use.

Drawing On The Canvas

We're finally ready to do some drawing on our canvas, which means we need to implement those event handlers:

    bool isPainting = false;
    long x;
    long y;
    private void StartPaint(UIMouseEventArgs e)
    {
        x = e.ClientX;
        y = e.ClientY;
        isPainting = true;
    }

    private async Task Paint(UIMouseEventArgs e)
    {
        if (isPainting)
        {
            var eX = e.ClientX;
            var eY = e.ClientY;

            await ctx.DrawLine(x, y, eX, eY);
            x = eX;
            y = eY;
        }
    }

    private void StopPaint(UIMouseEventArgs e)
    {
        isPainting = false;
    }
Enter fullscreen mode Exit fullscreen mode

Admittedly, these aren't that different to the JavaScript implementation, all they have to do is grab the coordinates from the mouse event and then pass them through to the canvas context wrapper, which in turn calls the appropriate JavaScript function.

Conclusion

🎉 We're done! You can see it running here and the code is on GitHub.

GitHub logo aaronpowell / blazor-devto-offline

A demo of how to create DEV.to's offline page using Blazor

This is a pretty quick look at Blazor, but more importantly, how we can use Blazor in a scenario that might require us to do a bit more interop with JavaScript that many scenarios require.

I hope you've enjoyed it and are ready to tackle your own Blazor experiments as well!

Bonus, The Colour Picker

There's one thing that we didn't do in the above example, implement the colour picker!

I want to do this as a generic component so we could do this:

<ColourPicker OnClick="@SetStrokeColour"
              Colours="@colours" />
Enter fullscreen mode Exit fullscreen mode

In a new file, called ColourPicker.razor (the file name is important as this is the name of the component) we'll create our component:

<div class="colours">
    @foreach (var colour in Colours)
    {
        <button class="colour"
                @onclick="@OnClick(colour)"
                @key="@colour">
        </button>
    }
</div>

@code {
    [Parameter]
    public Func<string, Action<UIMouseEventArgs>> OnClick { get; set; }

    [Parameter]
    public IEnumerable<string> Colours { get; set; }
}

Enter fullscreen mode Exit fullscreen mode

Our component is going to have 2 parameters that can be set from the parent, the collection of colours and the function to call when you click on the button. For the event handler I've made is so that you pass in a function that returns an action, so it's a single function that is "bound" to the name of the colour when the <button> element is created.

This means we have a usage like this:

@page "/"
@inject IJSRuntime JsRuntime

<ColourPicker OnClick="@SetStrokeColour"
              Colours="@colours" />

// snip

@code {
    IEnumerable<string> colours = new[] { "#F4908E", "#F2F097", "#88B0DC", "#F7B5D1", "#53C4AF", "#FDE38C" };

    // snip

    private Action<UIMouseEventArgs> SetStrokeColour(string colour)
    {
        return async _ =>
        {
            await ctx.SetStrokeStyleAsync(colour);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if you click the colour picker across the top you get a different colour pen.

Happy doodling!

Top comments (1)

Collapse
 
aspittel profile image
Ali Spittel

So cool!