CKEditorをカスタマイズする

リッチな WYSIWYG エディタで [CKEditor](https://ckeditor.com/) をカスタマイズする。

# 前提条件

- Rails 5.x
- [CKEditor](https://github.com/galetahub/ckeditor) の v4.2.4 を使用

以下、JavaScriptの処理は Rails に限らず使用できる。

# 不要なボタンを消す

[デフォルトの editorConfig](https://github.com/galetahub/ckeditor/blob/master/app/assets/javascripts/ckeditor/config.js) を上書きすることで表示項目をカスタマイズできる。

次の例は最低限 WYSIWYG に必要な機能だけ残し、エディタの高さを 380px 固定にした例。

```javascript:app/assets/javascripts/ckeditor/config.js
CKEDITOR.editorConfig = function (config) {
    config.toolbar_mini = [
        {
            name: 'paragraph',
            groups: ['list', 'indent', 'blocks', 'align', 'bidi'],
            items: [
                'NumberedList', 'BulletedList',
                '-',
                'Outdent', 'Indent',
                '-',
                'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'
            ]
        },
        {
            name: 'links',
            items: [
                'Link', 'Unlink'
            ]
        },
        {
            name: 'basicstyles',
            groups: ['basicstyles', 'cleanup'],
            items: [
                'Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript',
                '-',
                'RemoveFormat'
            ]
        },
        {
            name: 'insert',
            items: [
                'Image', 'Table', 'HorizontalRule'
            ]
        }
    ];

    // toolbar_mini が読み込まれる
    config.toolbar = "mini";

    // 高さを指定
    config.height = '380px';
};
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/31862/510c78f2-cc75-bfad-e438-2fa93c180e6c.png)

# i18n

locale ファイルを追加することでボタンなどの表示を i18n 対応できる。
しかし locale ファイルが適用されない箇所もあり、それは後述する。

```yml:config/locales/ja.ckeditor.yml
ja:
  ckeditor:
    confirm_delete: 'ファイルを削除してもよろしいですか？'
    buttons:
      cancel: 'キャンセル'
      upload: 'アップロード'
      delete: '削除'
      next: '次へ'
    dialog:
      title: '画像'
```


# リンク追加ダイアログをカスタマイズ

`Link` ボタンをクリックしたときに表示されるダイアログをカスタマイズする。
ダイアログはボタンがクリックされたときに JavaScript で生成される。その生成を hook する `dialogDefinition` イベントにコールバックを設定し動的にカスタマイズする必要がある。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/31862/ae1e8c9e-b094-5aa8-b0e2-e7961840e0f0.png)

ダイアログを取得し、そのタブ内の任意のフィールドを書き換えている。`.getContents` で要素を取得したり、 `.removeContents` で不要な要素を削除できる。
日本語をハードコーディングしているところは、[i18n-js](https://github.com/fnando/i18n-js)とか使って i18n 対応した方がよい。

```javascript:app/assets/javascripts/ckeditor/config.js
CKEDITOR.on('dialogDefinition', function (ev) {
    var dialogName = ev.data.name;
    var dialogDefinition = ev.data.definition;

    if (dialogName === 'link') {
        var infoTab = dialogDefinition.getContents('info');
        if (infoTab) {
            var urlField = infoTab.get('url');
            urlField['default'] = 'www.example.com';
        }

        if (dialogDefinition.getContents('advanced')) dialogDefinition.removeContents('advanced');
    }
}
```


# 画像選択ダイアログをカスタマイズ

リンクと同様に JavaScript でカスタマイズする。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/31862/454f462a-acee-4f70-06b4-fb0c330d474d.png)

```javascript:app/assets/javascripts/ckeditor/config.js
CKEDITOR.on('dialogDefinition', function (ev) {
    var dialogName = ev.data.name;
    var dialogDefinition = ev.data.definition;

    •••

    if (dialogName === 'image') {
        var uploadTab = dialogDefinition.getContents('Upload');
        if (uploadTab) {
            var upload = uploadTab.get('upload');
            if (upload) upload.label = '画像を選択してください';

            var uploadButton = uploadTab.get('uploadButton');
            if (uploadButton) uploadButton.label = 'アップロード';
        }

        var infoTab = dialogDefinition.getContents('info');
        if (infoTab) {
            infoTab.remove('txtAlt');
            infoTab.get('txtUrl')['hidden'] = true;
            infoTab.remove('txtHSpace');
            infoTab.remove('txtVSpace');
            infoTab.remove('txtBorder');
            infoTab.remove('cmbAlign');

            var browse = infoTab.get('browse');
            if (browse) browse.label = 'アップロード済みの画像を選択';
        }

        // remove unnecessary tabs
        if (dialogDefinition.getContents('Link')) dialogDefinition.removeContents('Link');
        if (dialogDefinition.getContents('advanced')) dialogDefinition.removeContents('advanced');
    }
});
```

# 画像のサイズ指定方法を変更

デフォルトだと画像のサイズ指定が、インラインの `style` タグで記述される。
`<img>` の `height` `width` 属性で指定したい場合は次の設定をする。

```javascript:app/assets/javascripts/ckeditor/config.js
CKEDITOR.editorConfig = function (config) {
    •••

    // 画像のサイズ指定をstyleタグではなく，width,height属性で設定
    config.disallowedContent = 'img{width,height}';
    config.extraAllowedContent = 'img[width,height]';
};
```


# Turbolinks と組み合わせる

`turbolinks:load` イベント内で、`<%= f.cktext_area :content %>` によってレンダリングされた全ての HTML を初期化する。

```javascript:app/assets/javascripts/ckeditor/index.js
//= require ckeditor/init.js.erb
//= require ./config

$(document).on('turbolinks:load', function () {
    $('.ckeditor').each(function () {
        CKEDITOR.replace($(this).attr('id'))
    });
});
```

# Form の Dirty Check をする

CKEditor の form と、それ以外を両方 Dirty Check するコードを示す。

まず `CKEDITOR` に `hasDirtyForm` メソッドを追加する。

```javascript:app/assets/javascripts/ckeditor/index.js
//= require ckeditor/init.js.erb
//= require ./config

CKEDITOR.hasDirtyForm = function () {
    var editors = this.instances;
    return Object.keys(editors).some(function (key) {
        return editors[key].checkDirty();
    });
};
```

`turbolinks:load` で Dirty Check を初期化する。

Form が Dirty で、まだ Submit されていない場合にのみ confirm が表示されるようになる。

```javascript:app/assets/javascripts/dirty-forms.js
$(window)
    .on('turbolinks:load', function () {
        if (!window.app) window.app = {};
        window.app.formDirty = false;
        window.app.formSubmit = false;
        window.app.dirtyMessage = '保存していない内容が失われますが、よろしいですか';

        $(':input').change(function () {
            if (!window.app.formDirty) window.app.formDirty = true;
        });
        $('form').submit(function () {
            window.app.formSubmit = true;
        });
    })
    .on("page:before-change turbolinks:before-visit", function () {
        if (window.app.formDirty || CKEDITOR.hasDirtyForm()) {
            return confirm(window.app.dirtyMessage);
        }
    })
    .bind("beforeunload", function (event) {
        if (!window.app.formSubmit &&
            (window.app.formDirty || CKEDITOR.hasDirtyForm())) {
            event.returnValue = window.app.dirtyMessage;
            return event.returnValue;
        }
    });
```