この記事は kintone アドベントカレンダー 2020 の 13 日目の記事です。
今年のアドベントカレンダーも折り返しですね!
はじめに
11 月の Cybozu Days 2020 Tokyo に出展側として参加して来ました。
ステージのすぐ近くのブースだった事もあり、各ステージで行われていたセッションを横目に見ながらブースにいらしたお客様に製品をご案内するみたいな感じでした。
で、あるセッションで、
kintone で Markdown が使えればいいのに
とエンジニアの皆さんなら激しく同意できるようなお話をされていたのを聞いたわけです。
確かに、Markdown 使いたいですよね。
アプリのフィールドの 1 つにあってもいいんじゃないかとさえ思いますよね。
少なくとも 文字列(複数行) フィールドの設定で「Markdown テキストを入力可能とする」みたいなオプションがあれば良いのにとか思っちゃいますよね。
現状の kintone の仕様ではカスタムコントロールと言うか自前で新しいタイプのフィールドを作成する事はできないので(そこらへんを開放してくれると新しい商売の道が開そうな気がしますよね)、既存のフィールドの組み合わせでなんとかしてみようじゃないかと言うのが今回の趣旨です。
Markdown エディタを作る
選択肢はいろいろある
Cybozu CDN にも Markdown 変換を行うライブラリとして Marked が挙げられていますけれども、個人的にはもう少し良いライブラリがないかなーと思っていて、markdown-it なんかはかなり良いなと思っています。
しかし今回はもっと楽になんとか出来ないかと言う事で、Vue.js
で動作する Markdown エディタである mavonEditor を使ってみようと思います。
まあ mavonEditor
も内部的には markdown-it
を使っているようですけど。
アプリの準備
今回はごくごくシンプルにタイトルと本文だけがある形にします。
ただし真ん中に広くスペース要素を配置します。
このスペースの大きさが Markdown エディタの初期表示時の大きさになるので、良い感じに欲しい大きさを確保しておきましょう。
で、このスペース要素のフィールドコードに editor-space
と指定しておきます。
本文
フィールドはエディタに書き込んだ内容を保持するためのものです。
まとめると以下の通り。
フィールドタイプ | フィールドコード |
---|---|
文字列 (1 行) | タイトル |
スペース | editor-space |
文字列 (複数行) | 本文 |
とは言え今回の実装ではタイトルは特に関わって来ません。
便宜上配置しているだけです。
プロジェクトの準備
アプリの準備ができたら次は実装の準備です。
事前に @vue/cli
を準備しておいてください。
まずはプロジェクトを作ります。
この辺りはあまり本筋ではないのでざっくりと。
vue create kintone-markdown
選択肢は以下のような感じで。
Vue.js
のバージョンは 2.x を選択します。
最初 3.x (と Composition API)で作り始めたんですけど、上手い事いかなかったので。
プロジェクトの作成ができたら kintone-markdown
ディレクトリを VS Code で開きます。
エディタ内でターミナルを起動して、
yarn add -D mavon-editor
で mavon-editor
をサクッとインストール。
背伸びして TypeScript
で実装しようとしているので、d.ts
ファイルを用意しておかないと何かと困ります。
以下のような内容で src/kintone.d.ts
ファイルを作っておきましょう。
declare let kintone: {
api: function;
events: {
on: function;
off: function;
};
app: {
record: {
set: function;
get: function;
getSpaceElement: function;
setFieldShown: function;
};
};
[key: string]: unknown;
};
declare let kintoneRecord: {
[key: string]: { type: string; value: string | number | object | unknown };
};
declare let kintoneEvent: {
appId: number;
record: typeof kintoneRecord;
recordId: number;
type: string;
};
これで実装前の準備は終了です。簡単ですね!
実装
それではいよいよ実装に入ります。
何はともあれエディタを表示
拙速に結果を欲しがる今時の若者の如く(偏見)、とにかく表示できるところまでやりましょう。
src/main.ts
を以下のように。
import Vue from "vue";
import App from "./App.vue";
Vue.config.productionTip = false;
// mavonEditor を使用
import mavonEditor from "mavon-editor";
import "mavon-editor/dist/css/index.css";
Vue.use(mavonEditor);
/**
* レコード作成・編集時イベント処理
*/
kintone.events.on(
["app.record.create.show", "app.record.edit.show", "app.record.detail.show"],
(event: typeof kintoneEvent) => {
// マウント対象の要素
const elem = kintone.app.record.getSpaceElement(
"editor-space"
) as HTMLElement;
// マウント
new Vue({
render: (h) => h(App),
}).$mount(elem);
return event;
}
);
src/App.vue
はこんな感じに。
<template>
<div id="app" class="editor-container">
<mavon-editor
class="editor-body"
:ishljs="true"
:language="'ja'"
></mavon-editor>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "App"
});
</script>
<style lang="scss"></style>
mavon-editor
コンポーネントに渡しているプロパティ、ishljs
は highlight.js でのハイライトをしますよと言うのと、language
は読んで字の如く表示言語を日本語にしますよと言う指定です。
それではどかーんとビルド。
yarn build
出来上がったファイル(dist/js/app.########.js
と dist/css/app.########.css
)を kintone 上でアプリに適用。
表示できた・・・けど・・・
なにこのコレジャナイ感・・・。
ダメ出しされる
この状態でも左のエディタペインにテキストを入力すれば即座に右のプレビューペインに結果が出るのです。
もうこの時点で Qiita の記事投稿フォームに追いついたと言っても過言ではないのです。(圧倒的に過言)
だと言うのに何が気に入らないって言うんですかお客さん。(半ギレ)
エディタはスペース要素の広さになるって言いましたよね?
高さ足りてないですよね?
エディタの上段の XXXXXXXX ってなんなんです?
卑猥なワードが検閲されたみたいに見えちゃいますよね?
エディタペインにテキスト入力してレコード保存するとその内容消えちゃいますよね?
本文フィールドに格納されないですよね?(致命的)
コテンパンですね。ぐうの音も出ない感じすごいですね。
バグ対応
しかしながら我らは誇り高きエンジニア。
「千人日の案件も一人日から」とは良く言ったものです。
ピラミッドを作るよりは遥かに容易い仕事です。あれは 20 万人月ですが。
と言うわけで、1 つ 1 つ解決していきましょう。
エディタをスペースいっぱいに拡げる
よくよく画面を見れば、エディタは横方向にはアプリ編集画面で作ったスペース要素の幅にちゃんと拡がっており、意図通りの広さを確保できているのが分かります。
つまり縦方向に足りていないだけ。
であれば CSS でなんとかしてやればいいだけです。
// ...省略
<style lang="scss" scoped>
.editor-container {
width: 100%;
.editor-body {
height: 100%;
}
}
</style>
あと、マウントする親要素を flexbox にしてやりましょう。
子要素を高さいっぱいに拡げるためです。
// ...省略
/**
* レコード作成・編集時イベント処理
*/
kintone.events.on(
["app.record.create.show", "app.record.edit.show", "app.record.detail.show"],
(event: typeof kintoneEvent) => {
// マウント対象の要素
const elem = kintone.app.record.getSpaceElement(
"editor-space"
) as HTMLElement;
// マウント対象の親要素にスタイルをつける
if (elem.parentElement) {
elem.parentElement.style.display = "flex";
}
// マウント
new Vue({
render: (h) => h(App),
}).$mount(elem);
return event;
}
);
そしたらまたビルドして出来上がった js と css をアプリに適用。
うん、いいですね!
これでスペースを広く使うポゼッション志向のサッカーみたいなエディタを体現する事ができました。
XXXX をなんとかする
これで卑猥なワードとか言われちゃうともうちょっとお客さん歪みすぎじゃないですかと言う気がしますが、 kintone は全年齢対応なのでなんとかします。
そもそもこの不健全さの理由は何か。
ここは本来ツールバーアイコンが並ぶ場所です。kintone で言うところのリッチエディターの上にある太字とか斜体とか下線とかのアレですね。
けど、それが文字化けしていてこんないかがわしい文字列になってしまっている。
なんでこんな風になっているかをつぶさに見ていくと、開発ツールの Network にいくつか 404
が出ているのが見つかります。
アイコンフォントのファイルが存在しませんぜ。と言うエラーですね。
実際、CSS ファイルでも @font-face
でこれらを参照している記述があります。
@font-face {
font-family: fontello;
src: url(../fonts/fontello.e73a0647.eot);
src: url(../fonts/fontello.e73a0647.eot#iefix) format("embedded-opentype"),
url(../fonts/fontello.8d4a4e6f.woff2) format("woff2"),
url(../fonts/fontello.a782baa8.woff) format("woff"),
url(../fonts/fontello.068ca2b3.ttf) format("truetype"),
url(../img/fontello.9354499c.svg#fontello) format("svg");
font-weight: 400;
font-style: normal;
}
と言う事は話は単純。
これらのファイルを kintone に適用(アップロード)すれば良いだけです。簡単ですね!
・・・簡単か?
そう。kintone ではサービスの仕様上、アプリに適用できるのは JavaScript ファイルか CSS ファイルだけです。
フォントファイルの実体である .woff
とか .woff2
とかをアップロードする事はまかりならぬ。
ならば CSS を CDN とかの外部参照にするとか?
それはそれで外部依存になっちゃうのはなんかフレキシビリティを失うようで嫌ですよね。
どうしましょう・・・。
ここは Webpack
先輩の出番です!
フォントファイルを適用できないのなら CSS の中にアイコンフォントを含めてしまえば良いじゃない。
筆者の中のマリーがそう呟く。
Vue CLI
で作成したプロジェクトはビルド時に Webpack
先輩のお世話になっています。うまく隠蔽されているだけで。
Webpack
の設定ファイルなんかひとつもないですけど、Vue CLI
さんが先輩との仲を取り持ってすごく上手くやってくれています。
そんなたいそうできるやつであるところの Vue CLI
さん経由で先輩にもう 1 つ仕事を頼んでもらいましょう。
真面目な話。
Vue CLI
で作成したプロジェクトでは、ビルド時の動作などをより細かくカスタマイズしたい場合、プロジェクト直下に vue.config.js
と言うファイルを配置する事ができます。
従来 webpack.config.js
に記述していたような事もこのファイルに書けます。
具体的に見てみましょう。
module.exports = {
publicPath: "/",
filenameHashing: false,
pages: {
app: {
entry: "src/main.ts",
template: "public/index.html",
filename: "main.html"
}
},
chainWebpack: config => {
config.module
.rule("fonts")
.test(/\.(woff2?|eot|ttf|otf)(\?.*)?$/)
.use("url-loader")
.loader("url-loader")
.options({
limit: 1000000,
name: "fonts/[name].[ext]"
});
}
};
chainWebpack
以下の部分に注目です。
拡張子が woff
や woff2
、eot
などフォントのものである場合、url-loader
を使ってコードの中に取り込んで下さいね、と言う記述です。
あと、ついでにビルドした時のファイル名のハッシュ部が出ないようにもしています。
(filenameHashing: false
の部分)
vue.config.js
は置いておくだけでビルド時に反映されます。
これでもう一度ビルドしてみましょう。
で、ビルドして出来上がった dist/js/app.js
と dist/css/app.css
をアプリに適用します。
改めてアプリ編集画面を見ると・・・
ベネ。(よし)
これで小さなお子さんにも安心の健全さを取り戻しました。
入力値が保存できない問題
これはもう酷すぎるにも程がありますよね。
例えて言うなら内蔵電池がすっかり消耗し切った状態で FC 版のドラクエ III をプレイするが如しです。
どんだけ頑張ってもリセットした瞬間にデータが消えてしまうんです。縛りプレイか?
レコード編集画面に表示されている Markdown エディタは、単にスペースフィールドをコンテナにして入力フォームを表示しているに過ぎません。
ここで入力したテキストは kintone とは無関係であって、レコードを保存したからどうなると言うわけではないのです。
なので、お客さんのご指摘通り、このエディタで入力した値をアプリ上に配置した 本文 フィールドと繋げてやらねばならないと言う事になりますね。
mavonEditor
は API が非常に充実しており、コンポーネント上でなんらかの操作をするとそれに対応したイベントが走るような仕掛けになっています。
今回は、エディタにテキストを入力した際、つまり change
イベントを拾って、本文フィールドに値を反映させるアプローチにしましょう。
<template>
<div id="app" class="editor-container">
<mavon-editor
class="editor-body"
:ishljs="true"
:language="'ja'"
@change="handleChange"
></mavon-editor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
// レコード操作ライブラリ
import { setTextValue } from "./record";
export default Vue.extend({
name: "App",
/**
* メソッド
*/
methods: {
/**
* テキスト編集時処理
*/
handleChange(value: string) {
// 入力値をレコードに反映させる
setTextValue(value, "本文");
},
},
});
</script>
// ...省略
レコード操作をするためのライブラリスクリプト src/record.ts
は以下のようにします。
/**
* レコードに値をセットする
*/
export const setTextValue = (value: string, field: string) => {
const record = kintone.app.record.get();
record.record[field].value = value;
kintone.app.record.set(record);
};
レコードを取得して、フィールドに値を格納して、またセットすると言う内容です。
そしたらまたビルドして出来上がったものをアプリに適用してレコード追加画面を表示してー。
はい!どうですお客さん!
エディタペインに入力したテキストが即座にプレビュー画面に反映されますし、同時に下の本文フィールドにも反映されているのがお分かりいただけたかと思います。
当然、レコード保存すればその内容が本文に記録されますし、次に同じレコードの編集を始めればエディタには最初からそのテキストが・・・
入ってないじゃないですか。
あっハイ・・・。
レコード編集時に保存した本文テキストが復元できない問題
追加の issue 報告が上がって来てしまいました。
と言っても、まあこれは当然です。
そもそも mavon-editor
コンポーネントに初期値として値を渡し忘れてるわけですからね。
それでは、次にそこを改善します。
main.ts
の App
コンポーネントをマウントする箇所でレコードの本文フィールドの値をプロパティで渡してやる事にします。
// マウント
new Vue({
render: (h) => h(App, { props: { srcText: event.record["本文"].value } })
}).$mount(elem);
App.vue
ではプロパティを受け取るとともに、描画用の値をデータ値として用意します。(props
でもらった値は編集してはいけないので)
<template>
<div id="app" class="editor-container">
<mavon-editor
v-model="editorText"
class="editor-body"
:ishljs="true"
:language="'ja'"
@change="handleChange"
></mavon-editor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
// レコード操作ライブラリ
import { setTextValue } from "./record";
export default Vue.extend({
name: "App",
/**
* プロパティ
*/
props: {
srcText: {
type: String,
required: true,
default: ""
}
},
/**
* データ
*/
data() {
return {
editorText: this.srcText
};
},
/**
* メソッド
*/
methods: {
/**
* テキスト編集時処理
*/
handleChange(value: string) {
// 入力値をレコードに反映させる
setTextValue(value, "本文");
}
}
});
</script>
// ...省略
proos
で受け取った srcText
を data
で editorText
に格納し、mavon-editor
コンポーネントではこの値を v-model
で見ていると言う事になりますね。
これでビルドしてアプリに適用してレコード編集画面を。
うん、今度はいいですね!
ちゃんと猫のすごさも伝わりますね!
ところで。
はい? お客さんなんですかまだなんかあるんですか?
レコード追加画面とか編集画面でエディタが表示されるのは良いんですけど、
レコード詳細画面でも表示されるのっておかしくないですか?
・・・おっ・・・
まるでレコード編集画面に行かなくとも詳細画面から内容編集できるみたいに見えますよね?
お客さん鋭いなあ・・・。
さらに言うとツールバーの右端の保存アイコンてなんか意味あるんです?
ここを押したからと言って kintone のレコードが保存されるわけではないですよね?
更なるダメ出し!
お客さん意外に辛口です!
細かいところを修正する
レコード詳細画面の時にはエディタペインは表示されず、右側のプレビューペインだけが表示される形があるべき姿と言えるでしょう。
そのためには、main.ts
から「今は編集画面ですよ」「今は詳細画面ですよ」と言うことを識別できる値を渡してやる必要があると言う事になります。
これは単純に kintone.events.on
ハンドラで貰う event
オブジェクトの type
の値が detail.show
で終わっていれば詳細画面、そうでなければ編集画面と言う判断で良いでしょう。
isEdit
と言うプロパティを App
コンポーネントに引き渡してやります。
// マウント
new Vue({
render: (h) =>
h(App, {
props: {
srcText: event.record["本文"].value,
isEdit: !event.type.endsWith("detail.show")
}
})
}).$mount(elem);
App.vue
側ではこれを props
で受け取ってコンポーネントの表示に反映させます。
mavon-editor
のドキュメントによれば、 defaultOpen
プロパティでどちらのペインをメインとするかを決め、 subfield
プロパティでメインでない方のペインを表示するかしないかを指定すると言う仕様のようです。
(ただし defaultOpen
に edit
を指定してしまうと subfield
を true
にしても初期状態ではエディタしか表示されない模様。なので両方表示したい場合は空文字が適切らしい)
また、レコード詳細画面ではツールバーボタンが表示されている意味はありません。編集しないわけですからね。
これは toolbarsFlag
プロパティでツールバー全体の表示非表示を切り替えられるようです。
なのでまとめると、以下のような値の組み合わせになりますね。
イベント(画面) | defaultOpen | subfield | toolbarsFlag |
---|---|---|---|
レコード詳細画面 | preview | false | false |
レコード編集・追加画面 | (空文字) | true | true |
コードに落とし込むとこんな感じに。
<template>
<div id="app" class="editor-container">
<mavon-editor
v-model="editorText"
class="editor-body"
:ishljs="true"
:language="'ja'"
:defaultOpen="isEdit ? '' : 'preview'"
:subfield="isEdit"
:toolbarsFlag="isEdit"
@change="handleChange"
></mavon-editor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
// レコード操作ライブラリ
import { setTextValue } from "./record";
export default Vue.extend({
name: "App",
/**
* プロパティ
*/
props: {
srcText: {
type: String,
required: true,
default: "",
},
isEdit: {
type: Boolean,
required: true,
},
},
// ...省略
});
</script>
// ...省略
ここまでの修正を再度適用!
良いじゃないですか、良いじゃないですか!
猫のすごさがプリミティブに訴えかけられてきてる感すごい出てますね!
最後にもうひとつ、編集画面での上のツールバーの保存ボタン不要論に対応しておきましょう。
これもまたドキュメントに記載がある通り、 toolbars
プロパティで表示したいボタンを選ぶ仕様。
未指定だと全部表示されますが、1 つでも指定するとそれ以外は非表示になると言う動作のようです。
今回消したいのは保存ボタンと何かと事故を招きかねないゴミ箱ボタンの 2 つだけなので、それ以外は表示します。
<template>
<div id="app" class="editor-container">
<mavon-editor
v-model="editorText"
class="editor-body"
:ishljs="true"
:language="'ja'"
:defaultOpen="isEdit ? '' : 'preview'"
:subfield="isEdit"
:toolbarsFlag="isEdit"
:toolbars="toolbars"
@change="handleChange"
></mavon-editor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
// レコード操作ライブラリ
import { setTextValue } from "./record";
export default Vue.extend({
name: "App",
// ...省略
/**
* データ
*/
data() {
return {
editorText: this.srcText,
toolbars: {
bold: true,
italic: true,
header: true,
underline: true,
strikethrough: true,
mark: true,
superscript: true,
subscript: true,
quote: true,
ol: true,
ul: true,
link: true,
imagelink: true,
code: true,
table: true,
fullscreen: true,
readmodel: true,
htmlcode: true,
help: true,
undo: true,
redo: true,
navigation: true,
alignleft: true,
aligncenter: true,
alignright: true,
subfield: true,
preview: true,
},
};
},
// ...省略
});
</script>
// ...省略
うん、良いですね!
ゴミ箱ボタンと保存ボタンを隠す事ができました。
これで完璧でしょ? 完成でしょお客さん!
いざ使い始めてみると・・・
あのですね。
え、お客さんまだなんかありますか?
これを見て。
おっ・・・
分かります?
調子乗って長い文章書くと、エディタがどんどん縦に長くなってくの。
・・・
これダメだよね?
・・・ダメ・・・ですね・・・。
できればエディタの高さは固定で、インラインでスクロールして欲しい。
ご要望はもっともです。
これはマウントする親要素にスタイル適用で対応しましょう。
// ...省略
/**
* レコード作成・編集時イベント処理
*/
kintone.events.on(
["app.record.create.show", "app.record.edit.show", "app.record.detail.show"],
(event: typeof kintoneEvent) => {
// マウント対象の要素
const elem = kintone.app.record.getSpaceElement(
"editor-space"
) as HTMLElement;
// マウント対象の親要素にスタイルをつける
if (elem.parentElement) {
elem.parentElement.style.display = "flex";
elem.parentElement.style.height = elem.parentElement.style.minHeight;
elem.parentElement.style.overflowY = "scroll";
}
// ...省略
}
);
これでどうですか!
うん、良いようですね! ちゃんと高さ固定で内部スクロールするようになりました!
これで調子乗ってどんだけ長いこと書いても大丈夫ですよお客さん!
ハァ?
あ、いえ、なんでもないです。
今度こそ完成!
じゃあ、良いですよねお客さん?
これで納品って事で良いですね?
それじゃお客さんの運用環境のアプリに・・・
・・・あのさぁ。
・・・はい?
・・・画像、差し込めない?
えっ。
いや、Qiita でもそうだけど、画像をドラッグ&ドロップしたら勝手にアップロードされて
インラインでうまく表示できたりするじゃない。
このエディタ画面でも上のツールバーに画像差し込みボタンあるしさぁ。
アプリに添付ファイルフィールドをつけて、ドラッグ&ドロップで画像を受け付けたりとか
そう言うの API 的なアレでちゃちゃっとできたりしないの?
・・・お客さん。
?
もう予算ないです。
おっおう・・・。
無事完パケです!!!
ファイル添付についてもう少し真面目に。
現実的に考えて、お客さんが言うファイル添付はニーズとしては非常に高そうです。
実際 mavon-editor
でも画像差し込み機能はあって、ファイルを受け取ったら dataURL
形式に変換してプレビュー表示に使用している実装になっている。
ただそれをサーバ上に置くなど永続化の手段を講じるのは実装者側に委ねられる(そのためのイベント imgAdd
が発火する)と言う線引きのようです。
お客さんの望みを最大限汲めば、この imgAdd
イベントで kintone API を使ってレコードに画像を追加すると言うアイデアは考えつきます。
しかし kintone の仕様上、API で画像をアップロードした時に返却されるファイルキーはダウンロード(=画像の表示)時に使うことはできず、レコードを REST API で保存する時に使えるだけです。
(つまり JavaScript API である kintone.app.record.set()
で使うことはできない・・・はず。恐らくは。)
そのため、この辺の取り回しで結構テクニカルな事が要求され、お客さんが望むような「画像をドラッグ&ドロップしたらプレビュー画面で閲覧できると共に kintone レコードにも首尾良く保存」と言うのは存外難しい。かなりのハマりポイントになる恐れありです。
そこらへんまで出来れば Markdows エディタ on kintone って事で商売の種にもなりそうには思っていますがね。
この辺りは筆者としても今後も少しなんとかならないかと思っている部分ではあります。
またシンタックスハイライトの部分とか、添付ファイルのインライン表示とか、今回の mavon-editor
を使うのとは違うアプローチで取り組んでいたりもします。
そのうちプラグインとして仕立てて売り物になれば良いなとは思っておりますが!
お客さんがいるかどうかは分かりませんが!
今回の成果物
この記事で書いたそのまんまではありませんが、ほとんど同じようなことをやっているソースは以下に置いてあります。
https://github.com/iShinkai/kintone-markdown
- 「本文」フィールドを非表示にしている(どうせコンポーネントで表示されるので)
- 各種フィールド設定等は外部ファイルにまとめている
などの違いがあります。
クローンして設定ファイルをお好みに直してビルドすればすぐ使えると思います。
終わりに
と言う事で、全エンジニアの望みであるところの kintone での Markdown と言う、まあある種誰でも思いつく希望の一部を叶える道筋を示してみました。
まあ正直なところ、こんなカスタマイズに頼る事なく、標準機能として Markdown が使えるようになればそっちの方がよほど素晴らしいのですがね。
アプリのフィールドだけでなく、レコードコメントやスレッドコメントなんかでも Markdown で入力できる世界が来たら素晴らしいですよね。
そうするとエンジニア向けの度合いが強まってしまうとかそう言う意図もあるのかもですが。
お読みいただきありがとうございました!
明日は @spica さんの出番です!