GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

Froala 徹底解説! #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です。

本ブログは GameWith アドベントカレンダーの2日目になります!?(全然間に合いませんでした)

qiita.com

本ブログでは GameWith のダッシュボードに導入されている Froala について様々なサンプルコードと共に解説していこうと思います。

Froala とは

Froala は高機能な WYSIWYG エディターです(ビジュアルエディターです)

froala.com

テキストの加工、リストやテーブルなどのリッチコンテンツ、画像のアップロードなど様々な機能がプラグインとして用意されています。

また、VueReact など様々なフレームワークに対応しています。

froala.com

以下のデモ画面から試すことができるので気になった方は是非試してみてください。

froala.com

導入

GameWith のダッシュボードは一部ページに Vue が導入されています。

導入の経緯については以下をご覧ください。

tech.gamewith.co.jp

そのため以下の Vue 用の Froala を利用して導入しました。

github.com

@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 には型定義ファイルが用意されていなので以下のユーザーによる型定義ファイルを参考に自作して利用しています。

github.com

Tips

Froala は様々なカスタマイズができるので一部紹介していきます。

カスタムボタンの実装

独自の挙動をするボタンを以下のページを参考に数多く実装しています。

froala.com

froala.com

サンプル

以下のサンプルは選択中の要素を 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.applyFroala のプラグインに用意されている関数で段落の変更が可能です。

froala.com

テキスト上で H2 ボタンをクリックすることで

テキストの要素が H2 になり、ボタンがアクティブになります。

この状態で再び H2 をクリックすると通常のテキストに戻ります。

カスタムドロップダウンの実装

froala.com

froala.com

サンプル

以下のサンプルは要素の上下移動のドロップダウンです。

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 で指定した内容が表示されます。

カスタムポップアップの実装

froala.com

froala.com

サンプル

上記サンプルで作った 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 の切り替えをすることができました。

カスタムアイコンの実装

デフォルトで様々なタイプのアイコンを利用することができますが、カスタムアイコンを作ることも可能です。

froala.com

サンプル

// デフォルトは 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 には様々なメソッドが用意されているので全部は紹介できないですが、よく使うメソッドを紹介しようと思います。

froala.com

取得系

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

選択中のセルを取得する

他にもいろいろメソッドが存在するようです。

  • addFooter
  • addHeader
  • applyStyle
  • back
  • customColor
  • deleteColumn
  • deleteRow
  • horizontalAlign
  • insertColumn
  • insertRow
  • mergeCells
  • remove
  • removeFooter
  • removeHeader
  • selectCells
  • setBackground
  • showColorsPopup
  • showEditPopup
  • showInsertPopup
  • splitCellHorizontally
  • splitCellVertically
  • verticalAlign

その他

selection.save

現在のキャレットを保存する

https://froala.com/wysiwyg-editor/docs/methods/#selection.save

selection.restore

保存したキャレットの位置を復元する

https://froala.com/wysiwyg-editor/docs/methods/#selection.restore

オプションの紹介

実際に設定しているオプションをいくつか紹介します。

froala.com

画像系

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

日本語の対応表は用意されていますが、コピーしてカスタマイズしたものを利用しています。

github.com

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

イベントの紹介

froala.com

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

developer.mozilla.org

ビジュアルエディタのコアである contenteditable です。

この属性を追加した要素は編集可能になります。

おそらく世のビジュアルエディタライブラリは contenteditable を利用していると思われます。

Range と Selection

developer.mozilla.org

Range は文章の範囲を表すオブジェクトです。

developer.mozilla.org

Selection 範囲やキャレットを扱うオブジェクトです。

RangeSelection は関係性がわかりにくいのでサンプルコードを記載します。

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/ でスクリプトを実行すると左上の H1a 要素で囲まれます。

範囲選択している要素を a 要素で囲む

Froalaselection.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 (キャレット)

developer.mozilla.org

キャレット (テキストカーソルとも呼ばれる) は、テキスト入力が挿入される場所を示すために画面に表示されるインジケーターです

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.saveselection.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>');
}

このようにキャレットが移動する前後で保存・復元をすることで想定通りの箇所に挿入することができます!

インライン要素のキャレット

インライン要素への書き込みの際もキャレットを意識する必要があります。

まず Froalaa 要素の挙動について解説します。

froala.com

a 要素の末尾にキャレットがある場合

テキストを入力すると a 要素の外側に入力されます。

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

次に a 要素以外のインライン要素(spanstrong など)の挙動について解説します。

strong 要素の末尾にキャレットがある場合

テキストを入力すると strong 要素の内側に入力されます。

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

a 要素の内側へのテキストの入力、a 要素以外のインライン要素の外側へのテキストの入力を実現するためには、キャレットとゼロ幅ノーブレークスペース(\uFEFF)を組み合わせます。

developer.mozilla.org

ゼロ幅ノーブレークスペースはホワイトスペースの1つでその名の通り幅を持ちません。

このゼロ幅ノーブレークスペースをインライン要素の末尾に追加することでタグ内と外への書き込みを実現します。

a 要素

a 要素の末尾にゼロ幅ノーブレークスペース(\uFEFF)を挿入することで、「a 要素内への書き込み」と「a 要素外への書き込み」の切り替えができるようになります。

<a>hoge\uFEFF</a>fuga

インスペクタ上では &#xFEFF; と表示されます。

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

インスペクタ上では &#xFEFF; と表示されます。

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, キャレットなど知っていると良い

引き続き便利なビジュアルエディタを開発し、最高の記事を最速にユーザーに届くよう尽力していきます!

www.wantedly.com