A Blazor Markdown Editor and Previewer for English and Hebrew

As part of one of my passion projects I figured out it was easier to store the contents of some web components as Markdown rather than as HTML strings. There are a number of examples of Blazor Markdown editor components out there, but most of them lack a Previewer and none of them was designed for both English and Hebrew Markdown.

Adding this Markdown editor to a Blazor page is simple enough:


@page "/"

<PageTitle>Index</PageTitle>

<h2>Markdown Editor</h2>

<HebrewMarkdownEditor @bind-Markdown="MarkdownText"></HebrewMarkdownEditor>

The code behind is equally simple:


public partial class Index
    {
        private string MarkdownText { get; set; } = string.Empty;
        
    }

The Razor code for the editor is fairly simply. It leverages an HTML textarea element which uses a few simple Javascript tricks to expand in height as needed based on the size of the Markdown text content. The editor utilizes a separate modal component to display the Markdown as it would appear on a web page. The editor is designed to detect when the Markdown contains Hebrew characters in order to automatically switch the direction of the textarea contents from left-to-right to right-to-left.


<div dir="@Direction">
    <textarea @ref="Element" class="form-control" @bind-value="Markdown" @bind-value:event="oninput" @onkeyup="OnInput">
            </textarea>
    <span class="fa fa-solid fa-eye @Position" title="Preview" aria-hidden="true" @onclick="OnPreview"></span>
</div>
<MarkdownPreviewModal MarkdownText="@Markdown" @bind-Display="Display" @bind-Display:after="OnAfterPreview"></MarkdownPreviewModal>


public partial class HebrewMarkdownEditor : ComponentBase, IAsyncDisposable
    {
        [Inject]
        private IJSRuntime? JSRuntime { get; set; }
        [Parameter]
        public string? Markdown { get; set; } = string.Empty;
        [Parameter]
        public EventCallback<string> MarkdownChanged { get; set; }
        private IJSObjectReference? Module { get; set; }
        private bool Display { get; set; } = false;
        private string Direction => Markdown != null && Markdown.IsHebrew() ? "rtl" : "ltr";
        private string Position => Markdown != null && Markdown.IsHebrew() ? "left" : "right";
        private ElementReference Element { get; set; }

        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender && JSRuntime != null)
            {
                Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/Blazor.HebrewMarkdown.Components/textArea.js");
                if (Module != null)
                {
                    await Module.InvokeVoidAsync("initialize", null);
                }
            }
            if (Module != null)
            {
                await Module.InvokeVoidAsync("setHeight", Element);
            }
            await base.OnAfterRenderAsync(firstRender);
        }

        private async Task OnInput(KeyboardEventArgs args)
        {
            if (args.AltKey && (args.Key == "p" || args.Key == "פ"))
            {
                Display = true;
                return;
            }
            if (MarkdownChanged.HasDelegate)
            {
                await MarkdownChanged.InvokeAsync(Markdown);
            }
        }

        private void OnPreview()
        {
            Display = true;
        }

        private async Task OnAfterPreview()
        {
            await Task.Run(async () => await Element.FocusAsync());
        }

        async ValueTask IAsyncDisposable.DisposeAsync()
        {
            if (Module is not null)
            {
                await Module.DisposeAsync();
            }
        }
    }

Note that the editor calls two bits of Javascript code; one to add and event listener for input events on the textarea, the other calls a method that resets the height of textarea based on the size of the textarea's content.


export function setHeight(element) {
    element.style.height = 'inherit';
    // Get the computed styles for the element
    var computed = window.getComputedStyle(element);
    // Calculate the height
    var height = parseInt(computed.getPropertyValue('border-top-width'), 10)
        + parseInt(computed.getPropertyValue('padding-top'), 10)
        + element.scrollHeight
        + parseInt(computed.getPropertyValue('padding-bottom'), 10)
        + parseInt(computed.getPropertyValue('border-bottom-width'), 10);
    element.style.height = height + 'px';
}

export function initialize() {
    document.addEventListener('input', function (event) {
        if (event.target.tagName !== 'TEXTAREA') return;
        setHeight(event.target);
    }, false);
}

I added two mechanisms to display the Markdown preview modal; one uses a hot key (Alt+p or Alt+פ). The other mechanism requires you to click on the eye icon displayed in the lower left or right corner of the textarea (depending on whether you are using LTR or RTL text). Note that the editor can automatically determine what direction should be set through use of the simple extension method below.

  
  public static partial class StringExtensions
    {
        [GeneratedRegex("\\p{IsHebrew}+", RegexOptions.Compiled)]
        private static partial Regex HebrewRegex();
        private static Regex Hebrew { get; set; } = HebrewRegex();
        public static bool IsHebrew(this string s)
        {
            return !string.IsNullOrEmpty(s) && Hebrew.IsMatch(s);
        }

    }
  
  

The final part of the editor is the Markdown previewer. It's based on a blazor-ised version of the Bootstrap modal component. Like the editor, it detects whether the Markdown contains any Hebrew text, and adjusts the direction of the text displayed accordingly.


<div @ref="Control" class="modal fade @Show" tabindex="-1" role="dialog" style="display: @DisplayType;" 
    @onkeydown="@(async (e) => await OnKeyDown(e))">
    <div class="modal-dialog modal-xl">
        <div class="modal-content">
            <div class="modal-header">
                <h6 class="modal-title">@Title</h6>
                <span class="fa-regular fa-rectangle-xmark" title="Close" aria-hidden="true" @onclick="(async () => await OnClose())"></span>
            </div>
            <div class="modal-body" dir="@Direction">
                @PreviewText
            </div>
        </div>
    </div>
</div>

@if (Display)
{
    <div class="modal-backdrop fade @Show"></div>
}

  
  public partial class MarkdownPreviewModal
    {
        [Parameter]
        public string? MarkdownText { get; set; } = string.Empty;
        [Parameter]
        public bool Display { get; set; } = false;
        [Parameter]
        public EventCallback<bool> DisplayChanged { get; set; }
        [Parameter]
        public string? Title { get; set; } = "Markdown Preview";
        private ElementReference Control { get; set; }
        private string? Show { get; set; }
        private string? DisplayType { get; set; }
        private MarkupString? PreviewText { get; set; }
        private MarkdownPipeline? Pipeline { get; set; }
        private string Direction => MarkdownText != null && MarkdownText.IsHebrew() ? "rtl" : "ltr";

        protected override async Task OnInitializedAsync()
        {
            Pipeline = new MarkdownPipelineBuilder()
                .UseAdvancedExtensions()
                .UseBootstrap()
                .Build();
            await base.OnInitializedAsync();
        }

        protected override async Task OnParametersSetAsync()
        {
            if (Display)
            {
                DisplayType = "block";
                await Task.Delay(150);  // provide a very small delay between block and show to allow for the transition (.15s)
                Show = "show";
                await Task.Run(async() => await Control.FocusAsync());
            }
            PreviewText = (MarkupString)Markdown.ToHtml(MarkdownText ?? string.Empty, Pipeline);
        }

        private async Task OnKeyDown(KeyboardEventArgs args)
        {
            if (args.AltKey && (args.Key == "c" || args.Key == "צ"))
            {
                await OnClose();
            }
        }

        private async Task OnClose()
        {
            Show = string.Empty;
            await Task.Delay(150);
            DisplayType = "none";
            if (DisplayChanged.HasDelegate)
            {
                await DisplayChanged.InvokeAsync(false);
            }
        }

    }
  
  

You can close the previewer using the hotkey Alt+c or Alt+צ or click on the close icon in the upper right hand corner of the previewer.

All the source code for this editor can be found in this Github repository.

An Enumeration-based Dropdown for WASM Blazor

This is the enumeration of the Sons of Israel    וּבְנֵ֣י יִשְׂרָאֵ֣ל לְֽמִסְפָּרָ֡ם

I found the above reference to enumeration in Chronicals 27:1 that seemed appropriate. The Hebrew word (l'misparam) here comes from the root ספר which is a bit unusual in that it has a couple of different meanings. In this instance, it relates to counting, but in other contexts it relates to writing.

I've often used dropdown controls that derive their dropdown items from a collection of values taken from a database. For some dropdowns where only a few items are needed whose values don't frequently change it may be simpler to use a dropdown that uses an enumeration as a data source.

I've created a sample enumeration called Desserts shown below. I'll elaborate on the use of the Display attribute momentarily.

  
   public enum Dessert
    {
        [Display(Name ="Chocolate Cake")]
        ChocolateCake,
        Baklavah,
        [Display(Name = "Fruit Compote")]
        FruitCompote,
        Tiramisu,
        [Display(Name = "Toffee Squares")]
        ToffeeSquares
    }
  
  

The Razor code seen here is very similar to the code I used for my generic dropdown . I make use of the Enum.GetValues() method to iterate over the various values found in my Desserts enumeration.


@typeparam TEnum
<div class="dropdown" @onfocusout="OnFocusOut">
    <button class="btn btn-primary dropdown-toggle @(Show ? "show" : string.Empty)" data-toggle="dropdown" type="button" @onmousedown="OnLabelMouseDown" aria-haspopup="true" aria-expanded="false">
        @Label
    </button>
    <div class="dropdown-menu @(Show ? "show" : string.Empty)">
        @{
            foreach (var enumValue in Enum.GetValues(typeof(TEnum)).Cast<TEnum>())
            {
                <button class="dropdown-item" type="button" @onmousedown="(() => OnItemMouseDown(enumValue))">@enumValue.DisplayName()</button>
            }
        }
    </div>
</div>

  

The C# code-behind is seen below:

  
public partial class EnumDropDown<TEnum> where TEnum : struct, Enum?
    {
        [Parameter]
        public EventCallback<TEnum?> Callback { get; set; }
        [Parameter]
        public TEnum? Selected { get; set; } = null;
        [Parameter]
        public string DefaultLabel { get; set; } = "Select";
        public string? Label => Selected == null ? DefaultLabel : Selected.DisplayName();
        private bool Show { get; set; } = false;

        private void OnFocusOut()
        {
            Show = false;
            StateHasChanged();
        }

        public void OnLabelMouseDown()
        {
            Show = !Show;
        }

        public async Task OnItemMouseDown(TEnum? item)
        {
            Show = false;
            if (item != null)
            {
                if (Callback.HasDelegate)
                {
                    await Callback.InvokeAsync(item);
                }
            }
        }
    }

The point of using the Display attribute from the System.ComponentModel.DataAnnotations namespace is to provide a mechanism for displaying spaces and reordering items in the dropdown. C# doesn't allow you to put spaces in the enum member names, so using the Display attribute is a simple workaround for this limitation. As you can see, at least 3 of the member names require spaces. You'll see that in both the Razor and C# code-behind for this component I employ an extension method called DisplayName() (see below). This method checks the selected member of the enumeration for the presence of a Display attribute. If the attribute is present and it has a Name parameter, that parameter value is returned instead of the ToString() value of the enumeration member. This trick allows us to display member names with spaces (and possibly other characters) that can't normally be used in the Enum definition.

  
public static class EnumExtensions
    {
        public static string? DisplayName(this Enum value)
        {
            if (value == null)
            {
                return null;
            }
            // Read the Display attribute name
            var name = value.ToString();
            if (name != null)
            {
                var member = value.GetType().GetMember(name)[0];
                var displayAttribute = member.GetCustomAttribute<DisplayAttribute>();
                if (displayAttribute != null)
                {
                    return displayAttribute.GetName();
                }
            }
            return Enum.GetName(value.GetType(), value);
        }
    }  
  
  

You can watch a short video demonstrating the use of the dropdown below.

The source code shown in this posting and a simple demo Razor page utilizing the dropdown can be found in my Github repository.