Vueで小説投稿サイトのフォームを改良した


## tl;dr

* Vue.jsを使って小説投稿サイトのフォームを便利にした
* 独自記法のプレビュー機能を作った

## 独自記法のプレビュー機能

![denkinovel_preview.gif](https://qiita-image-store.s3.amazonaws.com/0/5960/cf3c0b9e-6d5a-7582-12cd-baab20566356.gif "denkinovel_preview.gif")

こういうの。

### 作り方

本文のtextareaにv-modelを指定しておき、v-onのclickとkeydown, keyup, keypressにプレビュー表示を更新するメソッド（本件ではupdatePreview）を指定しておく。

なお本件での独自記法は、[bg red]や[bg sky]のような文字列を見つけると、「背景記法」だと判断するというシンプルなもの。

またプレビューは「[bg red]のようにデフォルトで用意してある背景画像名」「ユーザーがアップデートした画像」「URLを直接指定した画像」の3種類の画像を表示できる。が、基本的なVueを使っての作りは同じ。


### 作り方


HTML（erbだけど）のほうには

```
    <%= f.text_area :body, rows:20, id:'js-storyForm__body', 'v-model' => 'body', 'v-on' => 'click: updatePreview, keydown: updatePreview, keyup: updatePreview, keypress: updatePreview' %>
    
```

と書いておいて、JS（CoffeeScriptだけど）のほうには以下のように記述しておく。


```javascript:form.js.coffee
class Storyblog.Vue.Stories.Form
  createVue: ->
    @vm = new Vue(
      el: '#js-storyForm'
      data:
        currentTitle: ''
        currentTableType: 'buttonAndCode'
        currentTagType: ''
        currentNotices: []
        currentItems: []
        body: ''
        tags: []
        preparedPictures: []
        currentUserID: null
        isActivePreview: false
        isLoading: false
        toggleActivatePreviewButtonText: 'リアルタイムプレビューをONにする'
        filenameURL: {}
       
      methods:
        initialize: (e) ->
          @currentTitle = @$data['basicInstruction'].title
          @fillContent('basicInstruction')
          @getCurrentUserID()

        renderPreview: (e) ->
          @changeInstruction(e)
          @updatePreview(e)

        updatePreview: (e) ->
          return unless @isActivePreview
          re = /\[.*?\]/g;
          tags = @body.match(re)
          tagArtRe = /\[(\S+?)\s+(.+?)\]/;
          updatedTags = []
          _.each(tags, (tag) ->
            tagArgArray = tag.match(tagArtRe)
            return unless tagArgArray
            return if tagArgArray.length < 2
            funcName = tagArgArray[1]
            tagArg = tagArgArray[2]
            isBG = false
            if funcName == 'bg'
              isBG = true
            isImage = false
            if funcName == 'image'
              isImage = true
            isTextColor = false
            if funcName == 'text-color'
              isTextColor = true
            isFilter = false
            if funcName == 'filter'
              isFilter = true
            updatedTags.push({ 'whole':tag, 'funcName': funcName, 'arg':tagArg, 'isBG': isBG, 'isImage': isImage, 'isTextColor': isTextColor, 'isFilter': isFilter ,'url': null})
          )
          @tags = updatedTags
          @fetchFromPreparedPictures()
          @updateImagesURLs()
          @setImageURLToTags()

        setImageURLToTags: ->
          self = @
          _.each(@tags, (tag) ->
            if tag['isBG'] or tag['isImage']
              url = self.filenameURL[tag['arg']]
              if url
                tag['url'] = url
              else
                tag['url'] = tag['arg']
          )

        updateImagesURLs: ->
          self = @
          _.each(@tags, (tag) ->
            if tag['isBG'] or tag['isImage']
              self.setImageURLWithPreparedPictures(tag['arg'])
              self.fetchFromCurrentUserPicturesAndSetImageURLWithFilename(tag['arg'])
          )

        getCurrentUserID: ->
          @currentUserID = parseInt($('#js-currentUserID').text())

        fetchFromCurrentUserPicturesAndSetImageURLWithFilename: (filename) ->
          self = @
          return unless @currentUserID
          return if self.filenameURL[filename]
          $.ajax({
              type: 'GET'
              datatype: 'json'
              url: "../../users/#{self.currentUserID}/picture.json",
              data: {
                filename: filename
              }
              success: (image_info) ->
                unless image_info
                  self.filenameURL[filename] = filename
                else
                  file_path = image_info['file_path']
                  if file_path
                    url = file_path['image']['thumb']['url']
                    self.filenameURL[filename] = url
              error: (data) ->
                console.log data
            }
          )
        setImageURLWithPreparedPictures: (filename) ->
          self = @
          return unless self.preparedPictures
          url = self.preparedPictures[filename]
          self.filenameURL[filename] = url if url

        fetchFromPreparedPictures: () ->
          self = @
          return if self.preparedPictures.length > 0
          $.ajax({
              type: 'GET'
              datatype: 'json'
              url: "../../prepared_pictures.json",
              success: (data) ->
                self.preparedPictures = data.prepared_pictures
              error: (data) ->
                console.log data
            }
          )

        toggleActivePreview: (e) ->
          if @isActivePreview
            @toggleActivatePreviewButtonText = 'リアルタイムプレビューをONにする'
            @isActivePreview = false
          else
            @toggleActivatePreviewButtonText = 'リアルタイムプレビューをOFFにする'
            @isActivePreview = true
    )
    @vm.initialize()


Vue.component('previewItem', {
  template: '''
<td><code>{{whole}}</code></td>
<td>
<img class="previewItem__image" v-attr="src:url" v-if="isBG"/>
<img class="previewItem__image" v-attr="src:url" v-if="isImage"/>
<span v-if="isTextColor" v-style="color:arg">文字色</span>
<span v-if="isFilter" v-style="background-color:arg">フィルター</span>
</td>
'''
})

```

ちなみに、本文から独自記法を正規表現で取得しているのだけど、毎アクションごとに検索をかけるとパフォーマンス的に問題があるので、リアルタイムプレビューはOFFにできるようにしておくほうがいい

## 補足

Vueの便利なところはComponentというViewとVMを凝集度高く記述できる仕組みにあると思っている。上記のプレビュー機能だけだとcomponentはあまり効果的に動いていないが、プレビューで表示したアイテムをクリックするとライトボックスにして詳細表示、という風に、処理とViewが密接に関係する部分があるとComponentが大活躍する。