FlaskでMermaid簡易エディタを作る|.mmdファイルの保存と読込を実装する

「FlaskでMermaid簡易エディタを作る|.mmdファイルの保存と読込を実装する」の内容を表す技術イラスト
目次

今回の到達点

Day 3までで、Mermaid簡易エディタには次の処理ができています。

Mermaidコード入力
↓
debounce
↓
構文検証
↓
Mermaid.js
↓
SVG生成
↓
リアルタイムプレビュー

構文を間違えた場合も、

正常
↓
構文エラー
↓
修正
↓
正常

と復帰できるようになりました。

しかし、現時点では入力したMermaidコードはtextareaの中にしかありません。

ブラウザを閉じれば、その内容は失われます。

Day 4では、

エディタ
↓
.mmdとして保存
↓
PC上のファイル

PC上の.mmd
↓
読み込み
↓
エディタ
↓
再描画

という往復を作ります。

これによって、MermaidコードをWebアプリ内部の一時データではなく、外部ファイルとして持ち運べるデータへ変えます。

.mmdファイルとは何か

Mermaidの図はテキストで記述できます。

たとえば、

flowchart LR
    A[Input] --> B[Process]
    B --> C[Output]

という内容を、

sample.mmd

として保存できます。

重要なのは、.mmdが特殊なバイナリ形式ではなく、基本的にはMermaid記法を書いたテキストファイルとして扱えることです。

つまり今回必要なのは、

文字列
↓
ファイル

と、

ファイル
↓
文字列

の変換です。

この程度の処理であれば、Pythonへ送信しなくてもブラウザだけで実装できます。

今回もFlaskへ機能を追加しない

一見すると「ファイル保存」という言葉から、

ブラウザ
↓
Flask
↓
Python
↓
ファイル保存

を想像するかもしれません。

しかし今回保存したい場所は、Webサーバーではありません。

ユーザー自身のPCです。

したがって、

ブラウザ
↓
Blob
↓
ダウンロード
↓
ユーザーPC

で十分です。

読込も、

ユーザーPC
↓
ファイル選択
↓
File
↓
JavaScript
↓
textarea

とできます。

MDNでは、Fileオブジェクトはinput要素でユーザーが選択したファイルなどから取得でき、その内容へJavaScriptからアクセスできます。またFileはBlobの一種です。MDN Web Docs

このためDay 4でもapp.pyは変更しません。

これは「Pythonを使わない」のではなく、

Pythonが必要な処理だけをPythonへ持たせる

という設計です。

Day 4で追加するUI

Day 3の画面に、保存と読込の操作を追加します。

概念的には次の形です。

┌──────────────────────────────────────────────┐
│ Mermaid Editor                               │
│                                              │
│ [Open .mmd] [Save .mmd]                      │
├──────────────────────┬───────────────────────┤
│ Code                 │ Preview               │
│                      │                       │
│ flowchart LR         │   A → B → C           │
│ ...                  │                       │
└──────────────────────┴───────────────────────┘

操作は2つです。

Open .mmd
→ ファイルを読み込む

Save .mmd
→ 現在のコードを保存する

HTMLへファイル操作UIを追加する

index.htmlのヘッダー部分を変更します。

<header class="app-header">
    <h1>Mermaid Editor</h1>

    <div class="file-actions">
        <button id="open-button" type="button">
            Open .mmd
        </button>

        <button id="save-button" type="button">
            Save .mmd
        </button>

        <input
            id="file-input"
            type="file"
            accept=".mmd,text/plain"
            hidden
        >
    </div>
</header>

新しく3つの要素を追加しました。

#open-button
→ ファイル選択開始

#save-button
→ ファイル保存

#file-input
→ 実際のファイル選択

input type="file"自体は非表示にしています。

ユーザーはOpen .mmdボタンを押します。

JavaScriptから、

fileInputElement.click();

を実行し、ファイル選択画面を開きます。

acceptは完全な検証ではない

ファイル入力には、

accept=".mmd,text/plain"

を指定しています。

これはユーザーがファイルを選びやすくするためのヒントです。

ただし、

accept
=
絶対的なファイル形式検証

ではありません。

したがってJavaScript側でもファイル名を確認します。

今回のMVPでは、

.mmd

だけを受け付ける設計にします。

CSSを少し追加する

style.cssへ追加します。

.app-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    padding: 16px 24px;
    background: #ffffff;
    border-bottom: 1px solid #dddddd;
}

.file-actions {
    display: flex;
    gap: 8px;
}

.file-actions button {
    padding: 8px 12px;
    border: 1px solid #cccccc;
    border-radius: 6px;
    background: #ffffff;
    cursor: pointer;
}

.file-actions button:hover {
    background: #f2f2f2;
}

今回CSSで重要なのはデザインそのものではありません。

ファイル操作UIを、

入力
描画
ファイル操作

の別責務として画面上でも分離していることです。

JavaScriptで必要な要素を取得する

Day 3のeditor.jsへ追加します。

const openButtonElement = document.getElementById("open-button");
const saveButtonElement = document.getElementById("save-button");
const fileInputElement = document.getElementById("file-input");

これで、

Open
Save
File input

をJavaScriptから操作できます。

.mmdを保存する仕組み

保存処理では、textareaの内容を取得します。

const definition = inputElement.value;

これはJavaScript上では単なる文字列です。

これをファイルとして扱える形へ変換します。

そこで使うのがBlobです。

const blob = new Blob(
    [definition],
    {
        type: "text/plain;charset=utf-8",
    }
);

Blobは、ブラウザ上でファイルのように扱えるデータオブジェクトです。

今回は文字列からBlobを作っています。

概念的には、

JavaScript String
↓
Blob
↓
Object URL
↓
Download

となります。

BlobからURLを作る

Blobを作っただけでは、まだダウンロードできません。

そこで、

const objectUrl = URL.createObjectURL(blob);

とします。

MDNではBlob URLは、メモリ上のBlobやFileをURLとして扱うための仕組みであり、ローカル生成データのダウンロードにも利用できると説明されています。MDN Web Docs

生成されるURLは概念的には、

blob:http://127.0.0.1:5000/...

のような一時URLです。

このURLをダウンロード用リンクへ渡します。

ダウンロード用リンクを一時的に作る

保存関数を作ります。

function saveMmdFile() {
    const definition = inputElement.value;

    if (definition.trim() === "") {
        showError("保存するMermaidコードがありません。");
        return;
    }

    const blob = new Blob(
        [definition],
        {
            type: "text/plain;charset=utf-8",
        }
    );

    const objectUrl = URL.createObjectURL(blob);

    const linkElement = document.createElement("a");

    linkElement.href = objectUrl;
    linkElement.download = "diagram.mmd";

    document.body.appendChild(linkElement);
    linkElement.click();
    linkElement.remove();

    URL.revokeObjectURL(objectUrl);
}

処理を分解すると、

textarea
↓
文字列
↓
Blob
↓
Object URL
↓
a要素
↓
download

です。

download属性の役割

一時的に作ったリンクへ、

linkElement.download = "diagram.mmd";

を指定しています。

これにより、ブラウザへダウンロード用ファイル名を提示します。

今回は固定で、

diagram.mmd

としています。

ファイル名入力機能まで追加するとDay 4の責務が広がるため、MVPでは固定名とします。

必要になれば後から、

diagram-001.mmd
hydraulic-circuit.mmd
sequence-chart.mmd

のように変更できるUIを追加できます。

Object URLを解放する

保存処理の最後には、

URL.revokeObjectURL(objectUrl);

を呼びます。

URL.createObjectURL()を呼ぶたびにObject URLが作られます。

MDNでは、不要になったObject URLはURL.revokeObjectURL()で解放することが推奨されています。Object URLが残っている間は元のBlobも解放できないため、長時間動くアプリでは不要なURLを保持し続けないことが重要です。MDN Web Docs

今回のObject URLはダウンロードを開始するためだけに使います。

したがって、その役割を終えた後に解放します。

Saveボタンへ接続する

イベントを登録します。

saveButtonElement.addEventListener(
    "click",
    saveMmdFile
);

これで、

Save .mmd
↓
saveMmdFile()
↓
diagram.mmd

となります。

.mmdファイルを読み込む

次は逆方向です。

.mmd
↓
File
↓
文字列
↓
textarea
↓
Mermaid再描画

まずOpenボタンからファイル選択を開始します。

function openFilePicker() {
    fileInputElement.click();
}

イベントを接続します。

openButtonElement.addEventListener(
    "click",
    openFilePicker
);

これだけでブラウザのファイル選択UIを利用できます。

選択されたFileを取得する

ユーザーがファイルを選ぶとchangeイベントが発生します。

fileInputElement.addEventListener(
    "change",
    handleFileSelection
);

処理本体を作ります。

async function handleFileSelection() {
    const [file] = fileInputElement.files;

    if (!file) {
        return;
    }

    try {
        await loadMmdFile(file);
    } finally {
        fileInputElement.value = "";
    }
}

fileInputElement.filesはFileListです。

今回は1ファイルだけ扱うので、

const [file] = fileInputElement.files;

で先頭を取得します。

MDNでもinput type="file"から得られるFileListを通じてFileへアクセスできることが説明されています。MDN Web Docs

ファイル名を検証する

読込関数を作ります。

async function loadMmdFile(file) {
    if (!file.name.toLowerCase().endsWith(".mmd")) {
        showError(".mmdファイルを選択してください。");
        return;
    }

    const definition = await file.text();

    inputElement.value = definition;

    scheduleRender();
}

まず、

file.name

を確認しています。

.MMDのような大文字も許容するため、

toLowerCase()

してから判定します。

File.text()で文字列として読む

今回の重要部分です。

const definition = await file.text();

現在のFile APIでは、Fileをテキストとして読み込む場合、text()を利用できます。MDNのFile API例でも、選択したファイルをawait file.text()で読み取る方法が示されています。MDN Web Docs

古いコードでは、

FileReader

を使った例も多く見つかります。

FileReader自体が誤りというわけではありません。

ただし今回のように、

File
↓
テキスト全文

だけが必要なら、

await file.text()

のほうが簡潔です。

読み込んだら既存の描画系へ渡す

ファイルを読んだ後に、

inputElement.value = definition;

とします。

しかし、JavaScriptからtextareaの値を書き換えただけでは、ユーザー入力によるinputイベントは自動的には発生しません。

そこで明示的に、

scheduleRender();

を呼びます。

これがDay 3で作った処理の再利用です。

.mmd
↓
File.text()
↓
textarea
↓
scheduleRender()
↓
debounce
↓
mermaid.parse()
↓
mermaid.render()
↓
Preview

Day 4用に別の描画処理を作っていません。

入口がキーボード入力でもファイルでも、その後は同じ描画パイプラインを使う

構成になっています。

Day 4で追加するJavaScript

Day 3のeditor.jsへ、次の処理を追加します。

const openButtonElement = document.getElementById("open-button");
const saveButtonElement = document.getElementById("save-button");
const fileInputElement = document.getElementById("file-input");


function saveMmdFile() {
    const definition = inputElement.value;

    if (definition.trim() === "") {
        showError("保存するMermaidコードがありません。");
        return;
    }

    const blob = new Blob(
        [definition],
        {
            type: "text/plain;charset=utf-8",
        }
    );

    const objectUrl = URL.createObjectURL(blob);

    const linkElement = document.createElement("a");

    linkElement.href = objectUrl;
    linkElement.download = "diagram.mmd";

    document.body.appendChild(linkElement);
    linkElement.click();
    linkElement.remove();

    URL.revokeObjectURL(objectUrl);
}


function openFilePicker() {
    fileInputElement.click();
}


async function loadMmdFile(file) {
    if (!file.name.toLowerCase().endsWith(".mmd")) {
        showError(".mmdファイルを選択してください。");
        return;
    }

    const definition = await file.text();

    inputElement.value = definition;

    scheduleRender();
}


async function handleFileSelection() {
    const [file] = fileInputElement.files;

    if (!file) {
        return;
    }

    try {
        await loadMmdFile(file);
    } catch (error) {
        showError(getErrorMessage(error));
    } finally {
        fileInputElement.value = "";
    }
}


openButtonElement.addEventListener(
    "click",
    openFilePicker
);

saveButtonElement.addEventListener(
    "click",
    saveMmdFile
);

fileInputElement.addEventListener(
    "change",
    handleFileSelection
);

このコードはDay 3の、

showError()
getErrorMessage()
scheduleRender()

をそのまま再利用しています。

これが積み上げ型で作るメリットです。

なぜfileInputElement.valueを空に戻すのか

読込後に、

fileInputElement.value = "";

としています。

これは同じファイルを連続して選び直せるようにするためです。

ファイル入力に前回の選択状態が残っていると、同じファイルを再選択した場合にchangeイベントが期待どおり発生しないケースがあります。

そこで処理終了後に選択状態をリセットします。

小さな処理ですが、実際に使うツールでは操作性に影響します。

保存→読込を往復テストする

Day 4では単独機能ではなく、往復で確認します。

まず次のMermaidコードを入力します。

flowchart LR
    A[Design] --> B[Check]
    B --> C[Release]

Save .mmdを押します。

保存された、

diagram.mmd

をテキストエディタで開きます。

中身が、

flowchart LR
    A[Design] --> B[Check]
    B --> C[Release]

になっていることを確認します。

次にMermaidエディタのtextareaを別の内容へ変更します。

その後、

Open .mmd

から先ほど保存したファイルを選びます。

期待結果は、

ファイル読込
↓
textarea更新
↓
scheduleRender()
↓
構文検証
↓
SVG再生成
↓
元の図が復元

です。

この記事生成環境ではブラウザのファイル選択・ダウンロード操作までは実行していないため、この結果を「実行確認済み」とはしていません。

実際の環境で往復確認してください。

構文エラーを含む.mmdを開いたらどうなるか

たとえば次のファイルを読み込みます。

flowchart LR
    A[

ファイル読込自体は成功します。

その後、

scheduleRender();

が呼ばれます。

Day 3で作った、

mermaid.parse()
↓
失敗
↓
showError()

へ流れます。

つまりDay 4では、ファイル読込専用のMermaid構文検証を作る必要がありません。

キーボード入力
ファイル入力

という2つの入口が、

scheduleRender()

へ合流しています。

この構造は今後さらに重要になります。

Day 6でテンプレートを選択した場合も、

テンプレート
↓
textarea
↓
scheduleRender()

とできます。

入口が増えても描画ロジックは1つです。

ブラウザ保存とサーバー保存は別物

今回実装した保存は、

ユーザーPC

へのダウンロードです。

Flaskサーバー上へファイルを保存しているわけではありません。

これは重要な違いです。

ブラウザ保存なら、

ユーザー
↓
ブラウザ
↓
ユーザーPC

で完結します。

一方、サーバー保存なら、

ユーザー
↓
ブラウザ
↓
HTTP POST
↓
Flask
↓
Python
↓
サーバー上のストレージ

になります。

後者では、

  • ファイル名管理
  • 同名ファイル
  • 保存先
  • アクセス権
  • 容量
  • バックアップ
  • セキュリティ
  • 複数ユーザー
  • 削除
  • 認証

など、新しい問題が発生します。

今回のMVPには必要ありません。

したがって、ブラウザで完結できる処理をわざわざサーバー処理へ変えないという判断をしています。

ファイルはデータ境界になる

.mmd保存には、単なるバックアップ以上の意味があります。

現在のアプリ内部ではMermaidコードは、

inputElement.value

という一時的な文字列です。

これを、

diagram.mmd

へ出すことで、アプリケーションの外へ持ち出せます。

すると、

Mermaid Editor
↓
.mmd
↓
Git

も可能です。

あるいは、

.mmd
↓
別のMermaid対応ツール

も可能です。

つまり.mmdは、

アプリケーションとデータを分離する境界

になります。

これは再利用可能なツールを作るうえで重要です。

独自DBへしか保存できない設計より、

プレーンテキスト
JSON
CSV
Markdown

のような外部利用可能な形式を持つほうが、データの寿命をアプリケーションから切り離せます。

よくある失敗

保存するとファイルが空になる

次を確認します。

const definition = inputElement.value;

Blobへ渡しているのが、

definition

になっている必要があります。

また空入力を保存しようとした場合は、今回のコードではエラー表示して終了します。

Openボタンを押しても何も起きない

次のイベント登録を確認します。

openButtonElement.addEventListener(
    "click",
    openFilePicker
);

さらに、

function openFilePicker() {
    fileInputElement.click();
}

が必要です。

ファイルは読めたが図が更新されない

次を確認します。

inputElement.value = definition;
scheduleRender();

textareaへ値を設定するだけでは、Day 3で登録したinputイベントは自動実行されません。

そのため明示的にscheduleRender()を呼びます。

同じファイルを再読込できない

処理終了後に、

fileInputElement.value = "";

としているか確認します。

Object URLを作り続ける

次だけを書いて終わらせないようにします。

const objectUrl = URL.createObjectURL(blob);

不要になったら、

URL.revokeObjectURL(objectUrl);

で解放します。

MDNもObject URLを不要になった時点で解放することを推奨しています。MDN Web Docs

練習問題

保存ファイル名を変更する

現在は、

linkElement.download = "diagram.mmd";

です。

これを、

linkElement.download = "my-flowchart.mmd";

へ変更してください。

確認観点

ファイル内容とファイル名は独立していることを確認します。

.txtファイルを選んでみる

通常の.txtファイルをOpenから選択してください。

今回のコードでは、

file.name.toLowerCase().endsWith(".mmd")

で判定しているためエラー表示になります。

確認観点

HTMLのacceptだけへ依存せず、アプリ側でも入力条件を確認していることを理解します。

保存→編集→読込を試す

次の図を保存します。

flowchart TD
    Start --> Design
    Design --> Check
    Check --> Finish

保存後、textareaを完全に別の図へ変更します。

その後、保存した.mmdを読み込みます。

確認観点

保存
↓
外部ファイル
↓
再読込
↓
再描画

の往復が成立することを確認します。

Day 4のまとめ

Day 4では、Mermaidコードをブラウザ内部だけの一時データから.mmdファイルへ出せるようにしました。

保存処理は、

textarea
↓
String
↓
Blob
↓
Object URL
↓
.mmd

です。

読込処理は、

.mmd
↓
File
↓
File.text()
↓
textarea
↓
scheduleRender()
↓
Mermaid
↓
SVG

です。

重要なのは、今回もFlask側を変更していないことです。

ファイルをユーザーPCへ保存・読込するだけなら、ブラウザの標準APIで完結できます。File、Blob、Object URLはいずれも広く利用可能なWeb Platformの機能です。MDN Web Docs

そして設計面では、

入力
描画
保存
読込

の責務が少しずつ分離されてきました。

Day 1では、

Flask → HTML

Day 2では、

入力 → Mermaid → SVG

Day 3では、

入力変更 → 検証 → 再描画

Day 4では、

.mmd ⇄ エディタ

が成立しました。

これでMermaidコードはアプリケーションの外へ持ち出せるようになり、データとツールを分離できるMVPへ一段進みました。

参考情報

ブラウザのFileインターフェースは、ユーザーが選択したローカルファイルの情報と内容へJavaScriptからアクセスするための標準APIです。FileはBlobの一種として扱えます。MDN Web Docs

File APIでは、選択したテキストファイルをawait file.text()で読み込む方法が利用できます。今回のような単純なテキスト読込では、イベントベースのFileReaderを必須にする必要はありません。MDN Web Docs

Blob URLはメモリ上のデータを一時的なURLとして扱える仕組みで、ローカル生成データのダウンロードにも利用できます。作成したObject URLが不要になった場合はURL.revokeObjectURL()で解放します。MDN Web Docs

次回予告

Day 5は**「SVG出力とクリップボード」**です。

現在プレビューに表示されているMermaidのSVGを取り出し、.svgファイルとして保存できるようにします。さらにMermaidソースをワンクリックでクリップボードへコピーできるようにし、**「編集するツール」から「成果物を外部へ渡せるツール」**へ進めます。

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次