PowerShell 7 で GUI(WinForms)を使って、Markdown Viewer/Editor を作ってみた。

screenshot

機能

Editor 部

  • 基本的にはただの TextBox
  • Live editor として機能
  • 外部からファイルのドラッグ&ドロップ可能
    • *.md なら、そのファイルを開く
    • 画像(*.(png|gif|jpe?g|webp|svg)) ファイルなら、カーソル位置へ ![](file:///...) を挿入

Browser 部

  • HTMLレンダリングには WebView2 を使用
  • GitHub Markdown 的なCSS
  • 外部からファイルのドラッグ&ドロップ可能
    • *.md なら、そのファイルを開く

HTMLレンダリング

起動すると MDView.html が読み込まれる。

エディタ部分のmarkdownテキストは PowerShell 7 内臓の ConvertFrom-Markdown (markdig)で HTML に変換され、MDView.html 内の JavaScript 関数に渡される。

コードブロックのハイライトとコピーのために、highlight.jshighlightjs-copy を読み込むようにしている。

この辺りや CSS が気に入らなければ、好きに弄ってくれ。

Fileメニュー

File Menu

特に言うこと無し

Viewメニュー

View Menu
  • TopMost: ウィンドウを常に全面に出すやつ
  • SplitView: エディタ部分とブラウザ部分を両方表示
  • Editor Only: エディタ部分のみ表示
  • Browser Only: ブラウザ部分のみ表示
  • Preview Mode:
    • ファイルの更新監視をして、ブラウザへ反映させる
    • ファイルを開いている時のみ有効
    • 好きなエディタでMarkdownファイルを開いて更新してね
  • Font: エディタ部分のフォントを変更

セットアップ

PowerShell 7 以上(Windows標準でインストールされているPowerShell 5.1ではない)で https://github.com/teramako/PSMarkdownEditor/blob/main/setup.ps1 を実行してもらう。

pwsh -NoProfile -File path\to\setup.ps1

実行すると setup.ps1 があるディレクトリに以下を行う

  1. WebView2 のセットアップ
    • WebView2 が使用するデータ保管用ディレクトリ(webview2_userdata)
    • .NET 用ライブラリを www.nuget.org からダウンロード
    • ダウンロードしたファイル(zip形式)中から必要な dll ファイルを libs ディレクトリに展開
  2. 起動用ショートカット(MDView.lnk)の作成
    • conhost.exe --headless 経由でスクリプト(ps1)実行することで、コマンドプロンプトが一瞬表示されるのを防止

あとは、MDView.lnk を実行すれば起動する

苦労話とか開発Tips

System.Windows.Forms.Form を継承したクラス作成

当初、普通に C# で書くような感じで、System.Windows.Forms.Form を継承したクラスを作って使用しようと、以下のように書いた。

using namespace System.Windows.Forms;
using namespace System.Drawing;
using namespace Microsoft.Web.WebView2.Core;
using namespace Microsoft.Web.WebView2.WinForms;

# ..

Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
Add-Type -Path $PSScriptRoot\libs\Microsoft.Web.WebView2.Core.dll
Add-Type -Path $PSScriptRoot\libs\Microsoft.Web.WebView2.WinForms.dll

class MDView : Forms {
    # ...
}

ところが、これではうまく行かない。

Add-Type による dll 読み込みより先にクラス定義が読み込まれてしまうのだ。 アセンブリ参照が足りない、みたいなエラーになって怒られる。

そこで、2ファイルに分ける対応をした。

MDView.ps1:

Add-Type -AssemblyName System.Windows.Forms;
Add-Type -AssemblyName System.Drawing;
Add-Type -Path $PSScriptRoot\libs\Microsoft.Web.WebView2.Core.dll
Add-Type -Path $PSScriptRoot\libs\Microsoft.Web.WebView2.WinForms.dll

. (Join-Path $PSScriptRoot MDView.class.ps1)

MDView.class.ps1:

using namespace System.Windows.Forms;
using namespace System.Drawing;
using namespace Microsoft.Web.WebView2.Core;
using namespace Microsoft.Web.WebView2.WinForms;

class MDView : Forms {
    # ...
}

このように Add-Type → クラス定義 の順番を強制することで、PowerShell でも問題なく Form を継承したクラスを作れる。

Timer による遅延レンダリング

TextBox の TextChanged イベント発生毎に HTML レンダリングしていると重たくなる。

そこで System.Windows.Forms.Timer を使用して、 一定時間入力が止まったらレンダリング する方式にした。

class MDView : Form {
    [Timer] $UpdateTimer
    [TextBox] $MarkdownTextBox
    MDView() {
        $this.UpdateTimer = New-Object Timer -Property @{ Interval = 500 }
        $this.MarkdownTextBox = New-Object TextBox -Property @{ ... }
    }

    [void] Form_Load($s, $e) {
        $this.MarkdownTextBox.Add_TextChanged($this.MarkdownTextBox_TextChanged)
        $this.UpdateTimer.Add_Tick($this.UpdateTimer_Tick)
    }

    [void] MarkdownTextBox_TextChanged($s, $e) {
        $this.UpdateTimer.Stop();
        $this.UpdateTimer.Start();
    }

    [void] UpdateTimer_Tick($s, $e) {
        $this.UpdateTimer.Stop();
        $this.UpdateView($null) # ←ブラウザにHTMLレンダリングさせるメソッド
    }

TextChanged イベント発生時には Timer の停止、開始のみ行い、 Timer に設定された Interval (ミリ秒) 経過後に実際のHTMLレンダリングをさせる。

ファイル監視

好きなエディタで書いて保存すると自動でブラウザに反映させたくてファイル監視機能を付けようとした。

System.IO.FileSystemWather を使用したのだが、最初は PowerShell がクラッシュしてしまった。

原因は、イベントが UI スレッドではなく ThreadPool スレッドで発火するため だそうで、PowerShell のコードが実行できず例外になる。

解決策は SynchronizingObject を設定すること。

$fsw = [IO.FileSystemWatcher]::new($this.File.DirectoryName, $this.File.Name)
$fsw.NotifyFilter = [IO.NotifyFilters]::LastWrite;
$fsw.IncludeSubdirectories = $false;
$fsw.SynchronizingObject = $this; # ← UIスレッドでイベントを発行させる
$fsw.EnableRaisingEvents = $true;
$fsw.Add_Changed($this.FileWatcher_Changed)

WebView2 へのファイルのドラッグ&ドロップ

ブラウザ部分へ *.md ファイルをドロップすることで、そのファイルをHTMLレンダリングさせようとした。 通常なら DragEnterDragDrop イベントをハンドラを設定するのだが、 WebView2 では NewWindowRequested イベントが発行される仕様なようで、このイベントのハンドラを設置する必要がある。

$this.WebView.CoreWebView2.Add_NewWindowRequested($this.WebView_NewWindowRequested)
[void] WebView_NewWindowRequested($s, [CoreWebView2NewWindowRequestedEventArgs] $e) {
    $uri = [uri]::new($e.Uri)
    if ($uri.IsFile) {
        $e.Handled = $true
        $fileInfo = [IO.FileInfo]::new($uri.LocalPath)
        if ($fileInfo.Extension -eq ".md") {
            $this.TryOpenMarkdownFile($fileInfo)
        }
    }
}