Thursday, 18 May 2023

Animations in Blazor



I tested out animations in Blazor today, using the AOS - Animate on Scroll - library. I will use Blazor WASM for this. The sample code in this article can be cloned from my GitHub repo here: https://github.com/toreaurstadboss/BlazorAnimateSample This library is very easy to set up for Blazor. First off, go to the AOS website for installation instructions. AOS Github Pages On this site, copy the links to the CSS and Js file from the CDN. But note that I instead used VS 2022 and choose:
Add=>Client-side Library


The benefit of using this way of adding Aos is that the CSS and Js is installed into lib folders and you can drag the two files into the <head> section and <script> section inside body tag at the bottom of the index.html file. You set up AOS using the init method. You can either define a startEvent or not, it should default to event DOMContentLoaded i.e. when the element is displayed, the animation is started. The use of AOS and init method is explained in the GitHub repo for AOS: https://github.com/michalsnik/aos I set up AOS like this in index.html:
 

    <script src="_framework/blazor.webassembly.js"></script>
    <script src="js/animate.js"></script>
    <script src="lib/aos/aos.js"></script>
    <script>
        AOS.init({
            easing: 'ease-in-out',
            //startEvent: 'custom'
        });
    </script>
    
 
You can set the startEvent to 'custom' for example if you want to disable automatically loading the start of animations as soon as the element is scrolled into view or displayed in some other control manner. (actually you could set it to 'myEvent' here or some other gibberish value, to turn off automatically loading animations. Over to the file animate.js which will be used by the Animation component in Blazor (will be described later in this article).
 
 



function RegisterAnimationStartupTrigger(wrapperAnimationElementId, triggerElementId, triggerEventId) {
    //debugger
    if (event != null && event.target != null && event.target != undefined && event.target.closest) {
        var closestParentDiv = event.target.closest('div');
        if (closestParentDiv != null && closestParentDiv.id == wrapperAnimationElementId) {

            //sub elements of the wrapper div should not trigger animation, to avoid AOS running animation again
            return;
        }
    }
    
    var elem = document.getElementById(wrapperAnimationElementId);
    if (elem == null || elem == undefined) {
        return;
    }
    var triggerElement = document.getElementById(triggerElementId);
    if (triggerElement == null || triggerElement == undefined) {
        return;
    }

    elem.classList.remove('aos-init'); //remove aos-animate class to avoid auto loading animation on scroll
    elem.classList.remove('aos-animate'); //remove aos-animate class to avoid auto loading animation on scroll

    triggerElement.addEventListener(triggerEventId, function () { AddAosAnimateCssClass(elem, triggerEventId, wrapperAnimationElementId); }); //remove aos-animate class to avoid auto loading animation on scroll
}

function AddAosAnimateCssClass(elem, triggerEventId, wrapperAnimationElementId) {
    //debugger
    if (event != null && event.target != null && event.target != undefined && event.target.closest) {
        var closestParentDiv = event.target.closest('div');
        if (closestParentDiv != null && closestParentDiv.id == wrapperAnimationElementId) {

            //sub elements of the wrapper div should not trigger animation, to avoid AOS running animation again
            return;
        }
    }
    if (elem == null || elem == undefined) {
        return;
    }

    elem.classList.remove('aos-init');
    elem.classList.remove('aos-animate');

    if (triggerEventId.toLowerCase() == 'change') {
        if (!event.target.checked) {
            return; //in case this is a checkbox, only trigger on checked state
        }
    }

    setTimeout(function () {
        elem.classList.add('aos-init');
        elem.classList.add('aos-animate');
    }, 500);
}

function RestartAosEventToImplicitEventDomContentLoaded() {
    AOS.init({
        easing: 'ease-in-out',
        startEvent: 'DOMContentLoaded'
    });
}

function DisableAosEventToImplicitEventDomContentLoaded() {
    AOS.init({
        easing: 'ease-in-out',
        startEvent: 'MyCustomEvent'
    });
}
 
 
We set up some helper methods here to be able control playing animations on demand. This is not required however, it was just an experiment from my side to see how you could select an element in the DOM and a event for that element (DOM event) to control how to start the animation. The Animation.razor component looks like this, with its code behind.
 
 

@inject IJSRuntime JsRuntime
<div id="@_wrapperDivUniqueId" data-aos="@SelectedAnimation.GetDisplayName()" data-aos-delay="@Delay" data-aos-duration="@Duration">
  @ChildContent
</div>

@code {
    private string _wrapperDivUniqueId = $"wrapperDiv_{Guid.NewGuid().ToString("N")}";

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    /// <summary>
    /// Duration must be set between 50 to 3000 ms, see defined limit here : https://github.com/michalsnik/aos
    /// </summary>
    [Parameter]
    public int Duration { get; set; } = 1000;

    /// <summary>
    /// Delay must be set between 0 to 3000 ms, see defined limit here: https://github.com/michalsnik/aos
    /// </summary>
    [Parameter]
    public int Delay { get; set; } = 50;

    /// <summary>
    /// Animation to use. Use name list defined in Animations. See here: <see cref="AnimationNames" /> for a list of supported Animations.
    /// </summary>
    [Parameter]
    public AnimationNames SelectedAnimation { get; set; } = AnimationNames.Fade;

    /// <summary>
    /// DOM id of the element that will trigger the animation. If not set, the animation will happed as default, when element scrolls into view according to AOS standard
    /// </summary>
    [Parameter]
    public string? TriggerElementId { get; set; }

    /// <summary>
    /// DOM event for the element that will trigger the animation. See https://www.w3schools.com/jsref/dom_obj_event.asp for a list of DOM events. If not set, the animation will happen as default, when the element scrolls into view according to AOS standard.
    /// </summary>
    [Parameter]
    public string? TriggerEventId { get; set; }

    protected async override Task OnAfterRenderAsync(bool firstRender)
    {
        if (!string.IsNullOrWhiteSpace(TriggerElementId) && !string.IsNullOrWhiteSpace(TriggerEventId))
        {
            //turn off automatic animation on scroll for the element

            await JsRuntime.InvokeAsync<string>("RegisterAnimationStartupTrigger", new[] {
                _wrapperDivUniqueId, TriggerElementId, TriggerEventId });
        }
    }


    protected override void OnParametersSet()
    {
        if (Duration < 50)
        {
            Duration = 50;
        } 
        else if (Duration > 3000)
        {
            Duration = 3000;
        }
        if (Delay < 0)
        {
            Delay = 0;
        }
        else if (Delay > 3000)
        {
            Delay = 3000;
        }
        if (string.IsNullOrWhiteSpace(SelectedAnimation.GetDisplayName()))
        {
            SelectedAnimation = AnimationNames.Fade;
        }       
    }

}

 
 

In the component above, we use the parameter ChildContent which is a RenderFragment? which is used in the razor markup. We wrap a div and generate a unique id which is used in Javascript to control the on demand coupling of starting the animation. In many cases, you could just use the default DOMContentLoaded if you want to just play the animation when the element is displayed. Here is how you use the Animation component in an example component:
 
 
@page "/counter"
@inject IJSRuntime JS

<PageTitle>Counter</PageTitle>

<h1>Counter with Blazor animations</h1>

<p role="status">Current count: @currentCount</p>

<input type="checkbox" id="CheckboxToggleViaSpecificJsEvent" />
    Check here to start the animation [TriggerElementId: CheckboxToggleViaSpecificJsEvent, TriggerEventId: change]
<br />
<br />
<Animation Duration="1000" SelectedAnimation="@AnimationNames.Fade" TriggerElementId="CheckboxToggleViaSpecificJsEvent" TriggerEventId="change">
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
</Animation>
<br />
<br />


<button @onclick="AOSRestartStartEvent" class="btn btn-outline-success">
    Click here to enable AOS start event to DOMContentLoaded
</button>
<br /><br />

<button @onclick="AOSCustomStartEvent" class="btn btn-outline-success">
    Click here to disable AOS start event to DOMContentLoaded (custom start event)
</button>
<br />
<br />

<hr />

<label>
    <h5 class="text-muted">
        Select the animation to show when button is visible
        <InputSelect @bind-Value="SelectedAnimation">
            @foreach (var item in Enum.GetValues(typeof(AnimationNames)).Cast<AnimationNames>())
            {
                <option value="@item">@item.GetDisplayName()</option>                
            }
        </InputSelect>
    </h5>
</label>

<br />

<label>  
 <InputCheckbox @bind-Value="@showAnotherCurrentCountBtn" />
   Check here to start the animation [No trigger element, visbility of button below controlled by data bound flag for the checkbox]
</label>

@if (showAnotherCurrentCountBtn){
    <Animation Duration="1500" SelectedAnimation="@SelectedAnimation" Delay="50">
        <button class="btn btn-outline-primary" @onclick="IncrementCount">Click me</button>
    </Animation>
}
<br />
<br />

@code {
    private int currentCount = 0;
    private bool showAnotherCurrentCountBtn = false;

    private AnimationNames SelectedAnimation = AnimationNames.Fade;


    private void IncrementCount()
    {
        currentCount++;
    }

    private async Task AOSRestartStartEvent()
    {
        await JS.InvokeAsync<string>("RestartAosEventToImplicitEventDomContentLoaded");
    }

    private async Task AOSCustomStartEvent()
    {
        await JS.InvokeAsync<string>("DisableAosEventToImplicitEventDomContentLoaded");
    }

} 
 
 
The AnimationNames is an enum which allows you to set one of the pre-defined animations in AOS. It is possible to define a custom (CSS-based) animation to use with AOS too, I might look into that in another article later on.
 
 using System.ComponentModel.DataAnnotations;

namespace BlazorAnimationSample.Components
{

    public enum AnimationNames
    {
        [Display(Name = "fade")]
        Fade = 0,

        [Display(Name = "fade-up")]
        FadeUp,

        [Display(Name = "fade-down")]
        FadeDown,

        [Display(Name = "fade-left")]
        FadeLeft,

        [Display(Name = "fade-right")]
        FadeRight,

        [Display(Name = "fade-up-right")]
        FadeUpRight,

        [Display(Name = "fade-up-left")]
        FadeUpLeft,

        [Display(Name = "fade-down-right")]
        FadeDownRight,

        [Display(Name = "fade-down-left")]
        FadeDownLeft,

        [Display(Name = "flip-up")]
        FlipUp,

        [Display(Name = "flip-down")]
        FlipDown,

        [Display(Name = "flip-left")]
        FlipLeft,

        [Display(Name = "flip-right")]
        FlipRight,

        [Display(Name = "slide-up")]
        SlideUp,

        [Display(Name = "slide-down")]
        SlideDown,

        [Display(Name = "slide-left")]
        SlideLeft,

        [Display(Name = "slide-right")]
        SlideRight,

        [Display(Name = "zoom-in")]
        ZoomIn,

        [Display(Name = "zoom-in-up")]
        ZoomInUp,

        [Display(Name = "zoom-in-down")]
        ZoomInDown,

        [Display(Name = "zoom-in-left")]
        ZoomInLeft,

        [Display(Name = "zoom-in-right")]
        ZoomInRight,

        [Display(Name = "zoom-out")]
        ZoomOut

    }

}
 


You can clone the Blazor Animation sample easily from Github by following Git command:

git clone https://github.com/toreaurstadboss/BlazorAnimateSample.git

Saturday, 22 April 2023

Tag Helpers in Asp.net Core Mvc 7

This article will present a sample Tag Helper in .net. A Tag Helper is similar to Html Helpers in Asp.net Mvc in .NET Framework, but it is easier to use in HTML as it does not use the special "@-syntax". The Tag helper will render a list using the <ul> and <li> tags. In addition, Bootstrap 5 will be used. Start by creating a razor application with this command:
dotnet new razor -o TagHelpers Then move into the folder TagHelpers and type: code .

Inside Visual Studio Code, hit Ctrl+P and look up the file _ViewImports.cshtml and add the current assembly/solution using:

@addTagHelper *, TagHelpers

This tells that we want to add any TagHelper from the assembly called TagHelpers (the solution we are working with).

@using TagHelpers
@namespace TagHelpers.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, TagHelpers

Consider the following HTML :

<list separator="|">option 1| option 2| option 3| option 4| option 5| option 6| option 7| option 8|this is fun<list>
We want to turn that HTML into the list shown in screen shot below :
That is - create a list using an <ul> tag followed by <li> tags inside. Since we need to access the inner content of the HTML here, we have to use ProcessAsync method of derived method from the TagHelper. We create a TagHelper by inheriting from this class and we also have to name the class suffixed by TagHelper by convention. The resulting Tag Helper then looks like this:


using System.Text;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace TagHelpers.TagHelpers;

public class ListTagHelper : TagHelper {

    public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
    {
        output.TagName = "ul";
        output.Attributes.Add("class", "list-group");
        output.Attributes.Add("style", "display:inline-block"); 
        var existingContent = await output.GetChildContentAsync(); 
        var allContent = existingContent.GetContent();
        var items = allContent.Trim().Split(new[] { Separator }, StringSplitOptions.None);
        var outputHtml = new StringBuilder();
        foreach (var item in items){
            outputHtml.Append($@"<li class=""list-group-item"">{item}</li>");
        }
        output.Content.SetHtmlContent(outputHtml.ToString());        
    }
    public string Separator { get; set; } = ",";
}


We default set the property Separator default to "," to separate items in our list. We could use another separator, such as "|" shown in the markup. If you omit the Separator, "," will be default used. Each public property becomes a recognized attribute in your TagHelper and can be used in the HTML. The TagName is the tag that will be used in the HTML. As we see, we also add 'class' and 'style' attributes here to show a list-group in HTML using Bootstrap 5 classes. We also split the items using the separator, make not that we use the GetChildContentAsync() method on the TagHelperOutput output object, followed by GetContent() method call. Also note that we have to use SetHtmlContent method in case we want to add explicit html content in the content of our 'a' tag here. It is suggested that you stick to string properties in Razor tag helpers instead of other data types.

Monday, 3 April 2023

Using Azure Cognitive Services to summarize articles

I have added a repo on Github for a web scraping app written in .NET MAUI Blazor. It uses Azure Cognitive Services to summarize articles. https://github.com/toreaurstadboss/DagbladetWebscrapper The web scrapper uses the Nuget package for Html agility pack to handle the DOM after downloading articles from the Internet. As the name of the repo suggests, it can be used to read for example Dagbladet articles, without having to waddle through ads. 'Website Scraping' is a term that means extracting data from web sites, or content in general. The following libraries are used in the Razor lib containing the text handling methods to scrap web pages:

<PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.52" />
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
Let's first look at the SummarizationUtil class. This uses TextAnalyticsClient in Azure.AI.TextAnalytics. We will summarize articles into five sentence summaries using the
Azure AI text analytics client.


using Azure.AI.TextAnalytics;
using System.Text;

namespace Webscrapper.Lib
{
	public class SummarizationUtil : ISummarizationUtil
	{

		public async Task<List<ExtractiveSummarizeResult>> GetExtractiveSummarizeResult(string document, TextAnalyticsClient client)
		{
			var batchedDocuments = new List<string>
			{
				document
			};
			var result = new List<ExtractiveSummarizeResult>();
			var options = new ExtractiveSummarizeOptions
			{
				 MaxSentenceCount = 5
			};
			var operation = await client.ExtractiveSummarizeAsync(Azure.WaitUntil.Completed, batchedDocuments, options: options);
			await foreach (ExtractiveSummarizeResultCollection documentsInPage in operation.Value)
			{
				foreach (ExtractiveSummarizeResult documentResult in documentsInPage)
				{
					result.Add(documentResult);
				}
			}
			return result;
		}

		public async Task<string> GetExtractiveSummarizeSentectesResult(string document, TextAnalyticsClient client)
		{
			List<ExtractiveSummarizeResult> summaries = await GetExtractiveSummarizeResult(document, client);
			return string.Join(Environment.NewLine, summaries.Select(s => s.Sentences).SelectMany(x => x).Select(x => x.Text));
		}

	}

}

We set up the extraction here to return a maximum of five sentences. Note the use of await foreach here. (async ienumerable) Here is a helper method to get a string from a ExtractiveSummarizeResult.

using Azure.AI.TextAnalytics;
using System.Text;

namespace Webscrapper.Lib
{

	public static class SummarizationExtensions
	{

		public static string GetExtractiveSummarizeResultInfo(this ExtractiveSummarizeResult documentResults)
		{
			var sb = new StringBuilder();

			if (documentResults.HasError)
			{
				sb.AppendLine($"Error!");
				sb.AppendLine($"Document error code: {documentResults.Error.ErrorCode}.");
				sb.AppendLine($"Message: {documentResults.Error.Message}");
			}
			else
			{
				sb.AppendLine($"SUCCESS. There are no errors encountered while summarizing the document");
			}

			sb.AppendLine($"Extracted the following {documentResults.Sentences.Count} sentence(s):");
			sb.AppendLine();

			foreach (ExtractiveSummarySentence sentence in documentResults.Sentences)
			{
				sb.AppendLine($"Sentence: {sentence.Text} Offset: {sentence.Offset} Rankscore: {sentence.RankScore} Length:{sentence.Length}");
				sb.AppendLine();
			}
			return sb.ToString();
		}
	}

}



Here is a factory method to create a TextAnalyticsClient.


using Azure;
using Azure.AI.TextAnalytics;

namespace Webscrapper.Lib
{
    public static class TextAnalyticsClientFactory
    {

        public static TextAnalyticsClient CreateClient()
        {
            string? uri = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_ENDPOINT", EnvironmentVariableTarget.Machine);
            string? key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_KEY", EnvironmentVariableTarget.Machine);
            if (uri == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_ENDPOINT' Set this variable first.");
            }
            if (uri == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_KEY' Set this variable first.");
            }
            var client = new TextAnalyticsClient(new Uri(uri!), new AzureKeyCredential(key!));
            return client;
        }

    }
}


To use Azure Cognitive Services, you have to get the endpoint (an url) and a service key for your account in Azure portal after having activated Azure Cognitive Services. The page extraction util looks like this, note the use of Html Agility pack.


using HtmlAgilityPack;
using System.Text;

namespace Webscrapper.Lib
{
	public class PageExtractionUtil : IPageExtractionUtil
	{

		public async Task<string?> ExtractHtml(string url, bool includeTags)
		{
			if (string.IsNullOrEmpty(url)) 
				return null;
			var httpClient = new HttpClient();

			string pageHtml = await httpClient.GetStringAsync(url);
			if (string.IsNullOrEmpty(pageHtml))
			{
				return null;
			}

			var htmlDoc = new HtmlDocument(); 
			htmlDoc.LoadHtml(pageHtml);
			var textNodes = htmlDoc.DocumentNode.SelectNodes("//h1|//h2|//h3|//h4|//h5|//h6|//p")
				.Where(n => !string.IsNullOrWhiteSpace(n.InnerText)).ToList();
			var sb = new StringBuilder();
			foreach (var textNode in textNodes)
			{
				var text = textNode.InnerText;
				if (includeTags)
				{
					sb.AppendLine($"<{textNode.Name}>{textNode.InnerText}</{textNode.Name}>");
				}
				else
				{
					sb.AppendLine($"{textNode.InnerText}");
				}
			}
			return sb.ToString();
		}
	}
}



Let's look at an example usage :

@page "/"
@inject ISummarizationUtil SummarizationUtil
@inject IPageExtractionUtil PageExtractionUtil

@using DagbladetWebscrapper.Models;

<h1>Dagbladet Artikkel Oppsummering</h1>

<EditForm Model="@Model" OnValidSubmit="@Submit" class="form-group">
    <DataAnnotationsValidator />
    <ValidationSummary />
  
    <div class="form-group row">
    <label for="Model.ArticleUrl">Url til artikkel</label>
    <InputText @bind-Value="Model!.ArticleUrl" placeholder="Skriv inn url til artikkel i Dagbladet" />
    </div>

    <div class="form-group row">
    <span>Artikkelens oppsummering</span>
    <InputTextArea readonly="readonly" placeholder="Her dukker opp artikkelens oppsummering" @bind-Value="Model!.SummarySentences" rows="5"></InputTextArea>
    </div>

    <div class="form-group row">
    <span>Artikkelens tekst</span>
    <InputTextArea readonly="readonly" placeholder="Her dukker opp teksten til artikkelen" @bind-Value="Model!.ArticleText" rows="5"></InputTextArea>
    </div>
    
    <button type="submit">Submit</button>


</EditForm>

@code {
    private Azure.AI.TextAnalytics.TextAnalyticsClient _client;

    public IndexModel Model { get; set; } = new();

    private async void Submit()
    {
        string articleText = await PageExtractionUtil.ExtractHtml(Model!.ArticleUrl, false);
        Model.ArticleText = articleText;
        if (_client == null)
        {
            _client = TextAnalyticsClientFactory.CreateClient();
        }
        string summaryText = await SummarizationUtil.GetExtractiveSummarizeSentectesResult(articleText, _client);
        Model.SummarySentences = summaryText;

        StateHasChanged();
    }   

}


The view model class for the form looks like this.


using System.ComponentModel.DataAnnotations;

namespace DagbladetWebscrapper.Models
{
	public class IndexModel
	{
        [Required]
        public string? ArticleUrl { get; set; }

        public string SummarySentences { get; set; }

        public string ArticleText { get; set; }
    }
}


Let's look at a screen shot that shows the app in use. It targets an article on the tabloid newspaper Dagbladet in Norway. This tabloid is notorious for writing sensational titles of articles so you have to click into the article (e.g. 'clickbait') and then inside the article, you have to wade through lots of ads. Here, you now have an app, where you can open up www.dagbladet.no and find a link to an article and now extract the text and get a five sentence summary using Azure AI Cognitive services in a .NET MAUI app.