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

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

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

CKEditorは商用利用については有償ということで、前回は商用利用でも無償に使えるものとしてMITライセンスの「[Summernote](https://summernote.org/)」にしました。
今回はMITライセンスに近いBSDライセンス（BSD-3-Clause）のWYSIWYGエディター「[Quill](https://quilljs.com/)」を使用します。
Quillは、GitHubのスターの数が1万を超える人気でパワフルなWYSIWYGエディターというキャッチフレーズです。
Summernoteと違いJQueryは不要となっています。ただし、IEは非推奨です。

# 使用環境
Quill ver 1.3.6
handsontable 6.2.2

# Summernote版との違い
アンドゥとリドゥは内部機能としてはあるが標準でボタンが付いていないので外しました。その代わり書式クリアーのボタンを追加してあります。背景色のボタンはやめましたが、これは標準にあるので簡単に追加できます。

```javascript
this.quill = new Quill(this.editableDiv, {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'strike', 'clean'], 
      [{'color': []}]
    ],
  }
});
```

# 戻り値の編集
P タグはこれまで同様に全て除外しています。
Quillの場合、P タグは改行の役割もあるため、BR タグに変換して調整しています。また、ql-cursor という変なゴミが付くので削除しています。

```javascript
RichTextEditor.prototype.getValue = function() {
  let val = this.quill.root.innerHTML;
  val = val.replace(/<\/p><p>/ig, '</br>');
  val = val.replace(/<p>/ig, '');
  val = val.replace(/<\/p>/ig, '');
  val = val.replace(/<br>/ig, '');
  val = val.replace(/<strong><span class="ql-cursor">.*?<\/span><\/strong>/ig,'')
  for (let i=0; i<3; i++) {
    val = val.replace(/^<\/br>/i, '');
    val = val.replace(/<\/br>$/i, '');
  }

  return val;
};
```

## 生成の違い
これまでは、RichTextEditor.prototype.createElements 内でエディターの生成を行っていたのですが、Quillの場合は正しくエディターの生成が出来なかったため、 RichTextEditor.prototype.prepare 内でエディターの生成を行っています。

Quill版を作成する上で最初にHandsontable の公式Twitterで、Quillを使用したカスタムエディターのサンプルの[jsfiddle](http://jsfiddle.net/ouabc4y5/)をたまたま見つけました。
https://twitter.com/handsontable/status/908001828503158785?lang=ja
このままでは動きは見れないため、フォークしてライブラリーを変更して動作するようになりました。
https://jsfiddle.net/yaju3D/8fpw31Ly/
これを元に少しずつ変更を行ったのですが、createElements への変更だけは解決出来ませんでした。

## 背景色
エディターの背景色が透明になっていたので、CSSにて白色に指定しています。また、Quillのツールバーの標準の背景色は素っ気ないためグレーっぽい色を指定しています。 

```css
.ql-container {
  background-color: white;
}
.ql-toolbar {
  display: block;
  background-color: #f5f5f5;
}
```

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

```javascript
// editor.style.top = (document.documentElement.clientHeight - quillHeight) / 2 + 'px';
// editor.style.left = (document.documentElement.clientWidth - quillWidth) / 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/)」を使用しています。
![Quill.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/85dc0670-2223-e3ac-5b70-4a17dce596e9.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="LYNVxKY" 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(Quill) with handsontable">
  <span>See the Pen <a href="https://codepen.io/yaju-the-encoder/pen/LYNVxKY">
  Rich text Editor(Quill) 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.quilljs.com/1.3.6/quill.snow.css">
<style>
.ql-container {
  background-color: white;
}
.quillEditor .handsontableInput {
  display: none;
}
.ql-toolbar {
  display: block;
  background-color: #f5f5f5;
}
</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://cdn.quilljs.com/1.3.6/quill.min.js"></script>

<div id="hot"></div>
<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());

  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 quillWidth = 400;
  const quillHeight = 100;

  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) {
    this.TD = td;
    this.row = row;
    this.col = col;
    this.prop = prop;
    this.originalValue = originalValue;
    this.cellProperties = cellProperties;
    this.state = 'STATE_VIRGIN';

    if (this.editableDiv) return;

    this.editableDiv = document.createElement('div');
    this.editableDiv.innerHTML = originalValue;
    this.TEXTAREA_PARENT.className += ' quillEditor';
    this.TEXTAREA_PARENT.appendChild(this.editableDiv);
    this.TEXTAREA_PARENT.style.width = quillWidth + 'px';
    this.TEXTAREA_PARENT.style.height = quillHeight + 'px';
    this.TEXTAREA_PARENT.style.display = 'none';

    this.quill = new Quill(this.editableDiv, {
      theme: 'snow',
      modules: {
        toolbar: [
          ['bold', 'strike', 'clean'],
          [{'color': []}],
        ],
      },
    });

    const that = this;

    this.quill.root.addEventListener('keydown', function(event) {
      if (!isOpen) return;

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

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

  Handsontable.cellTypes.registerCellType('RichText', {
    editor: RichTextEditor,
  });

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

    this.refreshDimensions();

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

    const that = this;

    setTimeout(function() {
      const toolbar = document.getElementsByClassName('ql-toolbar')[0];
      editor = toolbar.parentNode;
      editor.style.top = (document.documentElement.clientHeight - quillHeight) / 2 + 'px';
      editor.style.left = (document.documentElement.clientWidth - quillWidth) / 2 + 'px';
      // editor.style.top = that.TD.offsetTop + that.TD.offsetHeight + 'px';
      // editor.style.left = that.TD.offsetLeft + 'px';
      editor.style.display = '';

      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;
          that.quill.setSelection(0, 0);
        }
      };

      that.quill.setText('');
      that.quill.pasteHTML(0, that.TD.innerHTML);
      // Set focus;
      that.quill.setSelection(0, 0);
    }, 1);
  };

  RichTextEditor.prototype.close = function() {
    isOpen = false;
    this.instance.listen();
    this.TEXTAREA_PARENT.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 = this.quill.root.innerHTML;
    val = val.replace(/<\/p><p>/ig, '</br>');
    val = val.replace(/<p>/ig, '');
    val = val.replace(/<\/p>/ig, '');
    val = val.replace(/<br>/ig, '');
    val = val.replace(/<strong><span class="ql-cursor">.*?<\/span><\/strong>/ig,'')
    for (let i=0; i<3; i++) {
      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);
</script>
</body>
</html>
```

# 最後に
とりあえずRichTextエディターシリーズはこれで終わりにします。
個人的には、Summernoteがお勧めでJQuery依存が無くなればもっといいんですが、今後に期待です。

>特に表好きな日本人は、データ入力にも表形式のデザインを好む傾向にあります。
[独自の入力グリッドを持ったWPFアプリケーションの作成](https://codezine.jp/article/detail/6290)

コロナ禍もあり非エンジニアの方が[サイボウズのkintone](https://kintone.cybozu.co.jp/)でExcelライクなHandsontableという表形式のグリッドライブラリーを使用する際に、備考欄に太字や取消線や文字色を使えたらということも考慮して開発してきました。
いつか役に立てばいいですけどね。

今回、デモ画像とCodePenとソースコードの貼り付けまでしてみたのですが、修正があった場合に直すのが面倒なんでCodePenだけにすれば良かったね。