🐥note.

小鳥とMicrosoft <3 なエンジニアの技術Blog📚

.NET Core 3.0のBlazorで作るMarkdown Editor(修正版)

※本エントリは先日投稿したエントリのコードを修正して投稿したものです。

.NET Core 3.0のBlazorコトハジメとしてリアルタイムプレビューに対応したMarkdownエディタを作ってみました。

Blazorのことよく分かってないので雰囲気で作った感が満載です。

デモ

f:id:piyo_esq:20190926213612g:plain
デモ

成果物

コードはGitHubに上げています。

github.com

プロジェクトの作成

dotnet new blazorserver -o MarkdownEditor
cd MarkdownEditor

Rxの追加

Reactive Extensionsを使用するのでSysntem.Reactiveを追加します。

dotnet add package System.Reactive

Pages/Editor.razor

Pages以下にエディタの画面を追加します。

@pageでURLを指定しています。
実装は別ファイルへ切り出して@inheritsで取り込みます。
oninputイベントでTextAreaへの入力をリアルタイムに取得します。

Pages/Editor.razor

@page "/editor"
@inherits MarkdownEditor.Components.EditorBase

<h1>Markdown Editor</h1>

<div style="height: 300px">
    <div class="row h-100">
        <div class="col-md h-100">
            <textarea class="form-control h-100 w-100" @oninput="HandleOnInput"></textarea>
        </div>
        <div class="col-md h-100">
            <div style="border: solid 1px;" class="h-100">
                @((MarkupString)MarkdownText)
            </div>
        </div>
    </div>
</div>

Components/EditorBase.cs

Componentsフォルダを作成し、内部にPages/Editor.razorの実装クラスを追加します。

MarkdownをHtmlへ変換するにはJavaScriptMarkedhighlight.jsを使用します。 ※C#Markdigなどを使用してもOK

変換の処理はRxを使って1秒毎に行います。

C#のコードからJavaScriptを呼ぶためにIJSRuntime[inject]指定します。
この属性を指定すると、自動的に依存性注入してくれるっぽいですね。

ComponentBaseを継承したクラスから画面要素を更新するにはInvokeAsyncを経由してStateHasChangedを実行します。WPFのDispatcherみたいなノリですね。

Components/EditorBase.cs

using System;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using System.Reactive.Linq;
using System.Threading.Tasks;
using System.Diagnostics;

namespace MarkdownEditor.Components
{
    public class EditorBase : ComponentBase, IDisposable
    {
        [Inject]
        private IJSRuntime _jsRuntime { get; set; }
        [Inject]
        private ILogger<EditorBase> _logger { get; set; }
        protected string MarkdownText { get; set; }

        private IObservable<long> _timerSource;
        private IDisposable _timerSubscription;
        string _inputText;

        public EditorBase()
        {
            MarkdownText = "Hello World.";
        }
        public void Dispose()
        {
            // 手抜きDispose
            _timerSubscription?.Dispose();
        }

        // OnAfterRenderAsyncもある
        protected override void OnAfterRender(bool firstRender)
        {
            // 描画毎に呼ばれるので、最初の1回目のみとする.
            if (!firstRender) return;

            // Rxで1秒毎にMarkdownをHtmlへ変換
            _timerSource = Observable.Timer(TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(500));
            _timerSubscription = _timerSource.Subscribe(async _ => await ConvertMarkdownToHtml());
        }

        public void HandleOnInput(ChangeEventArgs e)
        {
            // oninputイベントハンドラ
            // TextAreaに入力した文字列を取得
            _inputText = e?.Value?.ToString();
        }

        private async Task ConvertMarkdownToHtml()
        {
            if (string.IsNullOrEmpty(_inputText)) return;
            try
            {
                // JavaScriptのConvertToHtml関数でMarkdownをHtml化
                MarkdownText = await _jsRuntime.InvokeAsync<string>("ConvertMarkdownToHtml", _inputText);
                // ComponentBaseを継承したクラスから画面の更新する際はInvokeAsyncを使用する
                await InvokeAsync(() => StateHasChanged());
            }
            catch (System.Exception exp)
            {
                _logger.LogError(exp.Message);
            }
        }
    }
}

Shared/NavMenu.razor

作成したEditorページへのリンクを追加します。

        // 途中省略
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
+        <li class="nav-item px-3">
+            <NavLink class="nav-link" href="editor" Match="NavLinkMatch.All">
+                <span class="oi oi-pencil" aria-hidden="true"></span> Editor
+            </NavLink>
+        </li>
        </li>
    </ul>
</div>
// 以下省略

wwwroot/app.js

MarkdownテキストをHtml化する関数を作成します。

<code>classにうまくhljsが入らなかったり、存在しないプログラム言語を```区で指定した際に例外を投げ続ける挙動があったので、highlight.jsのissueを参考に修正しています。

window.ConvertMarkdownToHtml = (markdownText) => {
    const renderer = new marked.Renderer()
    const doRenderCode = (code, lang) => lang && hljs.getLanguage(lang) ? hljs.highlight(lang, code, true).value : hljs.highlightAuto(code).value
    renderer.code = (code, lang) => `<pre><code class="hljs ${lang || ''}">${doRenderCode(code, lang)}</code></pre>`
    const markedOptions = {
        renderer
    }
    return marked(markdownText, markedOptions);
}

Pages/_Host.cshtml

app.js, Marked, highlight.js, シンタックスハイライト時のカラーテーマを読み込みます。

@page "/"
@namespace MarkdownEditor.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MarkdownEditor</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />

+   <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.6.2/marked.min.js"></script>
+   <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.8/highlight.min.js"></script>
+   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.8/styles/monokai.min.css">
</head>
<body>
    <app>
        @(await Html.RenderComponentAsync<App>(RenderMode.ServerPrerendered))
    </app>

    <script src="_framework/blazor.server.js"></script>
+  <script src="app.js"></script>
</body>
</html>

以上で完成です。

dotnet runで起動してみましょう。

f:id:piyo_esq:20190926213612g:plain

おわり

以前掲載していた記事から以下の点を変更しました。

  • TextAreaの値をリアルタイムに取得するのにTimer + IJSRuntimeのDom操作を使用していた点をOnInputイベントに変更しました。

  • 定期的にPreviewを更新する処理をSystem.TimerからRxに変更しました。

前者はひどいやり方だったので気が付いた時にすっごく恥ずかしかったです…。

以上です。