DjangoとVue.jsでMarkdownエディタ

Posted by one_meets_seven on 日 23 4月 2017

DjangoとVue.jsでプレビュー付きのMarkdownエディタ掲示板を作った

Markdown記法が好きです. 簡単なメモには重宝してますし, このブログもMarkdownで書いてます.
Markdownで記述できる掲示板をつくりたい, 誰もがそう考えたことがあると思います.
編集中にプレビューしてくれるような機能があるとなお嬉しいです(Qiitaみたいな).

サーバーサイドはPythonのフレームワークDjango,
フロントにはJavaScriptのフレームワークVue.jsを用いて
プレビュー機能をもつシングルページを実装したいと思います.

Vue.js

ローカル環境でとりあえず動かしてみる

インストールもせずにCDNで引っ張ります.
HTML, CSS, JavaScriptが動けばいいので, Pythonのhttp.serverでローカルサーバーを建てます.

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Markdown</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.2/marked.min.js"></script>
    <script src="https://unpkg.com/vue"></script>
    <link rel="stylesheet" href="markdown.css" />
  </head>
  <body>
    <div id="editor">
      <textarea :value="input" @input="update"></textarea>
      <div v-html="compiledMarkdown"></div>
    </div>
    <script src="markdown.js"></script>
  </body>
</html>

lodash, marked, そしてVue.jsのCDNを取得してきます.
肝心のmarkdown.jsはサンプル通りに書きます.

markdown.js

var editor = new Vue({
  el: '#editor',
  data: {
    input: '# 一行目'
  },
  computed: {
    compiledMarkdown: function () {
      return marked(this.input, { sanitize: true })
    }
  },
  methods: {
    update: _.debounce(function (e) {
      this.input = e.target.value
    }, 300)
  }
})

v-htmlはDOM要素の内側をHTMLとして書き換えます.
例ではcompiledMarkdown関数の戻り値です.

最後はCSSもサンプル通りに
markdown.css

html, body, #editor {
  margin: 0;
  height: 100%;
  font-family: 'Helvetica Neue', Arial, sans-serif;
  color: #333;
}

textarea, #editor div{
  display: inline-block;
  width: 49%;
  height: 100%;
  vertical-align: top;
  box-sizing: border-box;
  padding: 0 20px;
}

textarea {
  border: none;
  border-right: 1px solid #ccc;
  resize: none;
  outline: none;
  background-color: #f6f6f6;
  font-size: 14px;
  font-family: 'Monaco', courier, monospace;
  padding: 20px;
}

code {
  color: #f66;
}

Python2系の場合はSimpleHTTPServer, 3系はhttp.serverでローカルサーバーを立ち上げます.

$ python -m SimpleHTTPServer
$ python -m http.server

ブラウザでlocalhost:8000を確認してください.
簡単にマークダウンのプレビュー付きエディタができると思います.

Preview Markdown

Django

  • Django公式Tutorial
    • Djangoの公式は日本語翻訳もすべてではないですが充実しています. 公式チュートリアルではDjangoの設計方針を理解できます.
  • Python Django入門(1) -- Qiita
    • CRUD(データの作成, 読み込み, 更新, 削除)も丁寧に説明されています.
  • DjangoSnippets
    • Djangoに関してのSnippets(テクニック)集です.
    • あまり人がいなさそうですが, このサイトの醍醐味はこのサイト自体がコードをGitHubに公開しています.

あとは公式サイトのリファレンスが充実しています.

DjangoSnippetsを参考にモデル設計

DjangoSnippetsもMarkdown記述するとレンダリングしてくれる掲示板です.
app/models.pyはこんな感じになります.

from .utils import sanitize_markdown

class Post(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField()
    description_html = models.TextField(editable=False)
    pub_date = models.DateTimeField(auto_now_add=True)
    updated_date = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        self.description_html = sanitize_markdown(self.description)
        super(Post, self).save(*args, *kwargs)

    def get_absolute_url(self):
        return reverse('bbs:post_detail', args=[self.id])

Markdownで書かれる記事内容のdescriptionをHTMLに変換しdescription_htmlに保存する, というデータ構造です.

app/utils.pyにあるsanitize_markdown関数もほぼほぼDjangoSnippetsを真似しました.

def sanitize_markdown(value):
    return mark_safe(
            bleach.clean(
                markdown_func(value, extensions=['gfm'], tab_length=2),
                tags = [
                    'a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em',
                    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
                    'i', 'li', 'ol', 'p', 'pre', 'strong', 'ul',
                    'table', 'thead', 'tr', 'th', 'tbody', 'td',
                    'div', 'br', 'span',
                ],
            )
    )

Edit

editページのテンプレート. (CSSフレームワークはBootstrap)

<form class="form-horizontal" role="form" method="post" action="{% url 'bbs:post_edit' post_id=form.instance.id %}">{% csrf_token %}
  <div class="field {% if form.title.errors %}error{% endif %}">
    <label for="id_title"></label>
    {{ form.title }}
    {% if form.title.errors %}<small class="error">{{ form.title.errors|join:", " }}</small>{% endif %}
  </div>
  <div id="editor" class="field {% if form.description.errors %}error{% endif %}">
    <label for="id_description"></label>
    {{ form.description }}
    {% if form.description.errors %}<small class="error">{{ form.description.errors|join:", " }}</small>{% endif %}
    <div v-html="compiledMarkdown"></div>
  </div>
  <div class="form-group"><button type="submit" class="btn btn-primary">Save</button></div>
</form>

{{ form.discription }}の部分が, <textarea>タグに変換されるわけです.
app/forms.pyは普通こんな感じです.

class PostForm(forms.ModelForm):
    title = forms.CharField()
    description = forms.CharField(widget=forms.Textarea)

    class Meta:
        model = Post
        fields = ('title', 'description')

forms.Textareadjango/forms/widgets/textarea.htmlをテンプレとして読み込んでいることがソースからわかります.
Textarea -- Widgets | Django documention

app/templates/app/textarea.htmlという風にテンプレートファイルを作ります.

<textarea id="{{ widget.name }}" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %} v-model="input" @input="update">
{% if widget.value %}{{ widget.value }}{% endif %}</textarea>

ただ単に, v-model="input" @input="update"を挿入したかっただけです.

app/forms.pyは以下のように変更できます.

class EditorTextarea(forms.Widget):
    template_name = 'app/textarea.html'

    def __init__(self):
        super(EditorTextarea, self).__init__()

class PostForm(forms.ModelForm):
    title = forms.CharField()
    description = forms.CharField(widget=EditorTextarea)

    class Meta:
        model = Post
        fields = ('title', 'description')

もともとある記事を編集する場合, さきほどのjsだとinputが毎回初期化され,
<text area>DOMの中身(テキストエリアの中)も消えてしまいます.
そこで以下のように書き換えます.

var editor = new Vue({
  el: '#editor',
  data: {
    input: null
  },
  created: function(){
    this.input = $('#description').text();
  },
  computed: {
    compiledMarkdown: function () {
      return marked(this.input, { sanitize: true })
    }
  },
  methods: {
    update: _.debounce(function (e) {
      this.input = e.target.value
    }, 300)
  }
})

jQueryを使ってDOM要素(そのためにid="description"を指定しました)をinputに保持させます.

所感

JavaScriptのフレームワークに挑戦みたかったので, Vue.jsを触ってみました.
React.jsも気になってはいるのですがテンプレートっぽく気軽にかける(いまのところ)Vue.jsのほうが
敷居が低そうだったので選びました. Markdownエディタのプレビューを実装するにはちょうどよさそうです.

こんな感じで, ツリー形式のコメントやVote機能をとりつけて, Redditみたいな掲示板を構想しています.

tags: Django, Vue.js


Comments !