[ jQuery, PHP ] ライブプレビュー付Markdownエディタを作る

前回、前々回で、「テキストエリア以外でも、テキスト選択をする」「ライブプレビュー付のHTMLエディタを5行で作る」という記事を書きましたが、ほんとにやりたかったことは、これです。「ライブプレビュー付のMarkdownエディタを作る」。

すでにオンラインのMarkdownエディタはいくつもありますが、超絶簡単でカスタマイズが簡単なものが欲しかったので自作しました。といっても、ほとんど何もしていませんが。

ちなみに、自分が欲しかった機能は、こういう感じです。

  • ブラウザで動く
  • Markdownで書ける
  • HTMLも書ける
  • ライブプレビュー
  • HTMLのプレビューもコードも見たい
  • ローカルで(PHPを使って)動かす

一方、自分には不要なもの、できなくてもいいものは、次の通りです。

  • ファイルへの保存
  • ブログ等への投稿
  • ローカル以外で動かす(セキュリティ関連は無視)

以上を踏まえて、作っていきたいと思います。

基本機能の作成

HTMLを書いて単に表示するだけなら、「ライブプレビュー付のHTMLエディタを5行で作る」でできています。Markdownで書けるようにするためには、何か変換してくれるものが必要です。ここでは、「php Markdown」を利用します。Libraryをダウンロードします。上のサイトにも使い方は書いていますが、ここでは次のように使います。超シンプルです。

<?php

require_once './Michelf/MarkdownExtra.inc.php';
use Michelf\MarkdownExtra;
echo MarkdownExtra::defaultTransform( $_POST['msg'] );

これをedit.phpという名前で保存し、この階層に先ほどのLibraryを置いておきます。これで、このphpにmsgという値を投げたら、変換した値が返るようになりました。ローカルでしか使わない前提なので、特に値のチェックはしていません。なお、もしMarkdownExtraではなくノーマルなMarkdownを使いたければ、3か所のExtraを削除すれば使えます。MarkdownExtraの解説は、(英語ですが)上のライブラリにもありますし、日本語だと「開発などブログ » PHP Markdown Extra 仕様の全訳(意訳)」がいいと思います。

次に、edit.htmlを作っていきます。実際のエディタです。textareaの値を上のedit.phpに投げ、返ってきた値をプレビューに反映する、というのがメインです。「$(function(){ 」と「 });」の間に、次のように書いていきます。

  $("textarea").keyup( function(e) {
    $.post(
      "edit.php"
      , { msg: $("textarea").val() }
      , function(r){
          $("div").html(r);
        }
    }
  });

なお、htmlはこれです。

  <textarea></textarea>
  <div></div>

基本はこれだけです。textareaの中身をmsgに代入してphpにpostし、返ってきた値をdivに反映する。これだけで基本的には終了です。

機能の追加

基本的には上でおわりなのですが、欲しい機能を追加するためにもう少し手を加えます。

返ってきた値を、htmlでもコードでも表示したいので、プレビュー画面を切り替えられるようにします。まず、html部分は次のようにします。

  <textarea id="input"></textarea>
  <div id="result">
    <div id="header">
      <span class="html">html</span>
      <span class="raw">raw</span>
    </div>
    <div id="preview">
      <div class="html"></div>
      <pre class="raw hidden"></pre>
    </div>
  </div>

なお、hiddenクラスには、display:noneを設定し、非表示する予定です。さて、div#headerにあるhtmlとrawをクリックして、div#previewを切り替えられるようにします。hiddenクラスの追加・削除で実装します。また、例えばrawが表示されているときにさらにrawをクリックすると、preview内のテキストが選択できるようにしておきます。これは、「テキストエリア以外でも、テキスト選択をする」でやったとおりの内容です。下では、selectElementsという関数で実装しています。

  $("#header .html").click(function(){
    if ( $("#preview .html").hasClass("hidden") ) {
      $("#preview .html").removeClass("hidden");
      $("#preview .raw").addClass("hidden");
    } else {
      selectElements();
    }
  });

  $("#header .raw").click(function(){
    if ( $("#preview .raw").hasClass("hidden") ) {
      $("#preview .raw").removeClass("hidden");
      $("#preview .html").addClass("hidden");
    } else {
      selectElements();
    }
  });

  selectElements = function(){
    var element = document.getElementById("preview");
    var range = document.createRange();
    range.selectNodeContents(element);

    var selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
  };

続いて、phpから返ってきた値の処理も書き直します。ついでに、keyupのときに、textareaのデータが変わっていたときだけpostするように変更しておきます。というのは、keyupは十字キーなどでもイベントが発生してしまうのですが、そのたびにpostするのは無駄だからです。まぁ、ローカルでしか使わないなら、特に気にすることはないかもしれませんけど。

  $("#input").data("old", function() {
    return this.value;
  }).keyup( function(e) {
    if ( this.value !== $(this).data("old") ) {
      $.post(
        "edit.php"
        , { msg: $("#input").val() }
        , function(r){
            $("#preview .html").html(r);
            $("#preview .raw").html(r.replace(/</g,"&lt;").replace(/>/g,"&gt;"));
          }
      );
      $(this).data("old", this.value);
    }
  });

data()を使って、値をoldの中に一時的に保持しています。keyupのたびに、textareaとこのoldの中身を比べて、値が違っていたらpostを実行する、としています。また、「#preview .raw」に書き出すデータは、htmlタグを変換し、タグも表示できるようにしています。

以上をあわせ、個人的に好きな設定を反映させたものが下になります。

<!DOCTYPE html>
<html>
<head>
<title>Simple Markdown Editor</title>
<meta charset="UTF-8">
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<style>
html, body{
  margin: 0;
  padding: 0;
}
html, body, #input, #result {
  height: 100%;
  font-family: 'メイリオ', Meiryo, 'MS Pゴシック', sans-serif;
}
#input, #result {
  overflow-y: scroll;
  resize: none;
  float: left;
  width: 50%;
  box-sizing: border-box;
  line-height: 1.75em;
  letter-spacing: 1px;
}
#input {
  padding: 20px;
  font-size: 16px;
}
#result {
  word-wrap: break-word;
  background-color: #f0f0f0;
  font-size: 14px;
  padding: 0;
}
#header {
  position: fixed;
  right: 30px;
  background-color: #ccc;
  padding: 10px;
  font-size: 10px;
  line-height: 1em;
}
#header span {
  padding: 2px 5px;
  margin: 0 5px;
  border: 1px solid #666;
  cursor: pointer;
}
#preview {
  padding: 30px 10px 250px 20px;
}
.hidden {
  display: none;
}
</style>
</head>
<body>
  <textarea id="input"></textarea>
  <div id="result">
    <div id="header">
      <span class="html">html</span>
      <span class="raw">raw</span>
    </div>
    <div id="preview">
      <div class="html"></div>
      <pre class="raw hidden"></pre>
    </div>
  </div>
<script>
$(function(){
  $("#input").data("old", function() {
    return this.value;
  }).keyup( function(e) {
    if ( this.value !== $(this).data("old") ) {
      $.post(
        "edit.php"
        , { msg: $("#input").val() }
        , function(r){
            $("#preview .html").html(r);
            $("#preview .raw").html(r.replace(/</g,"&lt;").replace(/>/g,"&gt;"));
          }
      );
      $(this).data("old", this.value);
    }
  });

  $("#header .html").click(function(){
    if ( $("#preview .html").hasClass("hidden") ) {
      $("#preview .html").removeClass("hidden");
      $("#preview .raw").addClass("hidden");
    } else {
      selectElements();
    }
  });

  $("#header .raw").click(function(){
    if ( $("#preview .raw").hasClass("hidden") ) {
      $("#preview .raw").removeClass("hidden");
      $("#preview .html").addClass("hidden");
    } else {
      selectElements();
    }
  });

  selectElements = function(){
    var element = document.getElementById("preview");
    var range = document.createRange();
    range.selectNodeContents(element);

    var selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
  };
});
</script>
</body>
</html>

以上が完成形です。phpでは、値の確認をしていませんので、ローカル以外では使わないようにしてくださいね。

Markdownの変換ロジックを変えたければ、phpで別のライブラリを使うなどをすればいいし、エディタの見た目を変えたければcssをいじるだけでOKです。単純なベース部分しか作っていないので、いろいろ変更しやすいんじゃないかなと思います。

前の記事:
[ jQuery ] ライブプレビュー付のHTMLエディタを5行で作る
次の記事:
Androidアプリ公開手順まとめ 2015