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

# はじめに
Handsontable のRichTextエディターとしてこれまで「CKEditor」を用いてきました。

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

しかし、CKEditorはとてもいいのですが、やはり商用利用については有償なのが気になります。
そこで今回、MITライセンスまたはBSDライセンス（BSD-3-Clause）のWYSIWYGエディターの幾つかある中で、アンドゥ・リドゥ、太字、取消線、色が標準で揃っている「[Summernote](https://summernote.org/)」を選択しました。
Summernoteは、Bootstrapの超シンプルなWYSIWYGエディターというキャッチフレーズですが今回はより軽量にしたいため、Bootstrapを除外した「summernote-lite」を使用しました。
本当はJQueryも除外したかったんですが現状は必須となっています。脱JQueryが現在主流となっているので、新しいSummernoteの改良に期待します。

## 使用環境
* Summernote lite ver 0.8.18
* jquery 3.4.1
* handsontable 6.2.2

# CKEditor版との違い
プログラムの作りとしては、CKEditor５版に近いです。
縦幅は小さくして、ツールバーは標準である程度揃っているので必要なものだけにカスタマイズしています。
文字色はデフォルトで赤色にしています。

```javascript
width: 400,
height: 100,
toolbar: [
  ['misk',  ['undo', 'redo']],
  ['style', ['bold', 'strikethrough']],
  ['color', ['color']]
],
colorButton: {
  foreColor: 'red',
  backColor: 'transparent'
}
```

## 戻り値の編集
先頭と末尾に BRタグが付くようなので除外するようにしました。P タグはこれまで同様に全て除外しています。

```javascript
RichTextEditor.prototype.getValue = function () {
  var val = $('#myid').summernote('code');
  val = val.replace(/<P>/ig, "");
  val = val.replace(/<\/P>/ig, "");
  val = val.replace(/^<BR>/i, "");
  val = val.replace(/<BR>$/i, "");
  return val;
};
```

## 背景色
エディターの背景色が透明になっていたので、CSSにて白色に指定しています。

```css
.note-editable { 
  background-color: white !important;
}
```

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

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

```

# デモ
セル上でEnterキーを押すかダブルクリックすればRichTextエディターが起動します。
改行は、Shift + Enter になります。

デモの作成には「[ScreenToGif](https://www.screentogif.com/)」を使用しています。
![Summernote.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/fbbfb313-9b11-63e4-f27f-f317f795e413.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="zYrQbEN" 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(Summernote) with handsontable">
  <span>See the Pen <a href="https://codepen.io/yaju-the-encoder/pen/zYrQbEN">
  Rich text Editor(Summernote) 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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.css">
<style>
.note-editable { 
  background-color: white !important;
}
</style>
</head>
<body>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/lang/summernote-ja-JP.js"></script>

<div id="hot"></div>
<script>
$(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>
',
    },
  ];

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

  const onMouseMove = function(event) {
    editor.style.top = event.clientY - offsetTop + 'px';
    editor.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.TEXTAREA = document.createElement('input');
    this.TEXTAREA.setAttribute('type', 'text');
    this.TEXTAREA.setAttribute('id', '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;

    $('#myid').summernote({
      width: 400,
      height: 100,
      toolbar: [
        ['misk', ['undo', 'redo']],
        ['style', ['bold', 'strikethrough']],
        ['color', ['color']],
      ],
      colorButton: {
        foreColor: 'red',
        backColor: 'transparent',
      },
      callbacks: {
        onKeydown: function(event) {
          if (!isOpen) return;

          if (event.keyCode == 13 && !event.shiftKey) {
            event.stopImmediatePropagation();
            that.finishEditing(false, false);
          } else if (event.keyCode == 27) {
            event.stopImmediatePropagation();
            that.finishEditing(true, false);
          }
        },
      },
    });

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

  RichTextEditor.prototype.open = function(keyboardEvent) {
    isOpen = true;

    this.refreshDimensions();

    const that = this;

    if (keyboardEvent) {
      this.setValue(this.TD.innerHTML);
    }

    setTimeout(function() {
      const toolbar = document.querySelectorAll('[role="toolbar"]')[0];
      editor = toolbar.parentNode;
      editor.style.position = 'absolute';
      editor.style.zIndex = 110;
      editor.style.top = (document.documentElement.clientHeight - editor.clientHeight) / 2 + 'px';
      editor.style.left = (document.documentElement.clientWidth - editor.clientWidth) / 2 + 'px';
      // editor.style.top = that.TD.offsetTop + that.TD.offsetHeight + 'px';
      // editor.style.left = that.TD.offsetLeft + 'px';

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

      $('#myid').summernote('focus');
      $('#myid').summernote('code', that.TEXTAREA.value);
    }, 1);
  };

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

  RichTextEditor.prototype.close = function() {
    isOpen = false;
    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 = $('#myid').summernote('code');
    val = val.replace(/<P>/ig, '');
    val = val.replace(/<\/P>/ig, '');
    val = val.replace(/^<BR>/i, '');
    val = val.replace(/<BR>$/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>
```

# 最後に
Summernote版を作成していて、おかしな動作をしているところを CKEditor版でも確認したら同じ現象になっていたので修正して反映していたりします。修正箇所は追記して CKEditor版側の記事に記載してあります。

BSDライセンス（BSD-3-Clause）のWYSIWYGエディターで人気なものに「[Quill](https://qiita.com/mima_ita/items/bacf3ca3c078d1e6c70f)」があり、IEは非推奨なのですが、次回はこれに挑戦してみます。