pell を使う

前回記事で Editer.js を使ってみました。
Editor.js を使う
WYSIWYG エディタに興味が湧いたので、今回はpellを試します。

今回使ったものを動作させたのが、以下の動画です。

では、詳細に入ります。

目次

参考

実装

やること

基本的には、前回 Editer.js を利用して実装した内容を pell を使用してリプレイスします。
vue.js の環境構築が済んでいる前提で進めます。

pell 他を導入

以下コマンドで、今回使うエディタの pell と html を markdown に変換する turndown、その逆を行う showdown を導入します。

1
npm install pell turndown showdown --save

pell を導入したコンポーネント

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
<template>
<div class="about">
<input type="text" v-model="title" />
<div>
<div class="pell" id="pell-editor"></div>
</div>
<div>
<button @click="save">SAVE</button>
</div>
</div>
</template>

<script>
import { init } from "pell";
import axios from "axios";
import qs from "qs";

import Turndown from "turndown";

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: "",
subText: "",
interval_handler: undefined,
post_id: undefined,
};
},
methods: {
save: async function () {
if (this.post_id != undefined) {
this.update();
return;
}

const turndownService = new Turndown({ headingStyle: "atx" });
const markDown = turndownService.turndown(this.subText);

const save_result = await axios.post(`/api/posts`, {
post: { title: this.title, maintext: markDown },
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 turndownService = new Turndown();
const markDown = turndownService.turndown(this.subText);

const save_result = await axios.patch(`/api/posts/${this.post_id}`, {
post: { title: this.title, maintext: markDown },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
});
if (save_result.data.state == "OK") {
console.log("Updated");
}
},
init: async function () {
this.editor = init({
element: document.getElementById("pell-editor"),
onChange: (html) => {
this.subText = html;
},
});
},
},
beforeUnmount: function () {
clearInterval(this.interval_handler);
},
mounted() {
this.init();
},
};
</script>

pell の最小構成で使うには、以下の通りです。

1
2
3
4
5
6
7
8
import { init } from "pell";

init({
element: document.getElementById("pell-editor"),
onChange: (html) => {
this.subText = html;
},
});

pell で作成されたエディタがマウントされる HTMLelement と、onChangeを与えます。
onChangeって不要ではないか?」と考え試しましたが、1 文字入れるたびエラーになります。
editer.js のように保存用のメソッドはないので、入力結果を逐次変数に入れて持つようにしました。

実行された表示の様子が、以下の通りです。

ボタンの下の部分が、エディタになっていますが囲みもなくて非常にわかりにくいです。

何か入力すると以下の通りです。

pell のデータは html 文字列として吐き出されます。
XSS の危険などを考えると、そのままバックエンドに HTML で送ることは避けたいです。
pell のドキュメントには、turndownを使用し、markdown に変換する方法を紹介しています。

1
2
3
4
5
import Turndown from "turndown";

// html -> markdownに変換
const turndownService = new Turndown({ headingStyle: "atx" });
const markDown = turndownService.turndown("html文字列");

こちらの方法で、markdown に変換しサーバーへ送信・保存します。

保存した文字列を再度編集する時には、次のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { init } from "pell";
import showdown from "showdown";
const editer = init({
element: document.getElementById("pell-editor"),
onChange: (html) => {
this.subText = html;
},
});

// markdown -> htmlに変換
const converter = new showdown.Converter();
const html = converter.makeHtml("markdown文字列");

//pellのエディタにHTMLを与えて初期化する
editor.content.innerHTML = html;

pell の編集状況を再現するには、html 文字列を与える必要があるので、サーバーから受け取った markdown を html に変換します。
ここでは、showdownを使用して、変換しました。

編集結果を表示するコンポーネント

pell で編集した結果を、表示するコンポーネントは以下のようになります。

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
<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 showdown from "showdown";
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;

const converter = new showdown.Converter();
const html = converter.makeHtml(result.data.maintext);

this.mainText = html;
},
};
</script>

サーバーから受け取った markdown を HTML に変換するので、こちらでもshowdownを使用します。
v-htmlを用いて HTML そのものを画面に反映します。

動作させると、次のようになります。

pell で編集し、編集結果を別の画面で表示できました。
一度編集した内容を改行しなかったりと気になることがあるので、次はカスタマイズします。

pell と turndown、showdown のカスタマイズ

空行の改行の反映など、細かいところの動作が気になってきます。
以下の内容をカスタマイズします。

  • 空行の改行を反映
  • bulmaのスタイルの反映

改修したコンポーネント

編集用

src/views/Edit.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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
<template>
<div class="container is-fluid">
<div>
<router-link to="/posts">一覧へ</router-link>|
<router-link :to="{ name: 'Post', params: { id: $route.params.id } }">
Post
</router-link>
</div>
<div>
<div class="title">
<div class="level-left">
<div class="level-item">
<p class="maintitle is-5">Title:</p>
</div>
<div class="level-item">
<div class="field has-addons">
<p class="control">
<input
class="input"
id="title-input"
type="text"
v-model="title"
/>
</p>
</div>
</div>
</div>
</div>
<div>
<div class="edit" id="pell-editor"></div>
</div>
</div>
<div>
<div>
<button class="button" @click="save">SAVE</button>
</div>
</div>
</div>
</template>

<script>
import { init, exec } from "pell";
import axios from "axios";
import qs from "qs";
import Turndown from "turndown";
import showdown from "showdown";

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: "",
subText: "",
interval_handler: undefined,
};
},
methods: {
save: async function () {
const turndown = new Turndown({
headingStyle: "atx",
});

//下線を指定するu要素の変換を「__下線の対象の文字列__」のパターンにする
turndown.addRule("u", {
filter: ["u"],
replacement: function (content) {
return "__" + content + "__";
},
});

//markdownに<br>を残すための設定
//.keepというメソッドがあるが動作が確認できないので独自に実装
turndown.addRule("br", {
filter: ["br"],
replacement: function () {
return "<br>";
},
});

const markDown = turndown.turndown(this.subText);

const save_result = await axios.patch(
`/api/posts/${this.$route.params.id}`,
{
post: { title: this.title, maintext: markDown },
paramsSerializer: function (params) {
return qs.stringify(params, { arrayFormat: "brackets" });
},
}
);
console.log(`saved ${save_result.data.state}`);
},
init: async function () {
const result = await axios.get(`/api/posts/${this.$route.params.id}`);

this.interval_handler = setInterval(this.save, 60000);

this.editor = init({
element: document.getElementById("pell-editor"),
//デフォルトのブロックをpにする
defaultParagraphSeparator: "p",
onChange: (html) => {
this.subText = html;
},
//機能を選ぶ
//標準であれば以下のものなどが指定できる
//actions: ['bold', 'underline'],
//以下は自作のアクション
//underline,Paragraphは、アイコンを変更したかった
//Paragraphは、編集内容を<p>のみにするために必要だった
//h1-h3はbulmaでは、クラスの割り当てが必要だった。
actions: [
"bold",
{
name: "Custom Underline",
icon: "<b><u>U</u><b>",
title: "Custom Underline",
state: () => document.queryCommandState("underline"),
result: () => exec("underline"),
},
{
name: "Custom Paragraph",
icon: "<b>P<sub>ra</sub></b>",
title: "Custom Paragraph",
result: () => exec("formatBlock", "<p>"),
},
{
name: "Custom H1",
icon: "<b>H<sub>1</sub></b>",
title: "Custom H1",
state: () => document.queryCommandState("<h1>"),
result: () => {
document.execCommand("formatblock", false, "<h1>");
const listId = window.getSelection().anchorNode.parentNode;
listId.classList = "title is-1";
},
},
{
name: "Custom H2",
icon: "<b>H<sub>2</sub></b>",
title: "Custom H2",
state: () => document.queryCommandState("<h2>"),
result: () => {
document.execCommand("formatblock", false, "<h2>");
const listId = window.getSelection().anchorNode.parentNode;
listId.classList = "title is-2";
},
},
{
name: "Custom H3",
icon: "<b>H<sub>3</sub></b>",
title: "Custom H3",
state: () => document.queryCommandState("<h3>"),
result: () => {
document.execCommand("formatblock", false, "<h3>");
const listId = window.getSelection().anchorNode.parentNode;
listId.classList = "title is-3";
},
},
],
//独自のクラスを割り当てたいときには以下のように記述する
//pell-hogehogeは、デフォルトのもの
//bulma はボタンのクラスにbuttonが必要なので設定した
classes: {
actionbar: "pell-actionbar",
button: "pell-button button",
content: "pell-content",
selected: "pell-button-selected",
},
});

this.title = result.data.title;

const classMap = {
h1: "title is-1",
h2: "title is-2",
h3: "title is-3",
};

const bindings = Object.keys(classMap).map((key) => ({
type: "output",
regex: new RegExp(`<${key}(.*)>`, "g"),
replace: `<${key} class="${classMap[key]}" $1>`,
}));

const converter = new showdown.Converter({
extensions: [...bindings],
});
converter.setOption("underline", true);
converter.setOption("noHeaderId", true);

const html = converter.makeHtml(result.data.maintext);
this.editor.content.innerHTML = html;
this.subText = html;
},
},
beforeUnmount: function () {
clearInterval(this.interval_handler);
},
mounted() {
this.init();
},
};
</script>

<style lang="scss" scoped>
.title {
margin-bottom: 10px;
}
.edit {
border: 3px solid rgba(100, 100, 100, 0.5);
border-radius: 7px;
}

/deep/ .pell-content {
padding: 10px;
}

/deep/ .pell-content:focus {
outline: none;
}
</style>

表示用

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<template>
<div class="container is-fluid">
<div>
<router-link to="/posts">一覧へ</router-link>|
<router-link :to="{ name: 'Edit', params: { id: $route.params.id } }">
Edit
</router-link>
</div>
<div></div>
<div class="title">
<div class="level-left">
<div class="level-item">
<p class="title">Title:</p>
</div>
<div class="level-item">
<div class="field has-addons">{{ title }}</div>
</div>
</div>
</div>
<div v-html="mainText" class="maintext"></div>
</div>
</template>

<script>
import showdown from "showdown";
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;

//bulma用のclass割り当て用の実装
//参考 https://github.com/showdownjs/showdown/wiki/Add-default-classes-for-each-HTML-element
const classMap = {
h1: "title is-1",
h2: "title is-2",
h3: "title is-3",
};

const bindings = Object.keys(classMap).map((key) => ({
type: "output",
regex: new RegExp(`<${key}(.*)>`, "g"),
replace: `<${key} class="${classMap[key]}" $1>`,
}));

//自作拡張機能を設定
const converter = new showdown.Converter({
extensions: [...bindings],
});

//オプションを設定
converter.setOption("underline", true);
converter.setOption("noHeaderId", true);

const html = converter.makeHtml(result.data.maintext);

this.mainText = html;
},
};
</script>

<style scoped>
.maintext {
padding: 10px;
border: 3px solid rgba(100, 100, 100, 0.5);
border-radius: 7px;
}
</style>

動作確認

改修したコンポーネントで動作させると以下のようになります。

空行の反映、bulma のスタイルの適用を達成できました。


今回は pell を使ってみました。
拡張機能の実装のために、pell のソースコードも確認しました。
使われている API にdocument.execCommand()があります。
こちらは、廃止されている API であるため、pell を本格的に使用することは、将来的に危険である可能性が高いです。
github のコミットを確認すると、大体 2 年前が最終更新でした。
この点でいうなら Editer.js を選定すべきでしょう。

他のエディタを再度比較すべきでしょうが、エディタの中要素に対して class の設定をする。
表示のために、markdown->HTML への変換の過程で class を割り当てる。といった実装が必要になっていました。
今回は、拡張機能の実装そのものにも興味があったので、クラス付与を積極的に行いました。
省力化するのであれば、親要素以下の要素型セレクタを指定する形で CSS を書いたほうが、絶対に楽だろうと見込みます。
こんな感じで。

1
2
3
4
5
6
7
8
.editer .viewer {
p {
/*何かの設定*/
}
h1 {
/*何かの設定*/
}
}

pell を触ってみたうえで、Editer.js に戻ってみようかと考えています。
機能拡張などやっていないこともあるので。
もしくは、WYSIWYG もう一種類tiptapmedium-editorを試したいですね。

ではでは。