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.

No comments:

Post a Comment