リッチテキストエディタ（CKEditor5）をNuxt上で動かして画像はS3にアップロードする

はじめに
---
この記事は**2019/09/17**現在の所々の最新のライブラリについて記した用法なので、この記事自体が半月や１年以上経つと、全く意味のない内容になっていたりするので、注意されたい

記事でやること
---
更地のフォルダにnuxt,CKEditor5,APIサーバーを導入していき、最終的にCKEditor5のアップロード機能をAWS S3で行えるようにする

事前準備
---
- AWSマイセキュリティ認証情報のアクセスキー （[このへんからダウンロードする](https://console.aws.amazon.com/iam/home#/security_credentials)）
- [NodeJS v10.16.3](https://github.com/nvm-sh/nvm#installation-and-update)
- [yarn v1.16.0](https://yarnpkg.com/lang/ja/docs/install/#mac-stable)

記事内で使用するライブラリとバージョン
---
- [nuxt@2.9.2](https://nuxtjs.org/guide/installation#installing-code-nuxt-code-)
- [@nuxtjs/axios@5.6.0](https://axios.nuxtjs.org)
- [@ckeditor/ckeditor5-vue@1.0.0-beta.2](https://www.npmjs.com/package/@ckeditor/ckeditor5-vue)
- [@ckeditor/ckeditor5-build-classic@12.4.0](https://www.npmjs.com/package/@ckeditor/ckeditor5-build-classic)
- ほかexpress関連（[aws-sdk](https://www.npmjs.com/package/aws-sdk) [cors](https://www.npmjs.com/package/cors) [express](https://www.npmjs.com/package/express) [mime](https://www.npmjs.com/package/mime) [multer](https://www.npmjs.com/package/multer) [multer-s3](https://www.npmjs.com/package/multer-s3) [uuid](https://www.npmjs.com/package/uuid)）

Nuxtを建てる
---
更地のフォルダを作成してそこにnuxtと`pages/index.vue`だけを作成する

```bash
mkdir nuxtjs-ckeditor5-s3
cd nuxtjs-ckeditor5-s3
yarn add nuxt
mkdir pages
vim pages/index.vue
```

```vue:index.vue
<template>
  <h1>Hello world!</h1>
</template>
```

```bash:ここまでの生成物
tree
# .
# ├── node_modules
# ├── package.json
# ├── pages
# │   └── index.vue
# └── yarn.lock
```

```bash:nuxt起動。以降、起動を前提として説明を続ける
open http://localhost:3000/
yarn nuxt dev # 少し待つ...
```

<img width="299" alt="スクリーンショット 2019-09-17 1.11.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28576/d5250001-7f45-8fa8-3b52-08ad17a12337.png">

nuxtの動作を確認して次へ

CKEditor5( `@ckeditor/ckeditor5-build-classic` )の導入
---
この単純な環境に導入するにもけっこう骨が折れる。ただimportしただけではエラーになるので、`no-ssr`(最新版では`client-only`に変名になった)コンポーネントの使用や`nuxt.config.js`などでSSRを無効にしたあと、専用のコンポーネントを作成して、その中で初期化する必要がある

```bash:依存モジュールを追加
yarn add @ckeditor/ckeditor5-vue
yarn add @ckeditor/ckeditor5-build-classic

vim nuxt.config.js
```

```js:nuxt.config.js
export default {
  plugins: [{ src: "~/plugins/ckeditor.js", mode: "client" }]
};
```

```bash:ckeditor5の読み込み時はSSRを無効にし、クライアントアクセス時のみckeditor5の初期化を行う
mkdir plugins
vim plugins/ckeditor.js
```

```js:plugins/ckeditor.js
import Vue from "vue";
import Ckeditor5Vue from "@ckeditor/ckeditor5-vue";
import CkeditorClassic from "@/components/CkeditorClassic";

Vue.use(Ckeditor5Vue);
Vue.component("ckeditor-classic", CkeditorClassic);
```

```bash:専用のコンポーネント内で初期化する
mkdir components
vim components/CkeditorClassic.vue
```

```vue:components/CkeditorClassic.vue
<template>
  <ckeditor
    v-model="ckeditorValue"
    v-bind="ckeditorConfig"
    @ready="ckeditorReady"
  />
</template>

<script>
import editor from "@ckeditor/ckeditor5-build-classic";
import "@ckeditor/ckeditor5-build-classic/build/translations/ja.js";

export default {
  props: ["config"],
  data() {
    const config = this.config || {};

    const ckeditorValue = "";
    const ckeditorConfig = { editor, ...config };
    const ckeditorReady = () => {};

    return { ckeditorValue, ckeditorConfig, ckeditorReady };
  },
  watch: {
    ckeditorValue(value) {
      this.$emit("input", value);
    }
  }
};
</script>
```

上記設定で `ckeditor5-vue`の初期化がブラウザ描写時にのみ行われるので、`window is not defined` エラーに関してはこれで修正になる。

```bash:ここまでの生成物
tree
# .
# ├── components
# │   └── CkeditorClassic.vue
# ├── node_modules
# ├── nuxt.config.js
# ├── package.json
# ├── pages
# │   └── index.vue
# ├── plugins
# │   └── ckeditor.js
# └── yarn.lock
```

用意した`ckeditor-classic`コンポーネントを`client-only`コンポーネントで梱包して、下記のように使用する

```vue:pages/index.vue
<template>
  <div>
    <client-only>
      <ckeditor-classic v-model="html" />
    </client-only>

    <pre>{{ html }}</pre>
  </div>
</template>

<script>
export default {
  data() {
    return { html: "" };
  }
};
</script>
```

さて、上記のようにindex.vueを変更すると、CKEditor5の動作が `http://localhost:3000/` 上で確認できる。

![](https://i.gyazo.com/4512444e1b2c6a93181afbee9e89eab4.gif)

エディタの内容は`html`で同期されるので良しとして、上記ツールバーのボタンで動作しないものがある。そう、 <img width="35" alt="ハイパーキネティックイメージアップローダー" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28576/b5e7cec9-cc81-824d-33bf-1c601b469e43.png"> だ。

じっさいにボタンをクリックすると、画像を選択するダイアログが出るが、選択後にコンソール上に下記のようなエラーが表示されて動作しない。

```bash:chromeコンソール上のエラー
filerepository-no-upload-adapter: Upload adapter is not defined. Read more: https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/error-codes.html#error-filerepository-no-upload-adapter
```

![](https://i.gyazo.com/219a462c94f700bfbddfe79a49473ef1.gif)

とりあえずPOSTさせるだけ
---
> You need to enable an upload adapter in order to be able to upload files.
> This warning shows up when FileRepository is being used without definining an upload adapter.
> If you see this warning when using one of the CKEditor 5 Builds it means that you did not configure any of the upload adapters available by default in those builds.

エラーメッセージのリンク先では上記のような説明がなされているが、具体的には「選択したファイルの受け取れるところまで確認」できれば、あとは[@nuxtjs/axios](https://axios.nuxtjs.org/setup)を使用してPOSTを行えばよい。

```bash:依存に「@nuxtjs/axios」を追加
yarn add @nuxtjs/axios
```
```js:nuxt.config.js
export default {
  plugins: [{ src: "~/plugins/ckeditor.js", mode: "client" }],
  modules: ["@nuxtjs/axios"],
  axios: {
    baseURL: process.env.BASE_URL || "http://localhost:3001"
  }
};
```

`FileRepository` というプラグインは、`ckeditor`コンポーネントの`@ready`イベントハンドラ内で取得できるため、前述の`components/CkeditorClassic.vue`内の`ckeditorReady`関数を、下記のように書き換える。

```js:components/CkeditorClassic.vue
///...
    const ckeditorReady = eventData => {
      const FileRepository = eventData.plugins.get("FileRepository");
      FileRepository.createUploadAdapter = loader => {
        class UploadAdapter {
          constructor(loader, $axios) {
            this.loader = loader;
            this.$axios = $axios;
          }
          async upload() {
            const file = await this.loader.file;
            const params = new FormData();
            params.append("file", file);

            return this.$axios.$post("/api/image-upload/", params);
          }
        }

        return new UploadAdapter(loader, this.$axios);
      };
    };
///...
```

`FileRepository.createUploadAdapter` には、上記のような`upload`メソッドを持つクラスインスタンスを返す、ファクトリ関数を代入すれば良い。（※ upload実行時、`this.loader.file` は[Promise](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise)<[File](https://developer.mozilla.org/ja/docs/Web/API/File)> を持つ）

> Custom image upload adapter - CKEditor 5 Documentation
> https://ckeditor.com/docs/ckeditor5/latest/framework/guides/deep-dive/upload-adapter.html

この変更を加えると、ファイル選択時の挙動が下記のように変わる。

![](https://i.gyazo.com/147c7c54ff10bf50d17e17e8a5d0e836.gif)

> `POST http://localhost:3001/api/image-upload/ 404 (Not Found)`

express + multer + multer-s3で画像アップロードAPIを実装する
---

axiosで`/api/image-upload/`に向けてPOSTするところまで正常に動作しているのを確認できたので、あとは受信側の実装を行う。

```bash:新しくapiフォルダを作成し、その中にサーバー用のjsとその依存をまとめる
mkdir api
cd api
echo '{}' > package.json
yarn add aws-sdk cors express mime multer multer-s3 uuid

vim index.js
```

```js:api/index.js
const AWS = require("aws-sdk");
const multer = require("multer");
const multerS3 = require("multer-s3");

const express = require("express");
const cors = require("cors");

const mime = require("mime");
const uuidv4 = require("uuid/v4");

AWS.config.update({
  secretAccessKey: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  accessKeyId: "XXXXXXXXXXXXXXX",
  region: "ap-northeast-1"
});

const upload = multer({
  storage: multerS3({
    s3: new AWS.S3({ apiVersion: "2006-03-01" }),
    bucket: process.env.AWS_S3_BUCKET || "nuxtjs-ckeditor5-s3",
    acl: "public-read",
    contentType: multerS3.AUTO_CONTENT_TYPE,
    key(req, file, callback) {
      const ext = mime.getExtension(file.mimetype);
      callback(null, uuidv4() + "." + ext);
    }
  })
});

const app = express();
app.use(cors());
app.post("/api/image-upload/", upload.single("file"), async (req, res) => {
  const url = req.file.location;
  if (!url) {
    return res.status(500).json({ error: new Error("upload failing") });
  }
  res.json({ default: url });
});

const PORT = process.env.PORT || 3001;
const listener = app.listen(PORT, () => {
  const { port } = listener.address();
  console.log(`Listen on http://localhost:${port}`);
});
```

```bash:ここまでの生成物
cd ..

tree
# .
# ├── api
# │   ├── index.js
# │   ├── package.json
# │   └── yarn.lock
# ├── components
# │   └── CkeditorClassic.vue
# ├── node_modules
# ├── nuxt.config.js
# ├── package.json
# ├── pages
# │   └── index.vue
# ├── plugins
# │   └── ckeditor.js
# └── yarn.lock
```

`secretAccessKey`, `accessKeyId ` は前述のAWSマイセキュリティ認証情報のアクセスキーからコピーを。バケットは事前に「nuxtjs-ckeditor5-s3」という名前で作成しておき、パブリックアクセスを許可する設定にしておく。

> アップロード時に `AccessDenied` などと出る場合はバケットの公開設定などを見直す。「バケットの作成＞③アクセス許可の設定＞パブリックアクセスをすべてブロック」のチェックを外してから、再度試す。また、バケット一覧で、下記のような表記に設定されているか確認する
<img width="1205" alt="スクリーンショット 2019-09-19 11.05.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/28576/9760ad16-5500-eb40-5e95-3dc859a4e1a6.png">

```bash:nuxtとは別にサーバーを並行で起動する
node api/index.js
# Listen on http://localhost:3001
```

サーバーを実行した状態で再度ファイルを選択すると、S3にアップロードが行われ、APIからは

```json:/api/image-upload/
{
  "default": "https://nuxtjs-ckeditor5-s3.s3-ap-northeast-1.amazonaws.com/e9d6cc4c-21b2-46ef-b1f6-d99d15efe58c.png"
}
```

のようなレスポンスが返されることを確認する。

![](https://i.gyazo.com/c5236f57a85f85c9fa4fb18331daf46c.gif)

終わりに
---
以上、駆け足となったが、nuxt上でのCKEditor5の設置方法と、画像アップロードの簡易セットアップ手順について記した。
前者についてはこれ以上簡潔にするのは難しいと思うが、後者については代替方法がいくらでもあると思うので、読者の好きなように環境を構築してもらって構わない。

記事中の環境については [nuxtjs-ckeditor5-s3 - github](https://github.com/59naga/nuxtjs-ckeditor5-s3) にアップした