Chienomi

Markdownで書いたメモを共有できるウェブアプリ

開発::webapp

「Markdownで書いた文書をシェアしたい」というのはかなり昔から思っていた。

私は人に教えたり説明したりの機会が多く、章立てや箇条書き、表が欲しいことはよくある。 これまでそういうときはMarkdownで書いてPandocで生成、自分のウェブサイトのpublicでないアドレスにアップ……みたいなことをしていたのだが、シンプルにめんどくさい。

そもそもありそうなものだけれど、長文私信をシェアするというもの自体がなくて、Markdownとなるととてもとても。

なければ作ればいい、ではあるのだけど、需要があるのが自分で、しかも自分には対応策がある、ということで2018年に「作りたい」と言い始めてからはや7年。

ついに作ったのである。 それがMDBoardだ。

アプリの紹介

どういうアプリ?

Markdownで書けるシンプルなエディタがあり、保存して読む用URLをシェアすれば読んでもらうことができる。

書いた文書はURLでしかアクセスできないものなので、いわゆる「限定公開」になる。 基本的に長い、もしくは複雑な私信向け。

しゃれたUIはないが非常に軽い。

入力補助パレットがあり、記号の入力しづらいスマホ環境や、Markdownうろ覚えの人でも書きやすい。

使い方

トップページからWriterのリンクで編集画面に行くことができる。 このとき、自動的にURLは編集ページのリンクになる。

このURLはその文書に固有のものなので、メモしておくなりブックマークしておくなりでいつでもその編集に戻れる。 逆にいうと、このURLは共有してはいけない。

saveボタンは現在の編集内容をサーバーに送信・保存する。

previewボタンは表示の編集とプレビューを切り替える。

M↓ボタンはMarkdown入力パレットに切り替える。

shareボタンは文書を読むためのURLを表示する。 これをシェアすることで、読んでもらうことができる。

リスト入力中は改行で自動的にリストを継続したりする。

注意事項

トップページにある通り、あくまで実験的なもの。

禁止事項には注意すること。

フィードバック送信

タイトルにfeedbackを含めて保存すると、フィードバックとして伝わる。

技術的な話

構成

クライアントはかなりシンプルなSPA。Vanilla JavaScript製なのはいつもどおり。 通信を発生させるのは「ロード時」「プレビュー時」「セーブ時」。

サーバーはこの3つに加え、新規ドキュメント生成に対応した計4つのAPIを持つ。

仕組み的には新規ドキュメントを生成してそのままドキュメントを設定して編集に入ることもできるのだけど、そもそもドキュメントの固有URLがあればlocation.hrefで置き換えてしまえば復帰と同じ流れに持ち込めるため、新規ドキュメント生成時はクライアントアプリは処理を途中で投げ捨ててページ移動する。

サーバーは保存時に読む用のHTMLを生成して別に保存する。 ここで静的生成しているため、読むときは単純にHTMLファイルへのアクセスになる。 このあたりは、私が大好き事前生成戦略が顔を出す。

最初はリーダーアプリも作っていたのだけど、結局この方式が楽でサーバー負荷も少ない。

サーバーで生成する関係で、プレビューもサーバーで生成しないと齟齬が出る。 これもプレビューが切り替え式になっている理由のひとつ。

横並びプレビューが嬉しいかどうかは強く環境に依存するし、スマホだと絶対にいらないので、今回の場合明らかに切り替え式のほうが使い心地は良い。

SPAは古典的な構成から書いてるうちにだんだん現代的になった。

SPAコンポーネントとリーダーページはNginxの配布、サーバーアプリはSinatra。

サーバーアプリ

サーバーアプリの中身は非公開だが、ほとんど構成の話で完結している。

Sintra/Pumaで動作するアプリケーションサーバーだ。 4つのAPIを持ち、保存時は送信データを保存すると同時に、読む用のHTMLページを生成する。

Markdownの取り扱いはRedcarpetを用いている。 多少重いが、今回の用途には最適だった。

データベースはJSONのファイル保存だが、本格的に運用する場合はトランザクションがめんどくさいため他の形式に変えるだろう。適性が高いのはLMDB。

Markdown補助

まず重要な知識が、textareaにフォーカスした状態でtextarea.selectionEndでカーソル位置がとれるということ。

そして、スライスを使って、textarea.value.slice(0, textarea.selectionEnd)でカーソルの手前がとれるし、textarea.value.slice(textarea.selectionEnd, undefined)でカーソルの後ろが取れる。

例えば今のカーソル位置に()を挿入した上でカーソルをひとつ右(つまりカッコの中)に動かす場合は

const pos = textarea.selectionEnd
const str = textarea.value
const before = str.slice(0, pos)
const after = str.slice(pos, undefined)
const addition = "()"

textarea.value = [before, addition, after].join("")
textarea.selectionEnd = pos + 1

となる。

一方、ブロック要素の場合は独立した段落に入力する必要がある。 これをアシストしたい場合、単純に手前に\n\nをいれれば絶対に独立したブロックになるが、ちょっと見栄えの悪い動きになるので、beforeの終端が\n\nなら現在独立した段落におり、\nだけなら行頭にいるが独立した段落ではないと判断できる。

実際は冒頭の処理であったり、後ろに改行を入れるべきかどうかの判断だったりということもあったりするのだが、あんまり需要がない上に好みの分かれるところでもあるため、段落の判断を簡単に行うだけにしている。

入力内容は押されたボタンのvalueからobjectのプロパティを参照する形式。 挿入する文字列、移動すべき量、ブロックかどうかの値を持っている。

リストの入力補助

これはちょっと厄介な要素。

inputのイベントを見ることで入力されたことは分かるが、改行はInputEvent.prototype.datanullになるため、判断できない。

このため、inputイベントが発生するたびに現在のtextareaの量を確認している。

nullが発生する状況は

  • null扱いになる文字が入力された
  • 実際には何も入力していない
  • 文字を消した

の3パターンがある。 そして、それらはそれぞれ

  • 文字数が増える
  • 文字数は変わらない
  • 文字数は減る

となるため、文字量を見ることでどのパターンであるかを特定できる。

そして文字数が増えたとき、例外が全くないわけではないが、基本的にはカーソル位置の手前の最後の文字が入力した文字である。 カーソル位置の手前はtextarea.value.slice(0, textarea.selectionEnd)で取ることができるから、

textarea.value.slice(0, textarea.selectionEnd).slice(-1)

で最後の文字がとれる。 そして、最後の文字が\nであったならば、入力したのは改行だろうと判断できる。

入力補助は最後の行全体を見たいため、カーソル手前文字列を改行でsplitして後ろから2番目を見る。 最後でないのは、改行を入力した直後であるため最後は空だからだ。

文字量をカウントするのは重たい処理に思えるかもしれないが、keydownイベントを見るよりは100倍マシで、なおかつIMEによる誤動作も避けられる。

IMEの誤動作を避けられるのは編集中の文字がイベントに入ってこないという意味ではなく、編集中の状態ではtextareaの文字量が変化しないからだ。 編集中の文字はイベントを起こしたり起こさなかったりする。

メッセージボックス

毎回ベストプラクティスがわからなくて悩まされるメッセージボックス。

最も簡単なのは非表示ボックスと表示ボックスのクラスを作り、非表示ボックスのCSSにtransitionをかけることだが、実はJavaScriptで一連の流れの中でクラスを変えて戻すと、「そもそも変更しない」という形に最適化されてしまう。

このため、setTimeout()を使ってクラスを戻すのを少しディレイさせている。

もっと簡潔に実装できると良いのだけど。

通信部分

fetchwrapperを使っている。

今回バグを見つけたのでちょっと直した。

初期化失敗時

document.body.innerHTMLに直書きする力技。

すべて書き換えたいのでこのようにした。

完走の感想

ぶっちゃけ、意外と時間かかった。 10時間くらい?

使い勝手は結構いいと思う。 個人的には重くなるくらいならUI/UXは簡素で良い(使いやすい前提で)と考えているから、割と理想的。 というかもっとブラウザの部品を使うべきだし、それによってブラウザの部品のデザインが改善されるべき。

サーバーとの通信部分が若干ラグいのはCloudFlareを噛ませているからだろうか。 ローカルだとめちゃくちゃ速い。 この関係で、使い勝手の感覚はローカルテスト時とちょっと変わってしまっている。

ただまぁ、そんなに頻繁にプレビューするものでもないし。

かなり真面目に簡潔さを追い求めたが、ベストではないにせよ結構うまくいったとは思う。

コード自体をより簡潔に書くこと(bum)は可能だけれど、こういうものの場合「手早く書く」ことも重視したいので、「本当はもっとこうしたほうがいいな」を100%やるわけではない。 簡潔に書くことには「早い」「ミスが混入しにくい」「忘れた頃に把握しやすい」といったメリットがあり、それらのバランスのとり方が大事。完成した状態から見て「こうのほうがよかった」は、手を加えるときにそのことを覚えておいて反映していくのがいい。  あとは「気になるならやる」。