Cloud Storage for Firebase とmavonEditorでQiitaのような画像アップロード機能つきマークダウンフォームを作成しよう!

#はじめに

フロントエンドエンジニア1年生の私が、QIitaやGithubのようなマークダウンフォームをつくったときに苦労したので記事にしてみました!

# 今回紹介する機能の概要

- キータのように画像を添付したブログ記事を投稿する
- ドラックアンドドロップで画像アップロードができる
- ブログ詳細画面に投稿内容を表示する


#完成イメージ
https://gyazo.com/92e8aed8c731cd3af71c5d29eac11bba

### 新規投稿
![スクリーンショット 2021-12-05 21.16.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/750557/598ba9d0-b29b-83a7-85c5-b329f2da4004.png)

### 詳細画面
![スクリーンショット 2021-12-05 21.16.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/750557/047fb701-b43b-3f1e-9875-7604ccddf628.png)


#使用技術
### フロントエンド
- Nuxt.js
- CompositionAPI
- Typescript
- [ mavonEditor](https://github.com/hinesboy/mavonEditor)
...バージョンは "mavon-editor": "^2.9.1"

### バックエンド
- [ Cloud Firestore](https://firebase.google.com/docs/firestore)
- [ Cloud Storage for Firebase](https://firebase.google.com/docs/storage?hl=ja)


#実装コード

```form.vue
<template>
  <form class="form-area" @submit.prevent="handleSubmit(onSubmit)">
    <label class="label">Content </label>
    <div class="drop_area" @dragover.prevent>
      <div class="markdown-editor">
        <no-ssr>
          <mavon-editor
            v-model="form.content"
            :toolbars="markdownOption"
            language="ja"
            @imgAdd="imgAdd"
          />
        </no-ssr>
      </div>
    </div>

    <div class="submit">
      <input type="submit" title="投稿する" value="Submit!" />
    </div>
  </form>
</template>
<script>
import { defineComponent, ref } from '@nuxtjs/composition-api'
import { firestore } from '@/plugins/firebase'
export default defineComponent({
  setup() {
    const form = ref({
      id: '',
      user_id: currentUser.uid, // ログインユーザーのID
      content: '',　　// マークダウンで投稿する内容
      created_at: timestamp(new Date()), // firestoreのtimeStamp型に変換しています
      updated_at: timestamp(new Date()),
      user: { ...currentUser },
      files: [],　　　// FirebaseCloudStorageに格納したfile情報を格納する配列
    })
    // NOTE:マークダウンの設定
    const markdownOption = {
      bold: true,
      italic: true,
      header: true,
      underline: true,
      strikethrough: true,
      mark: true,
      quote: true,
      ol: true,
      ul: true,
      link: true,
      imagelink: true,
      code: true,
      table: true,
      fullscreen: false,
      htmlcode: true,
    }
    // 画像選択時に発火します。
    const imgAdd = (filename: string, imgfile: File) => {
      const fileData = {
        file: imgfile,
        fileName: imgfile.name,
        content: form.value.content,
      }
      fileChanged(fileData)
    }

    // 画像選択時に発火します。
    const fileChanged = async (file: any) => {
      const id = uuidv4()
      try {
        const url = await store.dispatch('auth/uploadFile', {
          file,
          id,
        })
        const reg = new RegExp('\\([.\\d]+?\\)', 'g')
        form.value.content = file.content.replace(reg, `(${url}})`)
        form.value.file = [...form.value.file, { id, url }]
      } catch (error) {
        store.dispatch('auth/onRejected', error)
      } finally {
        isLoading.value = false
      }
    }

    const onSubmit = () => {
      try {
        const deleteFiles = JSON.parse(JSON.stringify(form.value.files)).filter(
          (v: FileArray) => !form.value.content.includes(v.url)
        )
        // NOTE:一度アップロードしたが、削除てしまったファイルがあればstorageから削除
        if (deleteFiles.length) {
          await deleteFiles.map((file: FileArray) => {
            const id = file.id
            store.dispatch('auth/deleteFile', {
              id,
            })
          })
        }
        form.value.files = form.value.files.filter((file: FileArray) =>
          form.value.content.includes(file.url)
        )
        const id = uuidv4()
        form.value.id = id
        firestore.collection('posts').doc(id).set(form.value)
        Router.push('/')
      } catch (error) {
        store.dispatch('auth/onRejected', error)
      }
    }
    return {
      form,
      imgAdd,
      markdownOption,
      onSubmit,
    }
  },
})
</script>


```


```store/auth/actions.ts 
import { ActionTree } from 'vuex'
import { RootState, AuthType } from '../types'

import { storage } from '~/plugins/firebase.js'

const actions: ActionTree<AuthType, RootState> = {

  uploadFile: ({ commit, dispatch }, payload) => {
    return new Promise((resolve) => {
      try {
        const file = payload.file
        const ref = `public/${payload.id}`
        storage
          .ref(ref)
          .put(file.file)
          .then((uploadTask) => {
            storage
              .ref(uploadTask.ref.fullPath)
              .getDownloadURL()
              .then((url) => {
                resolve(url)
              })
          })
      } catch (error) {
        dispatch('onRejected', error)
      }
    })
  },
 deleteFile: ({ commit, dispatch }, payload) => {
    return new Promise((resolve) => {
      try {
        const ref = `public/${payload.id}`
        storage.ref(ref).delete()
        resolve(null)
      } catch (error) {
        dispatch('onRejected', error)
      }
    })
  },
}
```
#実装内容の解説
##mavion-editorを導入しよう！
- [こちらの記事](https://www.kabanoki.net/3452/)を参考にすれば、mavion-editorを導入できます!詳しいoptionの内容とかも詳しく書かれているのでとても参考になると思います！

- ` @dragover.prevent`のついたdivで括ると、ドラックアンドドロップで画像アップロードができるようになります！




##画像選択時の処理を書こう！

画像選択時にイベントから、`filename: string, imgfile: File`を取得し,fileChangedメソッドを呼び出しています！
fileChangedメソッドの解説をしていきます!

### 1 FirebaseCloudStorageに画像をアップロードし、URLを取得する。

```vue.js(fileChangedメソッド)
   const url = await store.dispatch('auth/uploadFile', {
        file,
        id,
      })
```
```vue.js(dispatchしてる関数)
  uploadFile: ({ commit, dispatch }, payload) => {
    return new Promise((resolve) => {
      try {
        const file = payload.file
        const ref = `public/${payload.id}`
        storage
          .ref(ref)
          .put(file.file)
          .then((uploadTask) => {
            storage
              .ref(uploadTask.ref.fullPath)
              .getDownloadURL()
              .then((url) => {
                resolve(url)
              })
          })
      } catch (error) {
        dispatch('onRejected', error)
      }
    })
  },
```

### 2 画面上にURLを表示する
mavion-editorのデフォルト
![スクリーンショット 2021-12-05 23.47.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/750557/dd60035d-76d9-a1a8-23f6-16824c4b77c6.png)

画像URLをmavion-editor上に表示する

![スクリーンショット 2021-12-05 21.16.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/750557/beb509fa-2665-b5fa-9a38-f75781a7e4ab.png)

mavion-editorのデフォルトだと上記画像のように、プレビュー表示は簡単にできる！しかし、アップロードした画像の情報は、
`[スクリーンショット 2021-12-05 23.47.00.png]`という形の文字列として、
v-modelでバインドしている要素(今回であれば、form.value.content)に格納されるだけの処理となっている。そのため、このまま投稿処理をしてしまうと、投稿した画像を表示することができません！



なので、今回は下記のようにfileChangedメソッド内にて、form.value.filesの要素にFirebaseCloudStorageのパスをfireStore格納する用のデータに追加をしています。
また、qiitaやgithubのマークダウンフォームのように画像アップロードすると画像URLが表示されるようにしています。

```vue.js
//正規表現で![404.png](1)の形の文字列を排除し、代わりにFirebaseCloudStorageのurlを格納している
      const reg = new RegExp('\\([.\\d]+?\\)', 'g') 
      form.value.content = file.content.replace(reg, `(${url}})`)
     form.value.file=[...form.value.file, { id, url }]
```




##投稿ボタン押下時の処理を書こう！

投稿処理は、下記のonSubmitメソッドを呼び出します！
onSubmitメソッドの解説をしていきます!

### 一度アップロード済みの画像をマークダウンエディタ上で削除した場合の処理をする
form.value.filesとform.valye.contentを比較し、一度アップロード済みの画像をマークダウンエディタ上で削除した場合の処理をしています。一度アップロード済みの画像をマークダウンエディタ上で削除した場合は、画面上削除されていてプレビューには映らないが、FirebaseCloudStorageには保存がされてしまっている状態なので、一度アップロードしてから削除した画像はFirebaseCloudStorageから削除する処理をしています。

```vue.js
     const deleteFiles = JSON.parse(JSON.stringify(form.value.files)).filter(
          (v: FileArray) => !form.value.content.includes(v.url)
        )
        // NOTE:一度アップロードしたが、削除てしまったファイルがあればstorageから削除
        if (deleteFiles.length) {
          await deleteFiles.map((file: FileArray) => {
            const id = file.id
            store.dispatch('auth/deleteFile', {
              id,
            })
          })
        }
        form.value.files = form.value.files.filter((file: FileArray) =>
          form.value.content.includes(file.url)
        )

```
###投稿内容をfireStoreにPostする

```vue.js
firestore.collection('posts').doc(id).set(form.value)
```

## ローディング処理を追加しよう
動画では画像アップロード処理時にローディングを追加している。
これは、fileChangedメソッド中に下記を記載し、
`フォームとプレビューをDOM操作で非表示にする+loadingコンポーネントをposition:absoluteでmavion-editorに重ねる`ことで、動画のような動作を実現しています！

```vue.js
   document
        .querySelector('.auto-textarea-input')
        ?.classList.add('-hidden')
   document.querySelector('.v-note-show')?.classList.add('-hidden')
```




## 詳細画面で投稿内容を表示しよう
マークダウンで投稿した内容はmavion-editorをかまさないと表示できないので、下記のようにして表示画面を作成しています!

```vue.js
<template>
  <div class="markdown-editor">
    <no-ssr>
      <mavon-editor
        v-model="content"
        language="ja"
        :subfield="false"
        :editable="false" // 追記
        :toolbars-flag="false"　" //追記
        :box-shadow="false"
        default-open="preview"
        preview-background="#fff"
      />
    </no-ssr>
  </div>
</template>

```

#終わりに
mavion-editorは導入しやすくて、超おすすめです!
この記事が困っている誰かの役に立てたら最高でしかないですね！