【Handsontable】RichTextエディターによる入力(CKEditor4)

# はじめに
Excelファイルをメールでやり取りしている作業をWeb化しようという試みをしている。
Excelファイルの内容自体はマスタデータに備考欄を付いた一覧表になっているので、Web化する上でグリッドライブラリーの「Handsontable」を使用する。
問題なのは備考欄の部分である。備考欄には太文字と赤文字と取消線が使用されており、 Handsontable にはRichTextエディター入力が標準で用意されてされていない、しかしHTML表示はサポートしているので実現するにはHTMLタグを使えばいいわけだが、一般の人にHTMLタグを強要するには無理である。
Handsontable にRichTextエディター入力が実現できればいいなとネットで検索したところ、下記サイトを見つけた。
https://jsfiddle.net/isiddhant/uy7y7wss/7/

Handsontable のセル上でEnterキーを押すかダブルクリックすればWYSIWYGエディター(CKEditor4)が起動して、太文字や取消線の入力が出来て反映される。まさに欲しかった機能で、これを参考に改良していけばいい。

# 改良方針
* 脱JQuery
* ウィンドウの表示位置の修正と移動の実現
* Pタグと改行コードの除去
* ツールバーを必要最小限にする

昨今の流れとして脱JQueryにしています。
ウィンドウの表示位置は画面中央にしてドラッグで移動できるように修正しました。
エディタ入力後のセルを見ると余分な隙間が出来ます。これは Pタグで囲まれてセットされてしまうのが原因なので Pタグを除外します。また、改行させた場合に brタグ だけでなく改行コードも一緒にセットされてしまうので改行コードを除外します。
今回ツールバーに必要なのは、アンドゥとリドゥと太字と取消とテキストカラーがあればいいです。それを実現するためにCKEditor4のスタンダード版からフルセット版に変更しました。
CKEditorのツールバーをカスタマイズする下記サイトを利用しました。
https://ckeditor.com/latest/samples/toolbarconfigurator/index.html#basic

## 重なり順序の変更
CKEditor をマウスで移動させると下図のように HandsonTable の選択枠やヘッダの下に表示されてしまいます。
![CKEditor4zIndex.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/074c86cb-351d-99bf-d723-bc574f9cc347.png)

これを解消したくて調査しました。

|対象|z-index|
|:--|--:|
|枠|99|
|ヘッダ行|100|
|ヘッダ列|101|
|ヘッダ角|102|

z-index を103以上にすれば CKEditorが最上位になります。今回は110にしておきます。

```javascript
cke_myid.style.zIndex = 110;
```

## 初回の表示位置
必ずではないのですが、初回表示が指定位置に変更されない上に値がセットされない状態が発生しました。
そのため、Open時にsetTimeout関数で括ることで対応しました。
現象が発生した場合、初期位置に一瞬表示されるものの指定位置に移動します。

Handsontable用の複数選択ドロップダウンのプラグインのソースリストを参考にしました。
[handsontable-chosen-editor](https://github.com/mydea/handsontable-chosen-editor/blob/master/handsontable-chosen-editor.js)

## 表示位置
現在は画面中央に表示位置を指定していますが、ドロップボックスのリストのようにセルの直下にするようにも出来ます。
ただし、セルの位置によって表示位置を制御するようなことまではしてません。

```javascript
// cke_myid.style.top = (document.documentElement.clientHeight - cke_myid.clientHeight) / 2 + 'px';
// cke_myid.style.left = (document.documentElement.clientWidth - cke_myid.clientWidth) / 2 + 'px';
cke_myid.style.top = that.TD.offsetTop + that.TD.offsetHeight + 'px';
cke_myid.style.left = that.TD.offsetLeft + 'px';
```

# デモ
セル上でEnterキーを押すかダブルクリックすればRichTextエディターが起動します。
改行は、Shift + Enter または Alt(option) + Enter になります。
※通常のテキストでの改行は、Alt(option) + Enter のみです。

デモの作成には「[ScreenToGif](https://www.screentogif.com/)」を使用しています。
![CKEditor4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/1afba630-14db-c8c1-f31f-111201a48ceb.gif)

# CodePen
QiitaではCodePenが埋め込み可能なので貼っておきます。
[jsdo.it の移行先として CodePen を使ってみる](https://qiita.com/yaju/items/e2220f9e46b02fcd28d2)

<p class="codepen" data-height="512" data-theme-id="light" data-default-tab="result" data-user="yaju-the-encoder" data-slug-hash="BajqLXy" style="height: 512px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Rich text Editor(CKEditor 4) with handsontable">
  <span>See the Pen <a href="https://codepen.io/yaju-the-encoder/pen/BajqLXy">
  Rich text Editor(CKEditor 4) with handsontable</a> by やじゅ (<a href="https://codepen.io/yaju-the-encoder">@yaju-the-encoder</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# ソースコード
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>RichText with Handsontable</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.css">
</head>
<body>
<div id="hot"></div>
<script src="https://cdn.ckeditor.com/4.11.4/full/ckeditor.js"></script>
<script src="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
  let hot;

  const woData = [
    {
      0: 100,
      1: '通常テキスト',
      2: 'あいうえお',
      3: 'あいうえお<br>かきくけこ',
    },
    {
      0: 200,
      1: 'テスト',
      2: '<strong>あいうえお</strong><br>
かきくけこ
',
      3: '<span style=\'color:#ff0000\'><strong>あいうえお</strong></span><br>
<span style=\'color:#0000ff\'>かきくけこ</span>
',
    },
  ];

  customColumns =
  [
    {data: 0, type: 'numeric'},
    {data: 1, type: 'text'},
    {data: 2, renderer: RichTextRenderer, editor: 'RichText'},
    {data: 3, renderer: RichTextRenderer, editor: 'RichText'},
  ];

  customWidth = [100, 200, 300, 300];

  container = document.getElementById('hot'),
  hot = new Handsontable(container, {
    data: woData,
    colWidths: customWidth,
    colHeaders: ['numeric', 'text', 'RichText', 'RichText'],
    rowHeaders: true,
    columns: customColumns,
    manualColumnResize: true,
    manualRowResize: true,
    contextMenu: true,
    enterMoves: {row: 0, col: 1},
    outsideClickDeselects: false,
    afterOnCellMouseDown: function(sender, e) {
      if (e.row === -1) {
        this.getInstance().deselectCell();
      }
    },
  });
  hot.alter('insert_row', hot.countRows());

  function RichTextRenderer(instance, td, row, col, prop, value, cellProperties) {
    Handsontable.renderers.HtmlRenderer.apply(this, arguments);
    td.id = 'id' + row + col;
  }
});

(function(Handsontable) {
  'use strict';

  let offsetLeft;
  let offsetTop;
  let cke_myid;
  let isMoveWindow = false;

  const onMouseMove = function(event) {
    cke_myid.style.top = event.clientY - offsetTop + 'px';
    cke_myid.style.left = event.clientX - offsetLeft + 'px';
    isMoveWindow = true;
  };

  const RichTextEditor = Handsontable.editors.TextEditor.prototype.extend();

  RichTextEditor.prototype.prepare = function(row, col, prop, td, originalValue, cellProperties) {
    Handsontable.editors.TextEditor.prototype.prepare.apply(this, arguments);
  };

  RichTextEditor.prototype.createElements = function() {
    this.$body = document.getElementsByTagName('body');

    this.TEXTAREA = document.createElement('input');
    this.TEXTAREA.setAttribute('type', 'text');
    this.TEXTAREA.setAttribute('id', 'myid');
    this.$textarea = document.getElementById('myid');

    this.textareaStyle = this.TEXTAREA.style;
    this.textareaStyle.width = 0;
    this.textareaStyle.height = 0;

    this.TEXTAREA_PARENT = document.createElement('DIV');

    this.textareaParentStyle = this.TEXTAREA_PARENT.style;
    this.textareaParentStyle.top = 0;
    this.textareaParentStyle.left = 0;

    this.TEXTAREA_PARENT.appendChild(this.TEXTAREA);

    this.instance.rootElement.appendChild(this.TEXTAREA_PARENT);

    const that = this;

    CKEDITOR.replace(this.TEXTAREA.id, {
      on: {
        'instanceReady': function(ev) {
        },
        'loaded': function(ev) {
        },
        'key': function(event) {
          if (event.data.keyCode == CKEDITOR.ALT + 13) {
            this.execCommand('shiftEnter');
          } else if (event.data.keyCode == 13) {
            event.cancel();
            that.finishEditing(false, false);
          } else if (event.data.keyCode == 27) {
            event.cancel();
            that.finishEditing(true, false);
          }
        },
      },
      // ツールバーグループのカスタマイズ
      toolbarGroups: [
        {name: 'document', groups: ['mode', 'document', 'doctools']},
        {name: 'clipboard', groups: ['clipboard', 'undo']},
        {name: 'editing', groups: ['find', 'selection', 'spellchecker', 'editing']},
        {name: 'forms', groups: ['forms']},
        {name: 'basicstyles', groups: ['basicstyles', 'cleanup']},
        {name: 'paragraph', groups: ['list', 'indent', 'blocks', 'align', 'bidi', 'paragraph']},
        {name: 'links', groups: ['links']},
        {name: 'insert', groups: ['insert']},
        {name: 'styles', groups: ['styles']},
        {name: 'colors', groups: ['colors']},
        {name: 'tools', groups: ['tools']},
        {name: 'others', groups: ['others']},
        {name: 'about', groups: ['about']},
      ],
      removeButtons: 'Source,Save,Templates,NewPage,Preview,Print,PasteFromWord,PasteText,' +
        'Find,Replace,Cut,Copy,Paste,SelectAll,Scayt,Form,Checkbox,Radio,TextField,' +
        'Textarea,Select,ImageButton,HiddenField,Subscript,Superscript,CopyFormatting,' +
        'RemoveFormat,NumberedList,BulletedList,Outdent,Indent,Blockquote,CreateDiv,' +
        'JustifyLeft,JustifyCenter,JustifyRight,JustifyBlock,BidiLtr,BidiRtl,' +
        'Language,Anchor,Unlink,Link,Image,Flash,Table,HorizontalRule,Smiley,' +
        'SpecialChar,PageBreak,Iframe,Styles,Format,Font,FontSize,' +
        'BGColor,Maximize,About,ShowBlocks,Italic,Underline,Button',
      width: '455px',
      colorButton_colors: 'F00,00F,0F0,0FF,FF0,F0F',
    });

    this.instance._registerTimeout(setTimeout(function() {
      that.refreshDimensions();
    }, 0));
  };

  RichTextEditor.prototype.open = function(keyboardEvent) {
    this.refreshDimensions();

    const that = this;

    if (keyboardEvent) {
      this.setValue(this.TD.innerHTML);
    }
    const editor = CKEDITOR.instances[that.TEXTAREA.id];

    setTimeout(function() {
      cke_myid = document.getElementById('cke_myid');
      cke_myid.style.position = 'absolute';
      cke_myid.style.zIndex = 103;
      cke_myid.style.top = (document.documentElement.clientHeight - cke_myid.clientHeight) / 2 + 'px';
      cke_myid.style.left = (document.documentElement.clientWidth - cke_myid.clientWidth) / 2 + 'px';
      // cke_myid.style.top = that.TD.offsetTop + that.TD.offsetHeight + 'px';
      // cke_myid.style.left = that.TD.offsetLeft + 'px';

      const toolbar = document.getElementById('cke_1_top');

      toolbar.onmousedown = function(event) {
        offsetLeft = event.clientX - parseInt(cke_myid.style.left);
        offsetTop = event.clientY - parseInt(cke_myid.style.top);
        document.addEventListener('mousemove', onMouseMove);
      };
      toolbar.onmouseup = function(event) {
        document.removeEventListener('mousemove', onMouseMove);
        if (isMoveWindow) {
          isMoveWindow = false;
          editor.focus();
        }
      };

      editor.focus();
      editor.setData(that.TEXTAREA.value);
    }, 1);
  };

  RichTextEditor.prototype.init = function() {
    Handsontable.editors.TextEditor.prototype.init.apply(this, arguments);
  };

  RichTextEditor.prototype.close = function() {
    this.instance.listen();
    this.textareaStyle.display = 'none';
    Handsontable.editors.TextEditor.prototype.close.apply(this, arguments);
  };

  RichTextEditor.prototype.val = function(value) {
    if (typeof value == 'undefined') {
      return this.$textarea.val();
    } else {
      this.$textarea.val(value);
    }
  };

  RichTextEditor.prototype.getValue = function() {
    let val = CKEDITOR.instances[this.TEXTAREA.id].getData();
    val = val.replace(/\r?\n/g, '');
    val = val.replace(/^<P>/i, '');
    val = val.replace(/<\/P>$/i, '');
    return val;
  };

  RichTextEditor.prototype.beginEditing = function(initialValue) {
    const onBeginEditing = this.instance.getSettings().onBeginEditing;
    if (onBeginEditing && onBeginEditing() === false) {
      return;
    }

    Handsontable.editors.TextEditor.prototype.beginEditing.apply(this, arguments);
  };

  RichTextEditor.prototype.finishEditing = function(isCancelled, ctrlDown) {
    return Handsontable.editors.TextEditor.prototype.finishEditing.apply(this, arguments);
  };

  Handsontable.editors.RichTextEditor = RichTextEditor;
  Handsontable.editors.registerEditor('RichText', RichTextEditor);
})(Handsontable);
</script>
</body>
</html>
```

# 注意
Handsontable は、MITライセンスの6.2.2を利用しています。 Ver ７.０以降はPRO版で有償となります。
CKEditor4は、商用利用については有償となります。CKEditor4のサポートは2023年までとなっています。

# 最後に
当初はCKEditor４ではなく、CKEditor５や他のWYSIWYGエディター(MITライセンス)に挑戦しようとしたのですが、仕組みを理解せずにやろうとして訳がわからなくなってしまい断念、先ずは地道にCKEditor４からやることにしました。
仕組みを理解したので、他のエディターにした記事を後で書きます。

* [【Handsontable】RichTextエディターによる入力(CKEditor5)](https://qiita.com/yaju/items/644d07a0cf3c309ab524)

今回、WYSIWYG（ウィジウィグ）エディターを知る機会が出来て良かったです。