
はじめに
こんにちは。GameWith のエンジニアの tiwu です。
本ブログは GameWith アドベントカレンダーの2日目になります!?(全然間に合いませんでした)
本ブログでは GameWith のダッシュボードに導入されている Froala について様々なサンプルコードと共に解説していこうと思います。
Froala とは
Froala は高機能な WYSIWYG エディターです(ビジュアルエディターです)
テキストの加工、リストやテーブルなどのリッチコンテンツ、画像のアップロードなど様々な機能がプラグインとして用意されています。
また、Vue や React など様々なフレームワークに対応しています。
以下のデモ画面から試すことができるので気になった方は是非試してみてください。
導入
GameWith のダッシュボードは一部ページに Vue が導入されています。
導入の経緯については以下をご覧ください。
そのため以下の Vue 用の Froala を利用して導入しました。
@vue/cli-service を利用しているダッシュボードの Vue のリポジトリでは以下のように vue-froala-wysiwyg を利用しています。
main.ts
import Vue from 'vue'; import 'froala-editor/js/plugins.pkgd.min.js'; import 'froala-editor/css/froala_editor.pkgd.min.css'; import VueFroala from 'vue-froala-wysiwyg'; Vue.use(VueFroala); new Vue({ render: h => h(App) }).$mount('#app');
froala.vue
<template> <div> <froala tag="textarea" id="body" name="body" :config="config" v-model="body"></froala> </div> </template> <script lang="ts"> import { defineComponent } from '@vue/composition-api'; import useFroala from '@/composition/use-froala'; export default defineComponent({ setup() { const { body, config, } = useFroala(); return { body, config, }; } }); </script>
use-froala.ts
import { reactive, toRefs } from '@vue/composition-api'; import FroalaEditor from 'froala-editor'; const useVisual = () => { const config: Partial<FroalaEditor.FroalaOptions> = { key: process.env.VUE_APP_FROALA_KEY, }; const state = reactive({ body: '', }); return { ...toRefs(state), config, }; }
TypeScript
Froala には型定義ファイルが用意されていなので以下のユーザーによる型定義ファイルを参考に自作して利用しています。
Tips
Froala は様々なカスタマイズができるので一部紹介していきます。
カスタムボタンの実装
独自の挙動をするボタンを以下のページを参考に数多く実装しています。
サンプル
以下のサンプルは選択中の要素を H2 に切り替えるボタンです。
import FroalaEditor from 'froala-editor'; const isActive = (editorInstance: FroalaEditor.FroalaEditor): boolean => { const element = editorInstance.selection.element(); return element.tagName.toLowerCase() === 'h2'; }; // 表示するアイコンを設定 // ビジュアルエディタ上に H2 というテキストのボタンが表示されます FroalaEditor.DefineIcon('H2', { NAME: 'H2', template: 'text' }); FroalaEditor.RegisterCommand('H2', { title: 'H2', // ボタンをクリックした際に呼ばれる処理 callback: function(): void { if (isActive(this)) { // H2 要素であれば通常に戻す(p 要素になる) this.paragraphFormat.apply('N'); } else { // H2 要素でなければ H2 要素にする this.paragraphFormat.apply('h2'); } }, // 選択した要素が H2 であればボタンがアクティブになる refresh: function(btn): void { btn.toggleClass('fr-active', isActive(this)); } });
paragraphFormat.apply は Froala のプラグインに用意されている関数で段落の変更が可能です。
テキスト上で H2 ボタンをクリックすることで

テキストの要素が H2 になり、ボタンがアクティブになります。
この状態で再び H2 をクリックすると通常のテキストに戻ります。

カスタムドロップダウンの実装
サンプル
以下のサンプルは要素の上下移動のドロップダウンです。
import FroalaEditor from 'froala-editor'; // ドロップダウンで表示する選択肢 const options = { 'top': '上に移動', 'bottom': '下に移動', } as const; FroalaEditor.DefineIcon('要素の移動', { Name: '要素の移動', template: 'text' }); FroalaEditor.RegisterCommand('要素の移動', { title: '要素の移動', type: 'dropdown', // ドロップダウンで表示する選択肢を設定 options: options, // ドロップダウンの選択肢がクリックされた際に呼ばれる処理 // val に options で指定したキーが入っている // val = 'top' | 'bottom' callback: function(cmd: string, val: string): void { // oprions から val = 'top' | 'bottom' の型を取り出す switch(val as keyof typeof options) { case 'top': // 上に移動する処理 break; case 'bottom': // 下に移動する処理 break; } }, });
ドロップダウンをクリックすると

options で指定した内容が表示されます。

カスタムポップアップの実装
サンプル
上記サンプルで作った H2 の切り替えボタンを表示するポップアップを作ってみます。
公式に載っているのはツールバーのボタンから呼び出すポップアップですが、今回のサンプルは以下のリンクのメニューのようなエディタ内に表示されるようにしてみます。

import FroalaEditor, { CustomClickPlugin, Popups } from 'froala-editor'; FroalaEditor.POPUP_TEMPLATES['customParagraph.popup'] = '[_BUTTONS_]'; FroalaEditor.PLUGINS.customParagraph = (editor: FroalaEditor.FroalaEditor): CustomClickPlugin => { // ポップアップの初期化 function initPopup(): ReturnType<Popups['create']> { // ポップアップ内の HTML を作る let buttons = ''; buttons += '<div class="fr-buttons">'; buttons += editor.button.buildList(['H2']); buttons += '</div>'; // [_BUTTONS_] に引数の buttons の内容がレンダリングされる return editor.popups.create('customParagraph.popup', { buttons, }); } // ポップアップを表示する const showPopup = (clickEvent: JQuery.TriggeredEvent): void => { if (!clickEvent.pageX || !clickEvent.pageY) { return; } // ポップアップの初期化をする let $popup = editor.popups.get('customParagraph.popup'); if (!$popup) $popup = initPopup(); // ポップアップの親を指定する editor.popups.setContainer('customParagraph.popup', editor.$sc); // ポップアップを表示させる editor.popups.show('customParagraph.popup', clickEvent.pageX, clickEvent.pageY, target.offsetHeight); }; // ポップアップを非表示にする const hidePopup = (): void => { editor.popups.hide('customParagraph.popup'); }; return { showPopup, hidePopup }; }; const useVisual = () => { const config: Partial<FroalaEditor.FroalaOptions> = { key: process.env.VUE_APP_FROALA_KEY, events : { click : function(clickEvent) { // クリック時にポップアップの表示関数を実行する this.customParagraph.showPopup(clickEvent); } } }; return { config, }; }
クリック時にポップアップの表示処理をすることでテキストをクリック時にポップアップが表示され

H2 の切り替えをすることができました。

カスタムアイコンの実装
デフォルトで様々なタイプのアイコンを利用することができますが、カスタムアイコンを作ることも可能です。
サンプル
// デフォルトは viewBox="0 0 24 24" になっている FroalaEditor.DefineIconTemplate('svg_16', '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="[PATH]"></path></svg>'); // パスを直接記入する FroalaEditor.DefineIcon('要素の移動:上', { PATH: 'M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z', template: 'svg_16' }); // デフォルトだと class の追加ができない FroalaEditor.DefineIconTemplate('class_text', '<span class="[CLASS]">[NAME]</span>'); roalaEditor.DefineIcon('H2', { NAME: 'H2', CLASS: 'h2-icon', template: 'class_text' });
絶妙に手の届かないケースはカスタムアイコンで対応をしています。
メソッドの紹介
Froala には様々なメソッドが用意されているので全部は紹介できないですが、よく使うメソッドを紹介しようと思います。
取得系
html.get
記述した HTML を全て取得する
https://froala.com/wysiwyg-editor/docs/methods/#html.get
html.getSelected
選択中の HTML を取得する
https://froala.com/wysiwyg-editor/docs/methods/#html.getSelected
image.get
選択中の画像を取得する
https://froala.com/wysiwyg-editor/docs/methods/#image.get
selection.blocks
選択中のブロック要素を全て取得する
https://froala.com/wysiwyg-editor/docs/methods/#selection.blocks
selection.element
選択中の要素を1つ取得する
https://froala.com/wysiwyg-editor/docs/methods/#selection.element
selection.ranges
選択中の Range を取得する
https://froala.com/wysiwyg-editor/docs/methods/#selection.ranges
selection.text
選択中のテキストを取得する
https://froala.com/wysiwyg-editor/docs/methods/#selection.text
挿入系
html.insert
キャレットの位置に HTML を挿入する
https://froala.com/wysiwyg-editor/docs/methods/#html.insert
html.set
引数の HTML で上書きする
https://froala.com/wysiwyg-editor/docs/methods/#html.set
テーブル系
テーブル系のメソッドが公式サイトだと table.insert しか載ってないのですが、実際はいくつか存在するので紹介します。
https://froala.com/wysiwyg-editor/docs/methods/#table.insert
よく使うのは以下の取得系です。
table.selectedTable
選択中のテーブルを取得する
table.selectedCells
選択中のセルを取得する
他にもいろいろメソッドが存在するようです。
addFooteraddHeaderapplyStylebackcustomColordeleteColumndeleteRowhorizontalAligninsertColumninsertRowmergeCellsremoveremoveFooterremoveHeaderselectCellssetBackgroundshowColorsPopupshowEditPopupshowInsertPopupsplitCellHorizontallysplitCellVerticallyverticalAlign
その他
selection.save
現在のキャレットを保存する
https://froala.com/wysiwyg-editor/docs/methods/#selection.save
selection.restore
保存したキャレットの位置を復元する
https://froala.com/wysiwyg-editor/docs/methods/#selection.restore
オプションの紹介
実際に設定しているオプションをいくつか紹介します。
画像系
imageUploadURL
画像のアップロード先の URL です
https://froala.com/wysiwyg-editor/docs/options/#imageUploadURL
imageUploadParams
画像のアップロード URL を叩く時に渡すパラメーターです
https://froala.com/wysiwyg-editor/docs/options/#imageUploadParams
imageEditButtons
画像をクリックした際に表示されるボタンを指定できます
https://froala.com/wysiwyg-editor/docs/options/#imageEditButtons
その他
language
言語の変更ができます
https://froala.com/wysiwyg-editor/docs/options/#language
日本語の対応表は用意されていますが、コピーしてカスタマイズしたものを利用しています。
toolbarButtons
ツールバーに表示されるボタンを指定できます
https://froala.com/wysiwyg-editor/docs/options/#toolbarButtons
htmlAllowedAttrs
要素に記述できる属性を指定できます
全て許可する時は .* のように正規表現的に書くことができます
https://froala.com/wysiwyg-editor/docs/options/#htmlAllowedAttrs
htmlAllowedTags
記述できる要素を指定できます
全て許可する時は .* のように正規表現的に書くことができます
https://froala.com/wysiwyg-editor/docs/options/#htmlAllowedTags
htmlAllowedEmptyTags
中身が空でも削除しない要素を指定できます
https://froala.com/wysiwyg-editor/docs/options/#htmlAllowedEmptyTags
lineBreakerTags
改行ボタンを表示する要素を指定できます
https://froala.com/wysiwyg-editor/docs/options/#lineBreakerTags
イベントの紹介
contentChanged
エディタ内に変更があった際に発火します
https://froala.com/wysiwyg-editor/docs/events/#contentChanged
image.inserted
画像の挿入後に発火します
挿入された画像にクラスを追加したりなど操作する時に使えます
https://froala.com/wysiwyg-editor/docs/events/#image.inserted
image.beforeRemove
画像の削除前に発火します
画像関連で他の要素を追加などしていると単純に画像を削除するだけだとその要素が残ってしまうので、画像の削除前にその要素を消したりする時に使っています
https://froala.com/wysiwyg-editor/docs/events/#image.beforeRemove
ビジュアルエディタを実装する時に知っていると良い知識
自分がビジュアルエディタの実装を進めていく中で、知っているとスムーズに開発が進むと感じた知識を紹介します。
contenteditable
ビジュアルエディタのコアである contenteditable です。
この属性を追加した要素は編集可能になります。
おそらく世のビジュアルエディタライブラリは contenteditable を利用していると思われます。
Range と Selection
Range は文章の範囲を表すオブジェクトです。
Selection 範囲やキャレットを扱うオブジェクトです。
Range と Selection は関係性がわかりにくいのでサンプルコードを記載します。
H1 要素を範囲選択する
// Selection を取得 const selection = window.getSelection(); // 現在の選択している範囲を全て削除 selection.removeAllRanges(); // h1 を取得 const h1 = document.querySelector('h1'); // Range を取得 const range = document.createRange(); // Range に h1 を設定 range.selectNode(h1); // Range を Selection に設定 selection.addRange(range);
上記は H1 要素を範囲選択するスクリプトです。
例えば https://gamewith.jp/ でスクリプトを実行すると左上の H1 が選択されます。

H1 要素を a 要素で囲む
// h1 を取得 const h1 = document.querySelector('h1'); // Range を取得 const range = document.createRange(); // Range に h1 を設定 range.selectNode(h1); // h1 の中身をコピーする const cloned = range.cloneContents(); // リンクを作る const a = document.createElement('a'); // リンクの子要素に H1 の HTML を挿入する a.appendChild(cloned); // 遷移先の設定 a.href = 'https://froala.com/wysiwyg-editor/'; // 選択中の内容を削除 range.deleteContents(); // 選択中の内容を a で囲った H1 を挿入することで置換を実現する range.insertNode(a);
Range を利用することで任意の要素を任意の要素で囲むことができます。
https://gamewith.jp/ でスクリプトを実行すると左上の H1 が a 要素で囲まれます。

範囲選択している要素を a 要素で囲む
Froala の selection.ranges メソッドを組み合わせれば範囲選択した要素を a 要素で囲むことができます。
// 選択中の Range を取得 const range = selection.ranges(0); // 選択中の HTML をコピーする const cloned = range.cloneContents(); // リンクを作る const a = document.createElement('a'); // リンクの子要素に選択中の HTML を挿入する a.appendChild(cloned); // 遷移先の設定 a.href = 'https://froala.com/wysiwyg-editor/'; // 選択中の内容を削除 range.deleteContents(); // 選択中の内容を a で囲った HTML を挿入することで置換を実現する range.insertNode(a);
caret (キャレット)
キャレット (テキストカーソルとも呼ばれる) は、テキスト入力が挿入される場所を示すために画面に表示されるインジケーターです
PC やスマホではよく見るテキストを入力する位置を示す細い縦線ですが、キャレットと名前がついているのは実装を通して初めて知りました。
このキャレットは実装中によく登場します。
キャレットの保存と復元
例えば Froala にリンクを挿入するモーダルの実装を例に考えてみます。
以下のように「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」の間にキャレットを置いた状態でモーダルを表示します。

モーダル表示後にリンクやテキストを入力します。
この時点でキャレットが「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」の間から、リンクやテキストの入力フォームに移動しています。

この状態で Froala に用意されている html.insert を利用して挿入するとキャレットの位置情報は失われているので最後尾に挿入されます。

処理のフローは
- 「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」にキャレットがある
- モーダルを開く
- モーダル内のリンクやテキストに入力をする
- キャレットがモーダル内の入力フォームに移動する
- 挿入ボタンを押す
- モーダルが閉じる
html.insertを利用しリンクを挿入する- キャレットによる位置情報は失われているので末尾に挿入される
となり、コードは以下になります。
const openModal = () => { // モーダルを開く } const closeModal = () => { // モーダルを閉じる } // モーダルを開くボタン const clickOpen = () => { openModal(); } // リンク挿入ボタン const insertLink = () => { closeModal(); html.insert('<a href="https://froala.com/wysiwyg-editor">リンクのテキスト</a>'); }
ユーザーの想定する挙動としては「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」の間への挿入だと思います。
この仕様を実現するために紹介した selection.save と selection.restore メソッドを利用します。
- 「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」にキャレットがある
- モーダルを開く前に
selection.saveを実行しキャレットの位置を保存する - モーダルを開く
- モーダル内のリンクやテキストに入力をする
- キャレットがモーダル内の入力フォームに移動する
- 挿入ボタンを押す
- モーダルを閉じる前に
selection.restoreを実行し保存していたキャレットの位置を復元する - モーダルが閉じる
html.insertを利用しリンクを挿入する- 「このテキストの後ろに挿入をしたい。」と「このテキストの前に文章を入れたい。」の間にキャレットがあるので末尾ではなく間に挿入される
コードは以下になります。
const openModal = () => { // モーダルを開く } const closeModal = () => { // モーダルを閉じる } // モーダルを開くボタン const clickOpen = () => { // キャレットを保存 selection.save(); openModal(); } // リンク挿入ボタン const insertLink = () => { // 保存したキャレットを復元 selection.restore(); closeModal(); html.insert('<a href="https://froala.com/wysiwyg-editor">リンクのテキスト</a>'); }
このようにキャレットが移動する前後で保存・復元をすることで想定通りの箇所に挿入することができます!
インライン要素のキャレット
インライン要素への書き込みの際もキャレットを意識する必要があります。
まず Froala の a 要素の挙動について解説します。
a 要素の末尾にキャレットがある場合

テキストを入力すると a 要素の外側に入力されます。
そのため a 要素の内側にテキストを入力することができません。

次に a 要素以外のインライン要素(span や strong など)の挙動について解説します。
strong 要素の末尾にキャレットがある場合

テキストを入力すると strong 要素の内側に入力されます。
そのため a 要素とは逆に外側にテキストを入力することができません。

a 要素の内側へのテキストの入力、a 要素以外のインライン要素の外側へのテキストの入力を実現するためには、キャレットとゼロ幅ノーブレークスペース(\uFEFF)を組み合わせます。
ゼロ幅ノーブレークスペースはホワイトスペースの1つでその名の通り幅を持ちません。
このゼロ幅ノーブレークスペースをインライン要素の末尾に追加することでタグ内と外への書き込みを実現します。
a 要素
a 要素の末尾にゼロ幅ノーブレークスペース(\uFEFF)を挿入することで、「a 要素内への書き込み」と「a 要素外への書き込み」の切り替えができるようになります。
<a>hoge\uFEFF</a>fuga
インスペクタ上では  と表示されます。

e とゼロ幅ノーブレークスペース(\uFEFF)の間にキャレットがある時は「a 要素内への書き込み」になり、
ゼロ幅ノーブレークスペース(\uFEFF)と f の間にキャレットがある時は「a 要素外への書き込み」になります。
const element = document.querySelector('a'); element.innerHTML += '\uFEFF';
a 要素に対して innerHTML を利用し \uFEFF を追加するだけで実現できます。
a 要素以外のインライン要素
a 要素以外のインライン要素の外にゼロ幅ノーブレークスペース(\uFEFF)を追加することで、「a 要素以外のインライン要素内への書き込み」と「a 要素以外のインライン要素外への書き込み」の切り替えができるようになります。
<strong>hoge</strong>\uFEFFfuga
インスペクタ上では  と表示されます。

e とゼロ幅ノーブレークスペース(\uFEFF)の間にキャレットがある時は「a 要素以外のインライン要素内への書き込み」になり、
ゼロ幅ノーブレークスペース(\uFEFF)と f の間にキャレットがある時は「a 要素以外のインライン要素外への書き込み」になります。
const newText = document.createTextNode('\uFEFF'); const element = document.querySelector('strong'); element.after(newText);
a 以外のインライン要素に対して after を利用し \uFEFF のテキストノードを追加するだけで実現できます。
まとめと終わり
Froalaは様々なフレームワークに対応している高機能な WYSIWYG エディター(ビジュアルエディター)- 公式サイトのサンプルコードやドキュメントがかなり充実している
- カスタマイズをする際に
contenteditable,Range,Selection, キャレットなど知っていると良い
引き続き便利なビジュアルエディタを開発し、最高の記事を最速にユーザーに届くよう尽力していきます!