Reactで編集できるHタグを作る


WYSIWYG的なことをやります。

# はじめに

WYSIWYG とは、
>見たままのものを実際に作成出力するという言葉のWhat You See Is What You Getの頭文字をとったもの
（[WYSIWYGエディター - CMS用語集 | CMS - Web Meister(ウェブマイスター) 静的コンテンツマーケティングシステム](https://www.web-meister.jp/guide/glossary/glossary_a/wysiwyg_editor.html#:~:text=WYSIWYG%E3%81%A8%E3%81%AF%E3%80%81%E8%A6%8B%E3%81%9F,%E3%82%88%E3%81%86%E3%81%AA%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E3%81%93%E3%81%A8%E3%80%82)より）

とあるように、書いた文字がレイアウト等そのままに編集できることであり、
有名なOSSでは [Draft.js](https://draftjs.org/) や、 [Editor.js](https://editorjs.io/) といったものがあります。

今回はそういったものを使っていく、ではなく一部自作してみようという記事になります。

## Editorjsで不満だった点

端的に言えば、「**Editorjs は自由すぎた**」というのが不満な点でした。

### Editorjs のデータ構造

Editorjsでは、文章を以下のようなJSONで表します。

```json
{
  "time": "<timestamp>",
  "blocks": [ "<block>" ],
  "version": "<version>"
}
```

blocksの中では、各要素がブロックという単位で記述されていて、
`type: "header"` なブロックは見出しを表し、 `type: "paragraph"` ではそのブロックは段落を表します。
それぞれのブロックの type によって、 `hタグ` や `pタグ` に置換されてレンダリングされ、
それらにCSSがあたることで WYSIWYG を実現しています。

### Editorjs は「文書」を書ける

Editorjs は複数ブロックで構成される「文書」のエディタです。
ユーザーが望むままにブロック（見出しや段落）を追加・編集することができます。

具体的にはブロック内でエンターキーを押すと次の `type: "paragraph"` なブロックが挿入され、
そこにカーソルが移りますし、
気が変わったら `type: "header"` なブロックを paragraphに変更することができます。（逆もしかり）

これが Editorjs のいいところですが、
一方で一定のフォーマットで入力させたい場合には不利に働く場合があります。

### ユーザーが何を意図したブロックなのかわからない

ただの「ブロックの配列」になってしまうと、そこにはユーザーのどんな意図が含まれていたのか、
わからなくなってしまいます。「文書のタイトルは `level:1` の `type:"header"` で書くこと」といった
ルールを決めるケースもあるのかもしれません。
（それはそれでいいのかもしれない）

↓のように、metadataを追加できるようにする計画もあるにはありそうですが
https://github.com/codex-team/editor.js/issues/727

## 今回書く内容

というわけで、今回は編集可能なヘッダタグを作成してみよう、という記事になります。

# 実装

HTML5の `contenteditable` 属性を使用していきます。
MDN にドキュメントがあります。
[contenteditable - HTML: HyperText Markup Language | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable)

## ヘッダータグを編集可能にする

`contenteditable="true"` を指定したタグは編集可能になるとありますが、本当でしょうか？

```jsx
() => <h1 contentEditable>h1#contentEditable</h1>;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/79dafce0-27f9-99c6-7921-e2c826e931d7.png)

本当でした（本当じゃなかったら、ここで記事終わり）。

### 編集中の外枠を消す

編集中の外枠のスタイルは `:focus` の `outline` です。

```css
h1:focus {
  outline: none;
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/c98af44a-7446-c707-bb1c-702fa8ee8ca1.png)

消えますね。

### 編集中カーソルの色を変える

`input`タグ同様、 `caret-color` でカーソルの色を変えられます。

```css
h1:focus {
  outline: none;
  caret-color: red;
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/cf395fce-5218-b939-92a8-58664c600dab.png)

## contenteditableにおける改行処理のキャンセル

改行してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/3103e054-678b-1aec-cd07-2301705d914c.png)


↓「検証」の Elements で見てみた結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/45454747-427f-9cc0-f041-d556b59f6e93.png)

**`h1`タグの中の`div`ナンデ？！**
contenteditable時の仕様についてはブラウザによって異なるようで、その闇は↓の記事にまとまっています。

[noteと"contenteditable"｜ct8ker｜note](https://note.com/ct8ker/n/n037f6ba3c318)

これ読むと、WYSIWYGエディタのOSSには足を向けて眠ることができなくなりますね・・・

今回は、改行できないヘッダーを作成しよう、
というわけでエンター押したらフォーカス外すようにしましょう。

```jsx
function H1Editable({ text }) {
  const ref = useRef(null);
  return (
    <h1
      contentEditable
      ref={ref}
      onKeyDown={(e) => {
        if (e.which === 13) ref.current.blur();
      }}
    >
      {text}
    </h1>
  );
}
```

いいぞ。

## 変更検知を受け取る

`input`タグではないので、`onChange`は使用できません。代わりに`onInput`を使用していきます。

```jsx
function H1Editable({ text, onChange }) {
  const ref = useRef(null);
  return (
    <h1
      contentEditable
      ref={ref}
      onInput={() => {
        onChange({ target: { value: ref.current.innerText } });
        const oSel = document.getSelection();
        console.log(oSel.getRangeAt(0));
      }}
      onKeyDown={(e) => {
        if (e.which === 13) ref.current.blur();
      }}
    >
      {text}
    </h1>
  );
}

function App() {
  return (
    <div className="App-header" style={{ padding: 20 }}>
      <H1Editable onChange={(e) => console.log(e.target.value)}>h1#contentEditable</H1Editable>
    </div>
  );
}
```

propsに `onChange` を入れて、親のコンポーネントでハンドルします。とりあえず `console.log` します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328319/cc5fdb35-b111-8f92-d11b-51b53e07fcdd.png)

contentとEditableの間に hoge と入力してみました。
ちゃんとconsole.logされていますね

### 親コンポーネントの state を使用する

ここで、以下のように App の state で入力内容を持ってみましょう。

```jsx
function App() {
  const [h1Content, setH1Content] = useState("h1#contentEditable");

  const handleChange = useCallback(
    (e) => {
      setH1Content(e.target.value);
    },
    [setH1Content]
  );

  return (
    <div className="App-header" style={{ padding: 20 }}>
      <H1Editable onChange={handleChange} text={h1Content} />
      <input onChange={handleChange} type="text" value={h1Content}></input>
    </div>
  );
}
```

すると、入力が正しくできなくなることがわかると思います。
（比較用に input タグを置きましたが、そちらは入力できると思います。）

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">伝わらないモノマネ<br>「confluenceで複数人で同じ行を編集してしまったとき」 <a href="https://t.co/1u6OLDirBz">pic.twitter.com/1u6OLDirBz</a></p>&mdash; いしぐ (@yoh1496) <a href="https://twitter.com/yoh1496/status/1271826821202362371?ref_src=twsrc%5Etfw">June 13, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

起きる原因としては、propsが変わるたびに `h1Editable` の再レンダリングが走り、
結果としてカーソルが初期位置（0文字目の位置）に移動してしまうことが挙げられます。

今回は暫定対処ではありますが、再レンダリングを抑制する方向で対処してみたいと思います。

### shouldComponentUpdate で再レンダリング抑止

ここまでは FunctionComponent として実装してきましたが、 shouldComponentUpdate が使いたいので、
ClassComponent で行きます。

```jsx
class H1Editable extends React.Component {
  constructor(props) {
    super(props);
    this.ref = React.createRef();
  }
  shouldComponentUpdate(nextProps) {
    return nextProps.text !== this.ref.current.innerText;
  }
  render() {
    return (
      <h1
        contentEditable
        ref={this.ref}
        onInput={() => {
          this.props.onChange({
            target: { value: this.ref.current.innerText },
          });
        }}
        onKeyDown={(e) => {
          if (e.which === 13) this.ref.current.blur();
        }}
      >
        {this.props.text}
      </h1>
    );
  }
}
```

やってることはほぼ変わりませんが、
ポイントは `nextProps.text !== this.ref.current.innerText` であることを判定して、
もし `false` ならば更新なしとみなし再レンダリングを行わず、 `true` ならば再レンダリングを行います。

こうすることで、何らかの原因で親コンポーネントの `h1Content` に更新があった場合は再レンダリングされますが、
**`h1Editable` 自体が原因となって `h1Content` が更新された場合は再レンダリングされなく**なります。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">できた。shouldComponentUpdateか。 <a href="https://t.co/sLnRbuShsG">pic.twitter.com/sLnRbuShsG</a></p>&mdash; いしぐ (@yoh1496) <a href="https://twitter.com/yoh1496/status/1271834579494563841?ref_src=twsrc%5Etfw">June 13, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

当然、複数人で編集するケース（オンラインエディタなど）は、ガンガン外部要因で値が変わりますので、
カーソル飛びまくりますし、別の対処方法を検討していく必要があります。

# 考察

一旦目指すものはできましたが、想定されるツッコミを考えてみます。

## 「ヘッダーのスタイルを適用した input タグを使えば？」

これは非常に正しい。
インタフェースを定義する上で、「見出し」のタグに「文字入力」機能をつけるなど言語道断で、
このやり方が正攻法だと思います。

おそらくこれが活きる状況としては、
**「レンダリングされたhtmlをそのまま提供する場合」**でしょうか。
今回の方法はあくまでヘッダタグを表示しているので、
レンダリングされたhtmlをそのままペロッと他人に提供してもちゃんと「見出し」としての体裁は保つはずです。

まぁあとは、
**事前にどんなスタイルが適用されるか知らされず、CSS編集不可で、WYSIWYGを実装しろと言われた場合**
ぐらいでしょうか。（あるかそんな状況？）

## 「Previewボタンを作れば？」

WYSIWYGやめろ。


# 終わりに

以上、WYSIWYG的なことをやってみる記事でした。
今回 `contenteditable` 属性を知ることができてとてもためになりました。

# 参考URL

- [contenteditable - HTML: HyperText Markup Language | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable)
- [noteと"contenteditable"｜ct8ker｜note](https://note.com/ct8ker/n/n037f6ba3c318)