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

# はじめに
前記事「[【Handsontable】RichTextエディターによる入力(CKEditor4)](https://qiita.com/yaju/items/6dbaf147aed59c2de699)」では、RichTextエディターとして「CKEditor4」を用いていましたが、現在は「CKEditor５」がリリースしていますので、CKEditor５ に対応してみました。

# CKEditor4とCKEditor５の違い
機能的な内容は下記記事を参照してください。
[What is different about CKEditor 5 compared to CKEditor 4?](https://support.ckeditor.com/hc/en-us/articles/115005278429-What-is-different-about-CKEditor-5-compared-to-CKEditor-4-)

## ツールバーのカスタマイズ
今回のHandsontableのRichTextエディターとしては、CKEditor4がツールバーのフル状態から必要なものを除外していけたのですが、CKEditor５では標準ツールバーに取消線やテキストカラーは存在せず、追加するには以下の手順が必要になります。
[CKEditor５ adding-a-plugin-to-a-build](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/installing-plugins.html#adding-a-plugin-to-a-build)

1. ckeditor5-editor-classic のリポジトリを clone してくる
2. 追加したいプラグインをインストール
3. ClassicEditor.builtinPlugins に 2. のプラグインを追加
4. build する

今回は一般に公開する上ではツールバーは標準のままとするため、アンドゥとリドゥとボールドのみとしました。取消線とテキストカラーは除外しています。

## 戻り値の編集
CKEditor4版では、改行コードと先頭のPタグと末尾のPタグを除外しています。

```javascript:CKEditor4版
RichTextEditor.prototype.getValue = function () {
  var 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;
};
```

CKEditor５版では、改行コードとPタグとnbspを除外しています。なんか、Pタグとか無駄にセットされてしまうんですよね。

```javascript:CKEditor５版
RichTextEditor.prototype.getValue = function () {
  var val = myEditor.getData();
  val = val.replace(/\r?\n/g,"");
        val = val.replace(/&nbsp;/ig, "");
        val = val.replace(/<P>/ig, "");
        val = val.replace(/<\/P>/ig, "");
  return val;
};
```

## セル移動の無効化
CKEditor4版では発生しなかったのですが、セルのEnterキーで起動させた場合に矢印キーを押すと画面が閉じてセル側が移動してしまいます。またBSキーを押すと画面が閉じてセルの中身が消えてしまいます。
しかし、セルをダブルクリックで起動した場合は矢印キーでキャレットを移動させることができるし、BSキーで文字を消すことができます。

この対処方法が分からなく原点の使い方を見直したところ、「矢印キーの入力によるセル移動を無効にする」の記載を見つけて、なんとか対処することができました。
[Handsontable 使い方メモ１（基本）](https://qiita.com/opengl-8080/items/ac19deec388c357cd498#editor)

```javascript
Handsontable.hooks.add('beforeKeyDown', function (e) {
  var editor = this.getActiveEditor();

  if (editor.cellProperties.editor != 'RichText') return;

  if (editor.isOpened() && (isArrowKey(e.keyCode) || e.keyCode == 13 || e.keyCode == 8)) {
    e.stopImmediatePropagation();
  }
});

function isArrowKey(keyCode) {
  return Handsontable.helper.isKey(keyCode, 'ARROW_UP|ARROW_DOWN|ARROW_LEFT|ARROW_RIGHT');
}
```

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

```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 になります。

デモの作成には「[ScreenToGif](https://www.screentogif.com/)」を使用しています。
![CKEditor5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/739d7105-ecc3-8e94-d1e6-aed66f031188.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="PoZxxJp" 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(CKEditor5) with handsontable">
  <span>See the Pen <a href="https://codepen.io/yaju-the-encoder/pen/PoZxxJp">
  Rich text Editor(CKEditor5) 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
<html>
<head>
<meta charset="UTF-8">
<title>RichText with Handsontable</title>
<link href="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.css" rel="stylesheet" >
<style>
.ck-editor__editable {
    min-height: 200px;
}
.ck.ck-editor {
    min-width: 400px;
}
</style>
</head>
<body>
<script src="https://cdn.ckeditor.com/ckeditor5/20.0.0/classic/ckeditor.js"></script>
<script src="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.js"></script>
<div id="hot"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
  let hot;

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

  const customColumns =
  [
    {data: 0, type: 'numeric'},
    {data: 1, type: 'text'},
    {data: 2, renderer: RichTextRenderer, editor: 'RichText'},
    {data: 3, renderer: RichTextRenderer, editor: 'RichText'},
  ];
  const 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());

  Handsontable.hooks.add('beforeKeyDown', function(e) {
    const editor = this.getActiveEditor();

    if (editor.cellProperties.editor != 'RichText') return;

    if (editor.isOpened() && (isArrowKey(e.keyCode) ||
      e.keyCode == 13 || e.keyCode == 8)) {
      e.stopImmediatePropagation();
    }
  });

  function isArrowKey(keyCode) {
    return Handsontable.helper.isKey(keyCode, 'ARROW_UP|ARROW_DOWN|ARROW_LEFT|ARROW_RIGHT');
  }

  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 myEditor;
  let isMoveWindow = false;
  const RichTextEditor = Handsontable.editors.TextEditor.prototype.extend();

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

  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;

    ClassicEditor.create(this.TEXTAREA, {
      toolbar: ['undo', 'redo', 'bold'],
    })
        .then( (editor) => {
          myEditor = editor;
          editor.editing.view.document.on( 'keydown', ( event, data ) => {
            if ( data.keyCode == 13 && data.altKey ) {
              document.execCommand('shiftEnter');
            } else if (data.keyCode == 13 && !data.shiftKey) {
              data.stopPropagation();
              data.preventDefault();
              event.stop();
              that.finishEditing(false, false);
            } else if (data.keyCode == 27) {
              event.stop();
              data.preventDefault();
              that.finishEditing(true, false);
            }
          });
        } )
        .catch( (error) => {
          console.error( error );
        } );

    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);
    }

    setTimeout(function() {
      cke_myid = document.querySelectorAll('[role="application"]')[0];
      cke_myid.style.position = 'absolute';
      cke_myid.style.zIndex = 110;
      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.querySelectorAll('[role="toolbar"]')[0];

      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;
          myEditor.editing.view.focus();
        }
      };

      myEditor.editing.view.focus();
      myEditor.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 = myEditor.getData();
    val = val.replace(/\r?\n/g, '');
    val = val.replace(/&nbsp;/ig, '');
    val = val.replace(/<P>/ig, '');
    val = val.replace(/<\/P>/ig, '');
    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版で有償となります。
CKEditor5は、商用利用については有償となります。

# 最後に
CKEditorはとてもいいのですが、やはり商用利用については有償なのが気になりますね。
MITライセンスのWYSIWYGエディターが幾つかあるので、今回の目的に適したものを次回挑戦してみます。