Wails 実践講座 #7 ノートエディタの実践 — マークダウンプレビューと一覧の UX

読了 5分

ここから第 2 部です。第 1 部(#1#6)でノートアプリを配信可能な状態まで作りましたが、肝心のユーザーが毎日触る前側は「軽量なコンポーネントフレームワークを前提にする」として先送りにしてきました。第 2 部はこのアプリをまた開きたくさせる完成度を扱い、その最初の記事がエディタです。マークダウンノートアプリの核心の体験、つまり編集とプレビューを実際に実装します。

このシリーズは全 10 編、2 部構成です。

  • 第 1 部 — アプリを作って配信する: #1 · #2 · #3 · #4 · #5 · #6
  • 第 2 部 — 完成度とリピーター
    • #7 ノートエディタの実践 — マークダウンプレビューと一覧の UX ← この記事
    • #8 テスト戦略 — サービスとリポジトリを検証する
    • #9 自動更新の実装 — 新しいバージョンを安全に届ける
    • #10 仕上げと完成度 — 設定・ダークモード・データバックアップ

編集のためのバックエンド: Update メソッド #

#2 で作ったリポジトリには作成・取得・検索だけがありました。編集をするには更新が必要なので、リポジトリとサービスに Update を加えます。サービスが UpdatedAt を現在時刻に更新するのが核心です。この値が #3 で一覧を新しい順に並べる基準だからです。

service.go — 更新
func (s *NoteService) Update(id int64, title, body string) (Note, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Note{}, errors.New("タイトルを入力してください")
	}
	return s.repo.Update(Note{
		ID: id, Title: title, Body: body, UpdatedAt: time.Now(),
	})
}

バインディング層では #3 と同じパターンで更新後に notes:changed イベントを発行し、一覧が自動で描き直されるようにします。

プレビュー: マークダウンを安全にレンダリング #

マークダウンノートアプリは、編集したテキストを書式のある画面で見せる必要があります。フロントエンドでマークダウンを HTML に変えるライブラリ(たとえば marked)を使いますが、ここに実践の落とし穴が一つあります。ノート本文はユーザーが書いた内容であり、それを HTML に変えてそのまま挿入すると XSS のリスクが生まれます。 本文に <script>onerror 属性が入ると、そのまま実行されかねません。

自分だけが使うローカルアプリでも習慣を守ります。マークダウンを HTML に変えた後、必ずサニタイズのライブラリ(たとえば DOMPurify)を通して危険なタグ・属性を取り除いてから挿入します。

preview.js — レンダリング後にサニタイズ
import { marked } from "marked";
import DOMPurify from "dompurify";

export function renderMarkdown(source) {
  const rawHtml = marked.parse(source);
  return DOMPurify.sanitize(rawHtml); // 危険なタグ・属性を除いてから返す
}

プレビューはエディタの横に並べる分割画面が一般的です。編集中の本文を上の関数でレンダリングして右に描くと、タイピングするそばから書式が見えます。

自動保存: タイピングが止まると保存 #

ノートアプリで「保存」ボタンを押させるのは古い体験です。実践ではタイピングが少し止まると自動で保存します。ただしキーを打つたびに保存を呼ぶと DB に過度な書き込みが殺到するので、デバウンスで最後の入力から一定の時間が経ってから一度だけ保存するようにします。

editor.js — デバウンスの自動保存
import { UpdateNote } from "../wailsjs/go/main/App";

let saveTimer;
function scheduleSave(id, title, body) {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(async () => {
    try {
      await UpdateNote(id, title, body);
      setStatus("保存しました");
    } catch (err) {
      setStatus("保存に失敗: " + err); // #5 の拒否された Promise がここへ
    }
  }, 600); // 最後の入力から 600ms
}

setStatus で「保存中… / 保存しました / 保存に失敗」を見せると、ユーザーはボタンなしでも自分の文章が安全だと分かります。保存の失敗は #5 で扱ったとおり、Go が返した拒否された Promise で捕まえて画面に知らせます。

並びの揺れを扱う #

自動保存には微妙な UX の問題が一つ隠れています。保存のたびに UpdatedAt が更新され、一覧が新しい順なので、編集中のノートが自動保存されるたびに一覧の一番上に飛び上がります。 画面が揺れ続けて気になります。

解法は二つです。一つは編集中は一覧の更新イベントを無視し、編集を終えるか別のノートに移るときだけ一覧を描き直すことです。もう一つは一覧の並びの基準を UpdatedAt ではなく CreatedAt(固定)にし、最新編集順がどうしても必要なら別の並べ替えトグルを付けることです。実践では前者、つまり編集中は一覧を凍らせる方式が自然です。

編集中は一覧の更新を先延ばしにする
let editing = false;

EventsOn("notes:changed", async () => {
  if (editing) return;         // 編集中は一覧を揺らさない
  notes = await Search(currentQuery);
});

一覧の UX: キーボードで行き来する #

メモツールはマウスなしでも素早く行き来できるべきです。一覧に上下の矢印で選択を移し、エンターでエディタにフォーカスを与えるキーボードナビゲーションを付けます。ここに空の状態(ノートが一つもないとき、検索結果がないとき)のための案内画面まで備えると、アプリが空のときも道に迷いません。

この前側の作業はフレームワークに大きく頼りません。矢印キーのハンドラで選択インデックスを移し、選択したノートをエディタに載せ、保存は上のデバウンスに渡す流れなら、バニラでも Svelte・Vue でも同じ構造です。#1 で決めた原則(データの真実は Go に置く)を守るので、前側は画面と入力だけを担えばよいです。

まとめ #

  • 編集のためにリポジトリ・サービスに Update を加え、サービスが UpdatedAt を更新します。更新後に notes:changed で一覧を描き直します。
  • マークダウンプレビューはレンダリングした HTML を必ずサニタイズ(DOMPurify など)してから挿入します。ローカルアプリでも本文はユーザー入力なので XSS を防ぎます。
  • 自動保存はデバウンスで最後の入力の後に一度だけ保存し、「保存しました/失敗」の状態を見せて保存ボタンをなくします。
  • 自動保存が一覧を一番上に飛ばす揺れは、編集中は一覧の更新を凍らせる方式で扱います。
  • キーボードナビゲーションと空の状態の案内で一覧の UX を仕上げます。前側は画面と入力だけ、データの真実は Go に置きます。
  • 次の記事では、これまで積み上げたサービスとリポジトリを実際のテストで検証します。
X