10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

アルサーガパートナーズAdvent Calendar 2021

Day 7

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

Last updated at Posted at 2021-12-06

#はじめに

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

今回紹介する機能の概要

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

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

新規投稿

スクリーンショット 2021-12-05 21.16.15.png

詳細画面

スクリーンショット 2021-12-05 21.16.37.png

#使用技術

フロントエンド

  • Nuxt.js
  • CompositionAPI
  • Typescript
  • mavonEditor
    ...バージョンは "mavon-editor": "^2.9.1"

バックエンド

#実装コード

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を導入しよう!

  • こちらの記事を参考にすれば、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

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

スクリーンショット 2021-12-05 21.16.15.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は導入しやすくて、超おすすめです!
この記事が困っている誰かの役に立てたら最高でしかないですね!

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?