Nuxt.jsでvue-pdfを使ってPDFスライドを実装

[こちらの記事](https://qiita.com/Dai_Kentaro/items/2accfa8cc7d6bb4cda16)の個人開発サービスで、PDFスライド機能を実装しました。

https://www.slide-commenter.com/

個人開発第一弾の記事を書いていましたが、続編で技術記事を書くと言っていたので、書きました。


# 作りたい機能の要件
- PDFファイルをセットしてプレビュー表示できる
- プレビューページをキーボードの矢印キー等でスライド形式でswipeできる
- 画面に表示している←→ボタンをクリックしてもページをswipeできる
- PDFファイルはストレージに保存せず、セットするだけでswipe可能にしたい（ストレージに保存されないことでセキュリティーリスクをなくす&工数削減）
- PDFプレビューは全画面表示もできる（この記事では触れない）

# バージョン
- yarn v1.22.19
- nuxt v2.15.8
- Composition API v1.6.3
- Vue v2.6.14
- vuetify v2.6.1

# 使うライブラリ
- 今回はvue-pdfというライブラリを使用
- "vue-pdf": "4.2.0"を使用
- https://github.com/FranckFreiburger/vue-pdf

# 設定関連
- README.md通りに実装してもうまくいかないところもあるが、基本はREADME通り。
- Nuxt.jsにvue-pdfを導入するときに少しREADMEの設定を修正する必要がある

## 使用するAPI (コンポーネント)
https://github.com/FranckFreiburger/vue-pdf#api

exampleにあるように以下のような感じで、　用意されたコンポーネントにパラメーターやイベントを渡してあげて使用すると、コンポーネントがPDFスライドを実現してくれる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/b6d69e19-1fe8-7d64-4629-1bb02c55383d.png)

## vue-pdfが用意しているコンポーネントに渡すprops
https://github.com/FranckFreiburger/vue-pdf#props
今回使ったものは以下。

- :src 
    - PDFデータのURL以外にもオブジェクトも渡すことができる（今回はオブジェクトを渡すことでPDFをストレージに保存したりしなくて済むようにした）-
- page 
    - 表示しているページ番号

## ライブラリを使用することで使えるevent
https://github.com/FranckFreiburger/vue-pdf#events
上記を参考に。

イベントの受け取りはREADMEの通り、``$event``変数を使って受け取る。
https://github.com/FranckFreiburger/vue-pdf#examples

- 今回使ったイベント
    - @num-pages: 指定された pdf のすべてのページの合計。PDFをセットした時に総枚数が何枚なのか表示するのに使った

その他はREADMEをご参考に

## vue-pdfをinstall

```bash
$ yarn add vue-pdf
```

依存関係としては以下だが、注意点として、Nuxt.jsで使う場合は、バージョンを以下で揃えておかないと動かなかった。普通にyarnでinstallすると、``pdfjs-dist``がバージョン違いで入ってしまうので、揃ってなければ手動で調整が必要。

```package.json
"dependencies": {
    ~~省略~~,
    "pdfjs-dist": "2.5.207",
    "vue-pdf": "4.2.0",
   ~~省略~~,
}
```

## srcオプションにpdfのデータをセット

上述で紹介したvue-pdfが用意しているコンポーネントのsrc引数に対して、PDFデータを渡していく

- srcに渡せるもの（検証したもの）
    - PDFファイルを静的なデータとしてディレクトリ配下に置いたものを呼び出す
    ```
      <pdf src="/slide.pdf"></pdf>
    ```
    - 外部のストレージなどに保存しているデータを通信して表示する
    ```
      <pdf src="https://cdn.mozilla.net/pdfjs/tracemonkey.pdf"></pdf>
    ```
    - base64などでencodeしたPDFデータのオブジェクトを指定（今回はこれを採用）
    ```
      <pdf src='data:application/pdf;base64,JVBERi0xLjUKJbXtrvsKMyAwIG9iago8PCAvTGVuZ3RoIDQgMCBSCiAgIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlCj4+CnN0cmVhbQp4nE2NuwoCQQxF+/mK+wMbk5lkHl+wIFislmIhPhYEi10Lf9/MVgZCAufmZAkMppJ6+ZLUuFWsM3ZXxvzpFNaMYjEriqpCtbZSBOsDzw0zjqPHZYtTrEmz4eto7/0K54t7GfegOGCBbBdDH3+y2zsMsVERc9SoRkXORqKGJupS6/9OmMIUfgypJL4KZW5kc3RyZWFtCmVuZG9iago0IDAgb2JqCiAgIDEzOAplbmRvYmoKMiAwIG9iago8PAogICAvRXh0R1N0YXRlIDw8CiAgICAgIC9hMCA8PCAvQ0EgMC42MTE5ODcgL2NhIDAuNjExOTg3ID4+CiAgICAgIC9hMSA8PCAvQ0EgMSAvY2EgMSA+PgogICA+Pgo+PgplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvUGFnZQogICAvUGFyZW50IDEgMCBSCiAgIC9NZWRpYUJveCBbIDAgMCA1OTUuMjc1NTc0IDg0MS44ODk3NzEgXQogICAvQ29udGVudHMgMyAwIFIKICAgL0dyb3VwIDw8CiAgICAgIC9UeXBlIC9Hcm91cAogICAgICAvUyAvVHJhbnNwYXJlbmN5CiAgICAgIC9DUyAvRGV2aWNlUkdCCiAgID4+CiAgIC9SZXNvdXJjZXMgMiAwIFIKPj4KZW5kb2JqCjEgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzCiAgIC9LaWRzIFsgNSAwIFIgXQogICAvQ291bnQgMQo+PgplbmRvYmoKNiAwIG9iago8PCAvQ3JlYXRvciAoY2Fpcm8gMS4xMS4yIChodHRwOi8vY2Fpcm9ncmFwaGljcy5vcmcpKQogICAvUHJvZHVjZXIgKGNhaXJvIDEuMTEuMiAoaHR0cDovL2NhaXJvZ3JhcGhpY3Mub3JnKSkKPj4KZW5kb2JqCjcgMCBvYmoKPDwgL1R5cGUgL0NhdGFsb2cKICAgL1BhZ2VzIDEgMCBSCj4+CmVuZG9iagp4cmVmCjAgOAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDA1ODAgMDAwMDAgbiAKMDAwMDAwMDI1MiAwMDAwMCBuIAowMDAwMDAwMDE1IDAwMDAwIG4gCjAwMDAwMDAyMzAgMDAwMDAgbiAKMDAwMDAwMDM2NiAwMDAwMCBuIAowMDAwMDAwNjQ1IDAwMDAwIG4gCjAwMDAwMDA3NzIgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA4CiAgIC9Sb290IDcgMCBSCiAgIC9JbmZvIDYgMCBSCj4+CnN0YXJ0eHJlZgo4MjQKJSVFT0YK'></pdf>
    ```

## PDFプレビューの表示方法

- 一枚ずつ見ていくタイプ
今回はこれで実装。
このexampleと同じ形式
https://github.com/FranckFreiburger/vue-pdf#example---current-page--page-count

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/e10e358d-d299-dece-9ca6-24f955929b73.png)

- 分割してPDFプレビューを見たいタイプ
v-forを使ってコンポーネントをページ分だけ作るようにする方法
https://github.com/FranckFreiburger/vue-pdf#example---display-multiple-pages-of-the-same-pdf-document

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/b25e9aa9-ca5b-0bfb-6191-8e4fd84789e8.png)

## Nuxt.js用にプラグインを作って読み込ませる

Vue.jsにvue-pdfを導入する場合は特にこちらの対応不要だが、Nuxt.jsに導入する場合はプラグインを作らないと動かなかった。

- プラグイン作り方の参考: https://develop365.gitlab.io/nuxtjs-2.8.X-doc/ja/guide/plugins/

- 以下のファイルを作成

```plugins/vue-pdf.js
import Vue from 'vue'
import vuePdf from 'vue-pdf'

Vue.use(vuePdf)
Vue.component('vue-pdf', vuePdf)

```

- nuxt.config.jsにも追記

```nuxt.config.js
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [{ src: '~/plugins/vue-pdf.js', mode: 'client' }],
```

## pdfのアップローダーまわり

### 完成形イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/a9e9aaf4-8635-8f9b-2318-219f1716d07f.png)

https://mebee.info/2020/12/02/post-18031/
最低限度の実装です。元実装から必要な部分だけ抽出しているので、動くかはわからないですが、大体こんな感じです。

```component/ImageUploader.vue
<template>
  <div>
    <!-- ドラッグ&ドロップ領域start -->
    <v-row
      @click="openImageSelectDialog"
      @drop.prevent="handleDropImage"
      @dragover.prevent="handleDragEvent($event, true)"
      @dragleave.prevent="handleDragEvent($event, false)"
    >
      <v-col style="margin-top: 165px">
        <div class="text-center mb-12">
          <img src="/svg/uploader_icon.svg" alt="SwipeLeft" />
        </div>
        <div class="text-center">ここにスライドをドロップしてください</div>
      </v-col>
    </v-row>
    <!-- ドラッグ&ドロップ領域end -->
    <!-- スライドアップロード(ボタンをクリックした時のイベントでpdfのアップローダが開くようにしている)start -->
    <v-row justify="center" align="center" no-gutters>
      <v-btn
        color="primary"
        class="textWhite--text text-h4 elevation-0"
        width="420px"
        height="61px"
        @click="openImageSelectDialog"
      >
        スライドをアップロードする
      </v-btn>
    </v-row>
    <input
      ref="uploadedImage"
      class="d-none"
      type="file"
      accept=".pdf"
      @change="handleChangeImage"
    />
    <!-- スライドアップロードend -->
  </div>
</template>

<script>
import { defineComponent, ref, useStore } from '@nuxtjs/composition-api'
export default defineComponent({
  setup() {
    const store = useStore()
    const isDragging = ref(false)
    const uploadedImage = ref(null)
　　　　　　　　
　　　　　　　　　// アップローダーを開く
    const openImageSelectDialog = () => {
      uploadedImage.value.click() // inputタグ要素をクリックする
    }

    //  データをドラッグ&ドロップしたときにイベントを拾いfileデータを整形
    const handleDropImage = (event) => {
      isDragging.value = false
      const file = extractFile(event)
      if (!file) {
        return
      }
      changeImage(file)
    }
    
    // ドラッグ時に領域の色を変えるためにドラッグ時のステータスをリアクティブな形で更新
    const handleDragEvent = (event, dragging) => {
      isDragging.value = dragging && event.dataTransfer?.types[0] === 'Files'
    }

    // ファイルデータを整形
    const handleChangeImage = (event) => {
      const file = extractFile(event)
      if (!file) {
        return
      }
      changeImage(file)
    }

    const extractFile = (event) => {
      const fileList = event.target?.files || event.dataTransfer?.files
      if (!fileList) {
        return null
      }
      return fileList[0] || null
    }
    
    // pdfデータをbase64でエンコード
    const changeImage = (file) => {
      const reader = new FileReader()
      reader.onload = (event) => {
        const result = event.target?.result
        if (typeof result !== 'string') {
          return
        }
        store.dispatch('presentation/readyState', result)
      }
      reader.readAsDataURL(file)
    }

    return {
      openImageSelectDialog,
      handleDropImage,
      handleDragEvent,
      handleChangeImage,
      isDragging,
      uploadedImage,
    }
  },
})
</script>
```

## コンポーネントに渡すpdfオブジェクトをbase64でencodeして作成
上記の部分の中で、pdfのbase64encodeなどの箇所を抜粋
- inputタグでセットされたpdfデータを受け取る
```vuejs
<input
      ref="uploadedImage"
      class="d-none"
      type="file"
      accept=".pdf"
      @change="handleChangeImage"
    />
```

- onChangeイベントをトリガーに関数を実行してfileデータをエンコードする
    - [FileReader.readAsDataURL()](https://developer.mozilla.org/ja/docs/Web/API/FileReader/readAsDataURL)メソッドを使う
``` js
const handleChangeImage = (event) => {
  const file = extractFile(event)
  if (!file) {
    return
  }
  changeImage(file)
}

const extractFile = (event) => {
  // ドラッグ&ドロップイベントからきたデータはdataTransferで取り出す
  const fileList = event.target?.files || event.dataTransfer?.files
  if (!fileList) {
    return null
  }
  return fileList[0] || null
}

const changeImage = (file) => {
  const reader = new FileReader()
  // ↓pdfデータの読み込みが完了したら作動してオブジェクトを取り出す
  reader.onload = (event) => {
    const result = event.target?.result
    if (typeof result !== 'string') {
      return
    }
    store.dispatch('presentation/readyState', result) // 返却されたオブジェクトをstoreに格納しvue-pdfコンポーネントのsrcに渡す
  }
  reader.readAsDataURL(file) // inputタグでの読み込みpdfデータをセットしたら作動
}

```

## pdfをセットするとスライドできるコンポーネントを作成
完成形イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/e960e599-3e98-60d9-c786-2170aee392a7.png)

最低限度の実装です。元実装から必要な部分だけ抽出しているので、動くかはわからないですが、大体こんな感じです。

```components/DisplayPdf.vue
<template>
  <client-only>
    <v-row>
      <!-- vue-pdfのコンポーネントstart -->
      <vue-pdf
        :src="pdfUrl"
        :page="currentPageNum"
        @num-pages="totalPageNum = $event"
      />
      <!-- vue-pdfのコンポーネントend -->
    </v-row>
    <!-- 矢印キーでのページスワイプ等start -->
    <v-row justify="center" align="center" class="mb-8 mt-12">
      <div class="mr-8">
        <v-btn icon @click="jumpPage(1)">
          <img src="/svg/swipe_ahead.svg" alt="swipe_ahead" />
        </v-btn>
      </div>
      <div class="mr-7">
        <v-btn icon @click="jumpPage(currentPageNum - 1)">
          <img src="/svg/swipe_left.svg" alt="swipe_left" />
        </v-btn>
      </div>
      <div class="mr-7">{{ currentPageNum }} / {{ totalPageNum }}</div>
      <div class="mr-8">
        <v-btn icon @click="jumpPage(currentPageNum + 1)">
          <img src="/svg/swipe_right.svg" alt="swipe_right" />
        </v-btn>
      </div>
      <div>
        <v-btn icon @click="jumpPage(totalPageNum)">
          <img src="/svg/swipe_end.svg" alt="swipe_end" />
        </v-btn>
      </div>
    </v-row>
    <!-- 矢印キーでのページスワイプ等end -->
  </client-only>
</template>

<script>
import {
  defineComponent,
  ref,
  onBeforeUnmount,
  onBeforeMount,
} from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    // propsでpdfのオブジェクトを受け取る
    pdfUrl: {
      type: String,
      default: '',
    },
  },
  setup(_) {
    // リアクティブなデータをセット
    const currentPageNum = ref(1)
    const totalPageNum = ref(0)

    // ページのswipe
    const jumpPage = (toPage) => {
      if (toPage < 1 || toPage > totalPageNum.value) {
        return
      }
      currentPageNum.value = toPage
    }

    // 左右矢印キーを押下時のアクション
    const keyAction = (e) => {
      if (e.keyCode === 37) {
        jumpPage(currentPageNum.value - 1)
      } else if (e.keyCode === 39) {
        jumpPage(currentPageNum.value + 1)
      }
    }

    // マウント時にイベントを仕込んでおく
    onBeforeMount(() => {
      // keydownのイベント
      window.addEventListener('keydown', keyAction)
    })

    onBeforeUnmount(() => {
      window.removeEventListener('keydown', keyAction)
    })

    return {
      jumpPage,
      currentPageNum,
      totalPageNum,
    }
  },
})
</script>


```

# 参考
- https://simple-it-life.com/2021/02/19/vue-pdf/