今回の到達点
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ソースをワンクリックでクリップボードへコピーできるようにし、**「編集するツール」から「成果物を外部へ渡せるツール」**へ進めます。

