Editor.js を使う

今回は、Editor.js を使ってみます。
導入するベースとして、Ruby on Rails と Vue3 を使います。

目次

参考

前提

前提として、Ruby on Rails と Vue.js の環境の用意をします。

こちらは、Rails と Vue.js CSRF 対策を意識してシングルページアプリケーションを作ってみるに記載があるので、参照ください。

実装

環境構築を済ませていることを前提として、Editor.js を用いたコンポーネントを作っていきます。

Editor.js の導入

以下コマンドでインストール。

1
npm i @editorjs/editorjs --save

Editor.js を導入したコンポーネント

src/views/New.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<template>
<div class="about">
<input type="text" v-model="title" />
<div>
<div class="edit" id="editorjs"></div>
</div>
<div>
<button @click="save">SAVE</button>
</div>
</div>
</template>

<script>
import EditorJS from "@editorjs/editorjs";
import axios from "axios";
import qs from "qs";

axios.defaults.headers.common = {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
};

export default {
data: function () {
return {
editor: undefined,
title: "",
mainText: "",
interval_handler: undefined,
post_id: undefined,
};
},
methods: {
save: async function () {
if (this.post_id != undefined) {
this.update();
return;
}

//Editor.jsの作成したエディタ内容の保存
//保存するとオブジェクトになる。
const result = await this.editor.save();

//axiosでサーバーに送信する。
//Edier.jsの保存データは、オブジェクトなので、文字列化して送信する
const save_result = await axios.post(`/api/posts`, {
post: { title: this.title, main_text: JSON.stringify(result) },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
});

//初回保存の結果に含まれるIDを保存
this.post_id = save_result.data.id;

//初回の保存ができた、一分ごとの自動保存を開始する
this.interval_handler = setInterval(this.save, 60000);
},
update: async function () {
const result = await this.editor.save();

//IDを基に更新を実行
const save_result = await axios.patch(`/api/posts/${this.post_id}`, {
post: { title: this.title, main_text: JSON.stringify(result) },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
});
if (save_result.data.state == "OK") {
console.log("Updated");
}
},
init: async function () {
//Editor.jsの初期化
this.editor = new EditorJS({
//Editor.jsの対象にするidを与える
holder: "editorjs",
});
},
},
mounted() {
this.init();
},
beforeUnmount: function () {
//コンポーネント破棄される前にintervalを削除する
clearInterval(this.interval_handler);
},
};
</script>

<style scoped>
.edit {
border: 1px solid #000;
}
</style>

Editor.js の初期化は以下のようにします。

1
2
3
4
editor = new EditorJS({
//editor.jsの対象にするidを与える
holder: "editorjs",
});

editor.js の提供する WYSIWYG エディタを展開する先の ID を holder で与えるだけ、最小構成はこれだけです。
新規作成のコンポーネントでは、初回の保存後には更新をするためメソッドを変えています。
そのため axios でのリクエストは 2 種類用意しています。

動かしてみたところが次のようになります。

保存したデータの形式を確認しておきます。
以下に、Editor.js のデータを JSON にしたものを記載します。

1
2
3
4
5
6
7
8
{
"time": 1600585196852,
"blocks": [
{ "type": "paragraph", "data": { "text": "テスト1" } },
{ "type": "paragraph", "data": { "text": "テスト2" } }
],
"version": "2.18.0"
}

ブロックが配列で定義され、その中身の type と data があります。
これを読み込むことにより、保存したデータを編集することもできます。

データを読み込んで、Editor.js を初期化するには、次のようにします。

1
2
3
4
5
editor = new EditorJS({
holder: "editorjs",
//データを与えて初期化する
data: data,
});

editorjs-html の導入

Editor.js で作成したデータを非編集の形で表示するコンポーネントを作っていきます。
非編集の形で表示するには、オブジェクト形式のデータを HTML へ変換する必要があります。
こちらの機能を実装・公開されている方がいたので、使わせていただきます。

github - pavittarx/editorjs-html

以下コマンドでインストール。

1
npm install editorjs-html --save

editorjs-html の導入したコンポーネント

src/views/Post.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
<div class="post">
<div>
<router-link to="/posts">一覧へ</router-link>|
<router-link :to="{ name: 'Edit', params: { id: $route.params.id } }">
Edit
</router-link>
</div>
<div>{{ title }}</div>
<div v-html="mainText"></div>
</div>
</template>

<script>
import EditorJSHtml from "editorjs-html";
import axios from "axios";

export default {
data: function () {
return {
title: "",
mainText: "",
};
},
mounted: async function () {
const result = await axios.get(`/api/posts/${this.$route.params.id}`);

this.title = result.data.title;

const parser = EditorJSHtml();

//取得したEditor.jsの保存データの文字列をJSONでパースし、
//EditorJSHtml()から作ったパーサーでHTMLに変換する
//配列になるので、reduceですべて文字列連結する
this.mainText = parser
.parse(JSON.parse(result.data.main_text))
.reduce((x, y) => `${x}${y}`);

//結果をv-htmlで反映する。
},
};
</script>

<style scoped></style>

こちらを動作させたのが次の動画、編集した内容が、非編集の画面に反映できます。

editor.js のカスタマイズ

editor.js を素で使うと、本当に文字の入力しかできません。
必要な機能は、アドオンを導入して補完する必要があります。

今回は、以下の機能を追加してみます。

  • 空の行の許可
    素のままだと空の行が、保存されたオブジェクトでは削除されている。
    空行で伝わる心情というものはある。
  • 画像の投稿
    ブログサイトみたいなものを想定するとやはり欲しい画像投稿。

以下のようにすることで、src/views/New.vueに機能を追加できます。

src/views/New.vue(編集後)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<template>
<div class="about">
<input type="text" v-model="title" />
<div>
<div class="edit" id="editorjs"></div>
</div>
<div>
<button @click="save">SAVE</button>
</div>
</div>
</template>

<script>
import EditorJS from "@editorjs/editorjs";
import Paragraph from "@editorjs/paragraph";
import ImageTool from "@editorjs/image";
import axios from "axios";
import qs from "qs";

axios.defaults.headers.common = {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
.getAttribute("content"),
};

export default {
data: function () {
return {
editor: undefined,
title: "",
mainText: "",
interval_handler: undefined,
post_id: undefined,
};
},
methods: {
save: async function () {
if (this.post_id != undefined) {
this.update();
return;
}
const result = await this.editor.save();

const save_result = await axios.post(`/api/posts`, {
post: { title: this.title, main_text: JSON.stringify(result) },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
});

this.post_id = save_result.data.id;

this.interval_handler = setInterval(this.save, 60000);
},
update: async function () {
const result = await this.editor.save();

const save_result = await axios.patch(`/api/posts/${this.post_id}`, {
post: { title: this.title, main_text: JSON.stringify(result) },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
});
if (save_result.data.state == "OK") {
console.log("Updated");
}
},
init: async function () {
this.editor = new EditorJS({
holder: "editorjs",
tools: {
paragraph: {
class: Paragraph,
inlineToolbar: true,
config: {
// 空の行を許可する。
preserveBlank: true,
},
},
image: {
class: ImageTool,
config: {
read_only: true,
//ドキュメントには、以下のような例の記載があるが、csrfトークンを使う場合、uploaderを実装する必要があった
//endpoints: {
// byFile: 'http://localhost:3000/api/uploadfile',
// byUrl: 'http://localhost:3000/api/fetchurl'
//},
uploader: {
//Fileを選択、ドラッグアンドドロップしたときに呼び出されるアップローダー
uploadByFile(file) {
let formData = new FormData();
formData.append("image", file);
return axios
.post("/api/uploadfile", formData, {
headers: { "content-type": "multipart/form-data" },
})
.then((res) => {
return res.data;
});
},
//URLを貼り付けた時のアップローダー
//以下のようにすれば、サーバーにリクエストを送り、結果を受け取る
//uploadByUrl(url) {
// return axios.post('/api/fetchurl', { url: url }).then((res) => {
// return res.data
// })
//}
//URLで貼り付けた時、ファイルアップロードしないで、貼り付ける方法
//以下の実装をすればサーバーにリクエストを送らずに、画像をeditor.jsに含めることができる。
//画像は、外部のサーバーにあるものをそのまま参照することになる。
//できるけど、「いいのか」といわれると微妙
uploadByUrl(url) {
return new Promise((resolve) => {
resolve({ success: 1, file: { url: url } });
});
},
//uploadByFileとuploadByUrlは、
//Ptomiseを返り値として要求する。
//またその結果は、{ success: 1, file: { url: `参照先の画像のURL` } }とする必要がある
},
},
},
},
initialBlock: "paragraph",
});
},
},
beforeUnmount: function () {
clearInterval(this.interval_handler);
},
mounted() {
this.init();
},
};
</script>

<style scoped>
.edit {
border: 1px solid #000;
}
</style>

上記を実装できると、画像を editor.js で使用できるようになります。
コメントでたくさん書いていますが、@editorjs/image には URL を貼ることでアップロードする機能があります。
裏を返すと、画像の URL を貼った時にサーバー側でダウンロードをする必要があるのかとも考えましたが、対応できました。
URL を貼って外部の画像をそのまま参照させることもできました(いいのかはいったん置いておいて)。

見るとわかりますが、@editorjs/image を導入すると、上記の動画の様に画像のアップロードに合わせて画像下の Caption を使用できるようになります。
この Caption に、editorjs-htmlが対応していないので、表示用画面では、無視されてしまいます。
対応するためカスタムパーサーを実装します。

編集後は以下の通りです。

src/views/Post.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<template>
<div class="post">
<div>
<router-link to="/posts">一覧へ</router-link>|
<router-link :to="{ name: 'Edit', params: { id: $route.params.id } }">
Edit
</router-link>
</div>
<div>{{ title }}</div>
<div v-html="mainText"></div>
</div>
</template>

<script>
import EditorJSHtml from "editorjs-html";
import axios from "axios";

export default {
data: function () {
return {
title: "",
mainText: "",
};
},
methods: {},
mounted: async function () {
const result = await axios.get(`/api/posts/${this.$route.params.id}`);

this.title = result.data.title;

//画像用のカスタムパーサーを実装する
function customParser(block) {
return `<div>
<div class="image">
<figure>
<image src="${block.data.file.url}">
<figcaption>${block.data.caption}</figcaption>
</figure>
</div>
</div>`;
}

//imageというblockに対して、カスタムパーサーを適用する
const parser = EditorJSHtml({ image: customParser });
this.mainText = parser
.parse(JSON.parse(result.data.main_text))
.reduce((x, y) => `${x}${y}`);
console.log(this.mainText);
},
};
</script>

<style scoped></style>

上記の実装で、Caption が反映されるようになりました。
以下の動画で、Caption へ書いた内容が、反映されているのがわかります。

任意のクラスを割り当てたい場合は、標準のブロックに対してもカスタムパーサーを当てていく必要があるでしょう。


今回は、Editor.js を使ってみました。
Editor.js はとてもシンプルなのです。文字と画像だけの編集だけいい場合などでは、このくらいでも十分ではないかと感じます。
WYSIWYG エディタは、Editor.js を問わず他にも様々なものがあります。

別のエディタも試してみたいです。

ではでは。